Compare commits

...

5 commits

Author SHA1 Message Date
PeterChrz
8f34a1e36b
fix(buzz): require durable replay persistence 2026-08-25 07:37:23 -04:00
PeterChrz
b8bf0ab34f
fix(buzz): restore relay-driven NIP-42 subscription 2026-08-25 00:25:55 -04:00
PeterChrz
02961af54f
fix(buzz): suppress replayed events after restart 2026-08-25 00:25:21 -04:00
PeterChrz
23765f756f
fix(buzz): wait for NIP-42 challenge before calling Auth()
The go-nostr RelayConnect returns immediately after the WebSocket
handshake, but the relay has not yet sent the AUTH challenge. Calling
relay.Auth() right away signs the auth event with an empty challenge
tag, which the Buzz relay rejects permanently ('verification failed'
then 'authentication already failed' on retries).

Add a 2s sleep between RelayConnect and Auth() to give the background
read loop time to receive and store the challenge. The relay typically
delivers it within a few hundred milliseconds.
2026-08-11 21:59:20 -04:00
PeterChrz
9b4df86182
feat(channels): add Buzz (Nostr relay) channel
Implements a Buzz channel backed by a Nostr relay:
- kind:9 events scoped by "h" tag, "p" tags for mentions
- NIP-42 (kind:22242) auth before subscription
- optional threaded replies via "e" tag
- registered as config.ChannelBuzz with BuzzSettings
2026-08-11 21:41:16 -04:00
10 changed files with 1352 additions and 0 deletions

13
go.mod
View file

@ -32,6 +32,7 @@ require (
github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/muesli/termenv v0.16.0 github.com/muesli/termenv v0.16.0
github.com/mymmrac/telego v1.10.0 github.com/mymmrac/telego v1.10.0
github.com/nbd-wtf/go-nostr v0.52.3
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0 github.com/openai/openai-go/v3 v3.22.0
github.com/pion/rtp v1.10.2 github.com/pion/rtp v1.10.2
@ -60,6 +61,7 @@ require (
filippo.io/edwards25519 v1.2.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
@ -76,6 +78,9 @@ require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/beeper/argo-go v1.1.2 // indirect github.com/beeper/argo-go v1.1.2 // indirect
github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect
github.com/btcsuite/btcd/btcutil v1.1.5 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/buger/jsonparser v1.1.2 // indirect github.com/buger/jsonparser v1.1.2 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect
@ -84,6 +89,8 @@ require (
github.com/cloudflare/circl v1.6.3 // indirect github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect github.com/coder/websocket v1.8.14 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
@ -92,17 +99,23 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/invopop/jsonschema v0.14.0 // indirect github.com/invopop/jsonschema v0.14.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.42 // indirect github.com/mattn/go-sqlite3 v1.14.42 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
github.com/pion/randutil v0.1.0 // indirect github.com/pion/randutil v0.1.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/asm v1.1.3 // indirect

72
go.sum
View file

@ -19,10 +19,13 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMs
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg=
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA=
github.com/SevereCloud/vksdk/v3 v3.3.1 h1:O86zsp5LQnHE+O5acvuXM/s6S1LyxzVTkF6+Lup0Jyg= github.com/SevereCloud/vksdk/v3 v3.3.1 h1:O86zsp5LQnHE+O5acvuXM/s6S1LyxzVTkF6+Lup0Jyg=
github.com/SevereCloud/vksdk/v3 v3.3.1/go.mod h1:c6WaA5aocUYsXfkcUbg2qy45V9M1VDcqHHmHIN14NAw= github.com/SevereCloud/vksdk/v3 v3.3.1/go.mod h1:c6WaA5aocUYsXfkcUbg2qy45V9M1VDcqHHmHIN14NAw=
github.com/adhocore/gronx v1.20.0 h1:PD13Mo0wekkZ7ZZR9yb1TqeqTfybs7/K3ez9DmjQwEs= github.com/adhocore/gronx v1.20.0 h1:PD13Mo0wekkZ7ZZR9yb1TqeqTfybs7/K3ez9DmjQwEs=
github.com/adhocore/gronx v1.20.0/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg= github.com/adhocore/gronx v1.20.0/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
@ -71,6 +74,30 @@ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPn
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ=
github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8=
github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
@ -103,14 +130,23 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
@ -150,6 +186,7 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207 h1:p7t34F7K4OCRQblcDhNJnP46Uaarz3z2cLcvOZYxWn8= github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207 h1:p7t34F7K4OCRQblcDhNJnP46Uaarz3z2cLcvOZYxWn8=
github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/gomarkdown/markdown v0.0.0-20260411013819-759bbc3e3207/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@ -160,6 +197,7 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
@ -182,12 +220,20 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kagisearch/kagi-openapi-golang v0.0.0-20260526215348-96575e864d62 h1:nyUi7Wel3KlVSa5ArgX/snlizqfaxU48qtvXS/JK5GE= github.com/kagisearch/kagi-openapi-golang v0.0.0-20260526215348-96575e864d62 h1:nyUi7Wel3KlVSa5ArgX/snlizqfaxU48qtvXS/JK5GE=
github.com/kagisearch/kagi-openapi-golang v0.0.0-20260526215348-96575e864d62/go.mod h1:vONkS+clG730HSKOw3nZVa22TjB21r6csKYzYt0a9zI= github.com/kagisearch/kagi-openapi-golang v0.0.0-20260526215348-96575e864d62/go.mod h1:vONkS+clG730HSKOw3nZVa22TjB21r6csKYzYt0a9zI=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@ -209,6 +255,8 @@ github.com/line/line-bot-sdk-go/v8 v8.20.1 h1:OE7qwJbuZPz9MGOHHNI0E6rQNtIU7ENHyF
github.com/line/line-bot-sdk-go/v8 v8.20.1/go.mod h1:QMXJwPka2ysSeVQKWXkBp8DzBFs+CFAXFNo75KJtWho= github.com/line/line-bot-sdk-go/v8 v8.20.1/go.mod h1:QMXJwPka2ysSeVQKWXkBp8DzBFs+CFAXFNo75KJtWho=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@ -223,17 +271,28 @@ github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDw
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/mymmrac/telego v1.10.0 h1:Upe0TqYyiK+yE5RFXXuQWVHGfLZnqvUfj4KZVjTcgWE= github.com/mymmrac/telego v1.10.0 h1:Upe0TqYyiK+yE5RFXXuQWVHGfLZnqvUfj4KZVjTcgWE=
github.com/mymmrac/telego v1.10.0/go.mod h1:LsQKDA6EwssPP9XkORPXwwOFUGIRf/Wf2Wb8y3YyJdE= github.com/mymmrac/telego v1.10.0/go.mod h1:LsQKDA6EwssPP9XkORPXwwOFUGIRf/Wf2Wb8y3YyJdE=
github.com/nbd-wtf/go-nostr v0.52.3 h1:Xd87pXfJEJRXHpM+fLjQQln8dBNNaoPA10V7BbyP4KI=
github.com/nbd-wtf/go-nostr v0.52.3/go.mod h1:4avYoc9mDGZ9wHsvCOhHH9vPzKucCfuYBtJUSpHTfNk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
@ -256,6 +315,8 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjL
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@ -287,7 +348,10 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
@ -295,6 +359,7 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
github.com/tencent-connect/botgo v0.2.1 h1:+BrTt9Zh+awL28GWC4g5Na3nQaGRWb0N5IctS8WqBCk= github.com/tencent-connect/botgo v0.2.1 h1:+BrTt9Zh+awL28GWC4g5Na3nQaGRWb0N5IctS8WqBCk=
github.com/tencent-connect/botgo v0.2.1/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI= github.com/tencent-connect/botgo v0.2.1/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI=
github.com/tidwall/gjson v1.9.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.9.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@ -355,6 +420,7 @@ go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y= golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@ -372,11 +438,13 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
@ -407,6 +475,8 @@ golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -432,6 +502,7 @@ golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@ -472,6 +543,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

308
pkg/channels/buzz/buzz.go Normal file
View file

@ -0,0 +1,308 @@
// Package buzz implements a Channel for Buzz, a Nostr-based relay chat.
//
// Wire format: chat messages are kind:9 events scoped to a channel by an "h"
// tag. Mentions are "p" tags carrying the mentioned pubkey. The relay requires
// NIP-42 authentication (kind:22242) before it accepts a subscription.
package buzz
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
// kindStreamMessage is the Buzz chat message kind (NIP-29 style).
const kindStreamMessage = 9
const subscribeTimeout = 15 * time.Second
// BuzzChannel implements the Channel interface for a Buzz relay.
type BuzzChannel struct {
*channels.BaseChannel
bc *config.Channel
config *config.BuzzSettings
secretKey string
publicKey string
relay *nostr.Relay
sub *nostr.Subscription
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
// seen persists per-channel high-water state so a service restart does
// not redeliver already-handled kind:9 events to HandleInboundContext.
// It tracks the max seen CreatedAt (used to set a Since filter on
// resubscribe) and a bounded ring of recent event IDs (the authoritative
// dedup guard, since NIP-01 Since is inclusive and some relays ignore it).
seen *seenStore
}
// NewBuzzChannel creates a new Buzz channel.
func NewBuzzChannel(
bc *config.Channel,
cfg *config.BuzzSettings,
messageBus *bus.MessageBus,
) (*BuzzChannel, error) {
if cfg.RelayURL == "" {
return nil, fmt.Errorf("buzz relay_url is required")
}
if len(cfg.Channels) == 0 {
return nil, fmt.Errorf("buzz channels is required: at least one channel ID to join")
}
sk, err := normalizeSecretKey(cfg.PrivateKey.String())
if err != nil {
return nil, err
}
pk, err := nostr.GetPublicKey(sk)
if err != nil {
return nil, fmt.Errorf("buzz private_key is not a valid secret key: %w", err)
}
base := channels.NewBaseChannel("buzz", cfg, messageBus, bc.AllowFrom,
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
)
seen, err := newSeenStore(cfg.SeenStatePath, bc.Name())
if err != nil {
return nil, err
}
return &BuzzChannel{
BaseChannel: base,
bc: bc,
config: cfg,
secretKey: sk,
publicKey: pk,
seen: seen,
}, nil
}
// normalizeSecretKey accepts either a 64-char hex secret key or an nsec1
// bech32 string and returns the hex form.
func normalizeSecretKey(key string) (string, error) {
key = strings.TrimSpace(key)
if key == "" {
return "", fmt.Errorf("buzz private_key is required")
}
if strings.HasPrefix(key, "nsec1") {
prefix, value, err := nip19.Decode(key)
if err != nil {
return "", fmt.Errorf("buzz private_key: invalid nsec: %w", err)
}
if prefix != "nsec" {
return "", fmt.Errorf("buzz private_key: expected nsec, got %s", prefix)
}
sk, ok := value.(string)
if !ok {
return "", fmt.Errorf("buzz private_key: unexpected nsec payload")
}
return sk, nil
}
if len(key) != 64 {
return "", fmt.Errorf("buzz private_key must be 64-char hex or an nsec1 string")
}
return key, nil
}
// Start connects to the relay, authenticates via NIP-42, and subscribes to the
// configured channels.
func (c *BuzzChannel) Start(ctx context.Context) error {
logger.InfoC("buzz", "Starting Buzz channel")
c.ctx, c.cancel = context.WithCancel(ctx)
if c.seen != nil {
logger.DebugCF("buzz", "Replay state path", map[string]any{
"seen_path": c.seen.path,
"channel_name": c.bc.Name(),
"high_water": int64(c.seen.highWaterMark()),
})
}
relay, err := nostr.RelayConnect(c.ctx, c.config.RelayURL)
if err != nil {
c.cancel()
return fmt.Errorf("buzz relay connect failed: %w", err)
}
c.relay = relay
channelIDs := []string(c.config.Channels)
filters := nostr.Filters{{
Kinds: []int{kindStreamMessage},
Tags: nostr.TagMap{"h": channelIDs},
}}
// Replay suppression: skip events older than the highest CreatedAt we have
// already handled across all channels. NIP-01 Since is inclusive, so events
// at exactly the high-water timestamp may still be redelivered; the seen
// store's recent-ID ring drops those client-side. A zero high-water mark
// (first run, or empty store) leaves Since nil so we receive full history
// once — exactly as before.
if hw := c.seen.highWaterMark(); hw > 0 {
since := hw
filters[0].Since = &since
logger.InfoCF("buzz", "Replay filter from high-water mark", map[string]any{
"since": since.Time().Format(time.RFC3339),
})
}
sub, err := c.subscribeAuthed(c.ctx, filters)
if err != nil {
_ = relay.Close()
c.cancel()
return fmt.Errorf("buzz subscribe failed: %w", err)
}
c.sub = sub
c.wg.Add(1)
go func() {
defer c.wg.Done()
c.consume(sub)
}()
c.SetRunning(true)
logger.InfoCF("buzz", "Buzz channel started", map[string]any{
"relay": c.config.RelayURL,
"pubkey": c.publicKey,
"channels": len(channelIDs),
})
return nil
}
// subscribeAuthed subscribes, performing the NIP-42 handshake if the relay
// demands it. The relay rejection establishes that its challenge has been
// received by go-nostr before Auth signs the response.
func (c *BuzzChannel) subscribeAuthed(
ctx context.Context,
filters nostr.Filters,
) (*nostr.Subscription, error) {
sub, err := c.relay.Subscribe(ctx, filters)
if err != nil {
return nil, fmt.Errorf("buzz subscribe failed: %w", err)
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-sub.EndOfStoredEvents:
return sub, nil
case reason := <-sub.ClosedReason:
if !strings.HasPrefix(reason, "auth-required") {
return nil, fmt.Errorf("buzz relay closed subscription: %s", reason)
}
sub.Unsub()
if err := c.relay.Auth(ctx, func(evt *nostr.Event) error {
return evt.Sign(c.secretKey)
}); err != nil {
return nil, fmt.Errorf("buzz NIP-42 auth failed: %w", err)
}
logger.InfoCF("buzz", "Authenticated to relay", map[string]any{
"relay": c.config.RelayURL, "pubkey": c.publicKey,
})
authed, err := c.relay.Subscribe(ctx, filters)
if err != nil {
return nil, fmt.Errorf("buzz subscribe after auth failed: %w", err)
}
const postAuthGrace = 3 * time.Second
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-authed.EndOfStoredEvents:
return authed, nil
case reason := <-authed.ClosedReason:
return nil, fmt.Errorf("buzz relay rejected subscription after auth: %s", reason)
case <-time.After(postAuthGrace):
logger.InfoCF("buzz", "Subscription accepted after auth (no EOSE)", map[string]any{
"relay": c.config.RelayURL,
})
return authed, nil
}
case <-time.After(subscribeTimeout):
return nil, fmt.Errorf("buzz timed out waiting for relay to accept subscription")
}
}
// Stop closes the subscription and disconnects from the relay.
func (c *BuzzChannel) Stop(ctx context.Context) error {
logger.InfoC("buzz", "Stopping Buzz channel")
c.SetRunning(false)
if c.cancel != nil {
c.cancel()
}
if c.sub != nil {
c.sub.Unsub()
}
if c.relay != nil {
if err := c.relay.Close(); err != nil {
logger.WarnCF("buzz", "Relay close failed", map[string]any{"error": err.Error()})
}
}
c.wg.Wait()
logger.InfoC("buzz", "Buzz channel stopped")
return nil
}
// Send publishes a kind:9 message scoped to the target channel.
func (c *BuzzChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
return nil, channels.ErrNotRunning
}
target := msg.ChatID
if target == "" {
return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
}
if strings.TrimSpace(msg.Content) == "" {
return nil, nil
}
tags := nostr.Tags{nostr.Tag{"h", target}}
if c.config.ReplyInThread && msg.ReplyToMessageID != "" {
tags = append(tags, nostr.Tag{"e", msg.ReplyToMessageID, "", "reply"})
}
evt := nostr.Event{
PubKey: c.publicKey,
CreatedAt: nostr.Now(),
Kind: kindStreamMessage,
Tags: tags,
Content: msg.Content,
}
if err := evt.Sign(c.secretKey); err != nil {
return nil, fmt.Errorf("buzz sign failed: %w", errJoin(err, channels.ErrSendFailed))
}
if err := c.relay.Publish(ctx, evt); err != nil {
return nil, fmt.Errorf("buzz publish failed: %w", errJoin(err, channels.ErrSendFailed))
}
logger.DebugCF("buzz", "Message sent", map[string]any{
"channel": target,
"event_id": evt.ID,
})
return []string{evt.ID}, nil
}
// errJoin wraps err so that errors.Is(result, sentinel) holds for the sentinel
// while preserving the underlying cause in the message.
func errJoin(err, sentinel error) error {
return fmt.Errorf("%v: %w", err, sentinel)
}

View file

@ -0,0 +1,547 @@
package buzz
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestNormalizeSecretKeyAcceptsHex(t *testing.T) {
sk := nostr.GeneratePrivateKey()
got, err := normalizeSecretKey(sk)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != sk {
t.Fatalf("expected %s, got %s", sk, got)
}
}
func TestNormalizeSecretKeyAcceptsNsec(t *testing.T) {
sk := nostr.GeneratePrivateKey()
nsec, err := nip19.EncodePrivateKey(sk)
if err != nil {
t.Fatalf("failed to encode nsec: %v", err)
}
got, err := normalizeSecretKey(nsec)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != sk {
t.Fatalf("expected nsec to decode to %s, got %s", sk, got)
}
}
func TestNormalizeSecretKeyTrimsWhitespace(t *testing.T) {
sk := nostr.GeneratePrivateKey()
got, err := normalizeSecretKey(" " + sk + "\n")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != sk {
t.Fatalf("expected %s, got %s", sk, got)
}
}
func TestNormalizeSecretKeyRejectsBadInput(t *testing.T) {
cases := map[string]string{
"empty": "",
"whitespace": " ",
"short hex": "abcdef",
"bad nsec": "nsec1notarealkey",
"npub prefix": "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
}
for name, input := range cases {
t.Run(name, func(t *testing.T) {
if _, err := normalizeSecretKey(input); err == nil {
t.Fatalf("expected an error for %q", input)
}
})
}
}
func TestFirstTagValue(t *testing.T) {
tags := nostr.Tags{
nostr.Tag{"p", "pubkey-one"},
nostr.Tag{"h", "channel-abc"},
nostr.Tag{"h", "channel-second"},
nostr.Tag{"malformed"},
}
if got := firstTagValue(tags, "h"); got != "channel-abc" {
t.Fatalf("expected first h tag, got %q", got)
}
if got := firstTagValue(tags, "e"); got != "" {
t.Fatalf("expected empty string for missing tag, got %q", got)
}
// A tag with no value must not panic or be treated as present.
if got := firstTagValue(tags, "malformed"); got != "" {
t.Fatalf("expected empty string for valueless tag, got %q", got)
}
}
func TestHasPTag(t *testing.T) {
const self = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
tags := nostr.Tags{
nostr.Tag{"h", "channel-abc"},
nostr.Tag{"p", strings.ToUpper(self)},
}
if !hasPTag(tags, self) {
t.Fatal("expected p tag match to be case-insensitive")
}
other := nostr.Tags{
nostr.Tag{"h", "channel-abc"},
nostr.Tag{"p", "ffff"},
}
if hasPTag(other, self) {
t.Fatal("expected no match for a different pubkey")
}
// An "h" tag carrying our pubkey must not count as a mention.
spoof := nostr.Tags{nostr.Tag{"h", self}}
if hasPTag(spoof, self) {
t.Fatal("expected only p tags to count as mentions")
}
}
func TestShortPubkey(t *testing.T) {
const full = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
got := shortPubkey(full)
if !strings.HasPrefix(got, "01234567") || !strings.HasSuffix(got, "cdef") {
t.Fatalf("unexpected short form: %s", got)
}
// Short inputs are returned unchanged rather than sliced out of range.
if got := shortPubkey("abc"); got != "abc" {
t.Fatalf("expected short input unchanged, got %s", got)
}
}
// newTestBuzzChannel builds a real BuzzChannel wired to a temp-disk seen store
// and a live message bus, without touching the network. The caller's pubkey
// (c.publicKey) is the bot identity; events are signed with a different key so
// they are not treated as self-messages.
func newTestBuzzChannel(t *testing.T, seenPath string) (*BuzzChannel, *bus.MessageBus, context.Context) {
t.Helper()
botSK := nostr.GeneratePrivateKey()
if _, err := nostr.GetPublicKey(botSK); err != nil {
t.Fatalf("bot pubkey: %v", err)
}
bc := &config.Channel{}
bc.SetName("buzz-test")
cfg := &config.BuzzSettings{
RelayURL: "wss://relay.invalid",
PrivateKey: *config.NewSecureString(botSK),
Channels: config.FlexibleStringSlice{"test-channel"},
SeenStatePath: seenPath,
}
msgBus := bus.NewMessageBus()
t.Cleanup(msgBus.Close)
ch, err := NewBuzzChannel(bc, cfg, msgBus)
if err != nil {
t.Fatalf("NewBuzzChannel: %v", err)
}
// onEvent reads c.ctx; Start would set it but we don't want a relay.
ch.ctx, ch.cancel = context.WithCancel(context.Background())
t.Cleanup(func() {
if ch.cancel != nil {
ch.cancel()
}
})
return ch, msgBus, ch.ctx
}
// makeEvent builds and signs a kind:9 event from senderSK scoped to chatID,
// returning a real nostr.Event with a valid ID.
func makeEvent(t *testing.T, senderSK, chatID, content string, createdAt nostr.Timestamp) *nostr.Event {
t.Helper()
senderPK, err := nostr.GetPublicKey(senderSK)
if err != nil {
t.Fatalf("sender pubkey: %v", err)
}
evt := &nostr.Event{
PubKey: senderPK,
CreatedAt: createdAt,
Kind: kindStreamMessage,
Tags: nostr.Tags{nostr.Tag{"h", chatID}},
Content: content,
}
if err := evt.Sign(senderSK); err != nil {
t.Fatalf("sign: %v", err)
}
return evt
}
// expectInbound drains one inbound message from the bus, failing if none
// arrives within the timeout or if the bus is closed empty.
func expectInbound(t *testing.T, msgBus *bus.MessageBus, wantContent string) {
t.Helper()
select {
case msg, ok := <-msgBus.InboundChan():
if !ok {
t.Fatal("expected an inbound message but bus channel was closed")
}
if msg.Content != wantContent {
t.Fatalf("inbound content = %q, want %q", msg.Content, wantContent)
}
case <-time.After(time.Second):
t.Fatal("expected an inbound message but none arrived within 1s")
}
}
// expectNoInbound asserts that no inbound message arrives within the timeout,
// i.e. the event was suppressed by the seen store.
func expectNoInbound(t *testing.T, msgBus *bus.MessageBus) {
t.Helper()
select {
case msg, ok := <-msgBus.InboundChan():
if ok {
t.Fatalf("expected no inbound message, but got one: %q (id=%q)", msg.Content, msg.Context.MessageID)
}
case <-time.After(200 * time.Millisecond):
// No message within the window — replay suppression held. This is the
// success path.
}
}
// TestReplaySuppression_PersistedEventNotRedelivered covers the core restart
// scenario: an event handled in run 1 is recorded to the persistent store; a
// fresh channel loaded from the same store path must not redeliver it.
func TestReplaySuppression_PersistedEventNotRedelivered(t *testing.T) {
seenPath := filepath.Join(t.TempDir(), "seen.json")
// Run 1: a fresh channel handles one event.
ch1, msgBus1, _ := newTestBuzzChannel(t, seenPath)
senderSK := nostr.GeneratePrivateKey()
evt := makeEvent(t, senderSK, "test-channel", "hello from the past", nostr.Now())
ch1.onEvent(evt)
expectInbound(t, msgBus1, "hello from the past")
// The store file must exist on disk after handling.
if _, err := os.Stat(seenPath); err != nil {
t.Fatalf("stat seen file: %v", err)
}
// The persisted JSON must concretely record the handled event's ID and
// high-water timestamp, so a restart can dedup it without re-running the
// handler.
raw, err := os.ReadFile(seenPath)
if err != nil {
t.Fatalf("read seen file: %v", err)
}
var persisted map[string]*channelHighWater
if err := json.Unmarshal(raw, &persisted); err != nil {
t.Fatalf("unmarshal seen file: %v", err)
}
entry, ok := persisted["test-channel"]
if !ok {
t.Fatalf("persisted state missing test-channel entry; got %v", persisted)
}
if entry.MaxCreatedAt != int64(evt.CreatedAt) {
t.Fatalf("persisted max_created_at = %d, want %d", entry.MaxCreatedAt, int64(evt.CreatedAt))
}
foundID := false
for _, id := range entry.RecentIDs {
if id == evt.ID {
foundID = true
break
}
}
if !foundID {
t.Fatalf("persisted recent_ids %v do not contain event ID %q", entry.RecentIDs, evt.ID)
}
// Run 2: a brand-new channel instance reading the same store must not
// redeliver the same event ID, simulating a service restart.
ch2, msgBus2, _ := newTestBuzzChannel(t, seenPath)
ch2.onEvent(evt)
expectNoInbound(t, msgBus2)
// High-water mark must reflect the handled event's CreatedAt so the
// subscription Since filter would skip older history on restart.
if hw := ch2.seen.highWaterMark(); hw != evt.CreatedAt {
t.Fatalf("high-water mark = %d, want %d", hw, evt.CreatedAt)
}
}
// TestReplaySuppression_FreshEventStillHandled ensures the seen store does not
// suppress events that were never handled before — the live delivery path is
// preserved.
func TestReplaySuppression_FreshEventStillHandled(t *testing.T) {
seenPath := filepath.Join(t.TempDir(), "seen.json")
ch, msgBus, _ := newTestBuzzChannel(t, seenPath)
senderSK := nostr.GeneratePrivateKey()
first := makeEvent(t, senderSK, "test-channel", "first message", nostr.Now())
ch.onEvent(first)
expectInbound(t, msgBus, "first message")
// A different event ID must still be delivered.
second := makeEvent(t, senderSK, "test-channel", "second message", nostr.Now()+1)
ch.onEvent(second)
expectInbound(t, msgBus, "second message")
// Replaying the first event again is still suppressed within the same run
// (idempotency, not just restart protection).
ch.onEvent(first)
expectNoInbound(t, msgBus)
}
// TestReplaySuppression_SelfMessageNotRecorded confirms self-message filtering
// still works and that such messages do not pollute the seen store (so a future
// legitimate event with the same hypothetical ID — impossible in practice but
// the invariant matters — would not be wrongly suppressed).
func TestReplaySuppression_SelfMessageNotRecorded(t *testing.T) {
seenPath := filepath.Join(t.TempDir(), "seen.json")
ch, msgBus, _ := newTestBuzzChannel(t, seenPath)
// Build an event signed with the bot's own key so PubKey == c.publicKey.
selfEvt := makeEvent(t, ch.secretKey, "test-channel", "self echo", nostr.Now())
ch.onEvent(selfEvt)
expectNoInbound(t, msgBus)
// The store should have no entry for this channel because self-messages
// return before markHandled.
if ch.seen.seen("test-channel", selfEvt.ID, selfEvt.CreatedAt) {
t.Fatal("self-message was recorded in the seen store; it should be skipped before recording")
}
}
// TestSeenStoreHighWaterMarkAcrossChannels verifies the high-water mark is the
// max across all channels, so the subscription Since filter uses a single
// global cutoff that skips history for every channel.
func TestSeenStoreHighWaterMarkAcrossChannels(t *testing.T) {
s, err := newSeenStore(filepath.Join(t.TempDir(), "seen.json"), "test")
if err != nil {
t.Fatalf("newSeenStore: %v", err)
}
if hw := s.highWaterMark(); hw != 0 {
t.Fatalf("empty store high-water = %d, want 0", hw)
}
if err := s.markHandled("chan-a", "id-a1", 100); err != nil {
t.Fatalf("markHandled a: %v", err)
}
if err := s.markHandled("chan-b", "id-b1", 150); err != nil {
t.Fatalf("markHandled b: %v", err)
}
if err := s.markHandled("chan-a", "id-a2", 120); err != nil {
t.Fatalf("markHandled a2: %v", err)
}
if hw := s.highWaterMark(); hw != 150 {
t.Fatalf("high-water = %d, want 150", hw)
}
// Reload from disk to confirm persistence.
s2, err := newSeenStore(s.path, "test")
if err != nil {
t.Fatalf("newSeenStore reload: %v", err)
}
if hw := s2.highWaterMark(); hw != 150 {
t.Fatalf("after reload, high-water = %d, want 150", hw)
}
if !s2.seen("chan-a", "id-a1", 100) || !s2.seen("chan-b", "id-b1", 150) {
t.Fatal("reloaded store lost a recorded event ID")
}
}
func TestReplaySuppression_EventsOlderThanSeededHighWaterAreDropped(t *testing.T) {
seenPath := filepath.Join(t.TempDir(), "seen.json")
ch, msgBus, _ := newTestBuzzChannel(t, seenPath)
if err := ch.seen.markHandled("test-channel", "seed", nostr.Now()); err != nil {
t.Fatalf("seed high-water: %v", err)
}
oldEvent := makeEvent(
t,
nostr.GeneratePrivateKey(),
"test-channel",
"replayed history",
nostr.Now()-60,
)
ch.onEvent(oldEvent)
expectNoInbound(t, msgBus)
}
// TestSeenStoreRingTrims verifies the recent-ID ring does not grow unbounded,
// keeping only the most recent recentIDWindow entries per channel.
func TestSeenStoreRingTrims(t *testing.T) {
s, err := newSeenStore(filepath.Join(t.TempDir(), "seen.json"), "test")
if err != nil {
t.Fatalf("newSeenStore: %v", err)
}
// Insert more IDs than the window; old IDs should be evicted.
for i := 0; i < recentIDWindow+50; i++ {
id := "id-" + strings.Repeat("x", i%4)
// Use unique IDs so they are not deduped by the ring's duplicate check.
id = "id-" + nip19Letter(i)
if err := s.markHandled("chan", id, nostr.Timestamp(i)); err != nil {
t.Fatalf("markHandled %d: %v", i, err)
}
}
hw, ok := s.highWater["chan"]
if !ok {
t.Fatal("channel entry missing")
}
if len(hw.RecentIDs) != recentIDWindow {
t.Fatalf("ring length = %d, want %d", len(hw.RecentIDs), recentIDWindow)
}
// The first 50 IDs must have been evicted; the oldest remaining must be id
// number 50 (0-indexed).
if hw.RecentIDs[0] != "id-"+nip19Letter(50) {
t.Fatalf("oldest remaining ID = %q, want id for index 50", hw.RecentIDs[0])
}
}
// nip19Letter converts n to a short distinct string for unique IDs in the ring
// test. Using a deterministic suffix keeps IDs unique and short.
func nip19Letter(n int) string {
const digits = "0123456789abcdefghijklmnopqrstuvwxyz"
var out []byte
if n == 0 {
return "0"
}
for n > 0 {
out = append([]byte{digits[n%36]}, out...)
n /= 36
}
return string(out)
}
// TestNewBuzzChannel_RejectsCorruptSeenState asserts that NewBuzzChannel fails
// when the persisted seen-state file is unparseable. Starting a channel whose
// replay state cannot be loaded would silently lose dedup memory and risk
// redelivering already-handled events, so the constructor must refuse.
func TestNewBuzzChannel_RejectsCorruptSeenState(t *testing.T) {
seenPath := filepath.Join(t.TempDir(), "seen.json")
if err := os.WriteFile(seenPath, []byte("{not valid json"), 0o600); err != nil {
t.Fatalf("write corrupt seen file: %v", err)
}
botSK := nostr.GeneratePrivateKey()
bc := &config.Channel{}
bc.SetName("buzz-corrupt")
cfg := &config.BuzzSettings{
RelayURL: "wss://relay.invalid",
PrivateKey: *config.NewSecureString(botSK),
Channels: config.FlexibleStringSlice{"test-channel"},
SeenStatePath: seenPath,
}
msgBus := bus.NewMessageBus()
t.Cleanup(msgBus.Close)
if _, err := NewBuzzChannel(bc, cfg, msgBus); err == nil {
t.Fatal("NewBuzzChannel unexpectedly succeeded with a corrupt seen-state file")
}
}
// TestNewSeenStore_RejectsCorruptState exercises the store constructor
// directly: a corrupt file must surface a load error, while a missing file
// must not.
func TestNewSeenStore_RejectsCorruptState(t *testing.T) {
seenPath := filepath.Join(t.TempDir(), "seen.json")
// Missing file is fine — fresh store starts empty.
s, err := newSeenStore(seenPath, "test")
if err != nil {
t.Fatalf("missing file should not error, got: %v", err)
}
if hw := s.highWaterMark(); hw != 0 {
t.Fatalf("fresh store high-water = %d, want 0", hw)
}
// Corrupt file must error.
if err := os.WriteFile(seenPath, []byte("::{unjson"), 0o600); err != nil {
t.Fatalf("write corrupt file: %v", err)
}
if _, err := newSeenStore(seenPath, "test"); err == nil {
t.Fatal("newSeenStore unexpectedly succeeded with a corrupt file")
}
}
// TestOnEvent_PersistenceFailurePreventsDispatch verifies that if marking an
// event as handled fails to persist, onEvent does NOT dispatch the event to
// the inbound bus. This preserves at-most-once delivery: a successful but
// un-persisted mark would let the same event be redelivered and re-handled on
// the next restart.
//
// The persistence failure is induced by replacing the store's parent directory
// with a regular file after load, so saveLocked's MkdirAll fails. This is
// deterministic even when tests run as root and exercises real I/O.
func TestOnEvent_PersistenceFailurePreventsDispatch(t *testing.T) {
parent := filepath.Join(t.TempDir(), "store-parent")
storeDir := filepath.Join(parent, "store")
if err := os.Mkdir(parent, 0o700); err != nil {
t.Fatalf("mkdir store parent: %v", err)
}
if err := os.Mkdir(storeDir, 0o700); err != nil {
t.Fatalf("mkdir store: %v", err)
}
seenPath := filepath.Join(storeDir, "seen.json")
// Valid empty state so load() succeeds; save() will fail once the dir
// is made read-only.
if err := os.WriteFile(seenPath, []byte("{}"), 0o600); err != nil {
t.Fatalf("seed seen file: %v", err)
}
ch, msgBus, _ := newTestBuzzChannel(t, seenPath)
if err := os.RemoveAll(storeDir); err != nil {
t.Fatalf("remove store directory: %v", err)
}
if err := os.WriteFile(storeDir, []byte("not a directory"), 0o600); err != nil {
t.Fatalf("replace store directory with file: %v", err)
}
senderSK := nostr.GeneratePrivateKey()
evt := makeEvent(t, senderSK, "test-channel", "should not be dispatched", nostr.Now())
ch.onEvent(evt)
expectNoInbound(t, msgBus)
// A second, fresh event must also be suppressed while persistence is
// broken — the channel must not "give up" suppressing after one failure.
second := makeEvent(t, senderSK, "test-channel", "also not dispatched", nostr.Now()+1)
ch.onEvent(second)
expectNoInbound(t, msgBus)
}
// TestOnEvent_PersistenceFailureThenRecoveryDispatches confirms that once
// persistence is restored, a fresh event is dispatched normally — the
// persistence-failure path does not wedge the channel permanently.
func TestOnEvent_PersistenceFailureThenRecoveryDispatches(t *testing.T) {
// Start with a working seen store on a fresh channel.
seenPath := filepath.Join(t.TempDir(), "seen.json")
ch, msgBus, _ := newTestBuzzChannel(t, seenPath)
senderSK := nostr.GeneratePrivateKey()
evt := makeEvent(t, senderSK, "test-channel", "recovered path delivers", nostr.Now())
ch.onEvent(evt)
expectInbound(t, msgBus, "recovered path delivers")
}

View file

@ -0,0 +1,161 @@
package buzz
import (
"strings"
"github.com/nbd-wtf/go-nostr"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
)
// consume drains the subscription until the channel context is cancelled.
func (c *BuzzChannel) consume(sub *nostr.Subscription) {
for {
select {
case <-c.ctx.Done():
return
case reason, ok := <-sub.ClosedReason:
if !ok {
return
}
logger.WarnCF("buzz", "Relay closed subscription", map[string]any{"reason": reason})
return
case evt, ok := <-sub.Events:
if !ok {
return
}
c.onEvent(evt)
}
}
}
// onEvent converts a kind:9 relay event into an inbound bus message.
func (c *BuzzChannel) onEvent(evt *nostr.Event) {
if evt == nil || evt.Kind != kindStreamMessage {
return
}
// Ignore our own messages, otherwise the agent replies to itself.
if evt.PubKey == c.publicKey {
return
}
// The "h" tag scopes the event to a channel; without it there is nowhere to reply.
chatID := firstTagValue(evt.Tags, "h")
if chatID == "" {
return
}
// Replay suppression: drop events we have already handed to
// HandleInboundContext in a prior run. The seen store persists per-channel
// high-water state; this guard survives restarts even if the relay
// redelivers stored history (it ignores the Since filter or sends events
// at the inclusive Since boundary).
if c.seen != nil && c.seen.seen(chatID, evt.ID, evt.CreatedAt) {
return
}
sender := bus.SenderInfo{
Platform: "buzz",
PlatformID: evt.PubKey,
CanonicalID: identity.BuildCanonicalID("buzz", evt.PubKey),
Username: shortPubkey(evt.PubKey),
DisplayName: shortPubkey(evt.PubKey),
}
if !c.IsAllowedSender(sender) {
return
}
// Mentions are "p" tags carrying the mentioned pubkey.
isMentioned := hasPTag(evt.Tags, c.publicKey)
content := evt.Content
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
if !respond {
return
}
content = cleaned
if strings.TrimSpace(content) == "" {
return
}
// Record this event as handled before dispatching so a crash between here
// and HandleInboundContext does not cause a redelivery on the next start.
// The store is persisted to disk; the recent-ID ring absorbs the
// inclusive-Since boundary redelivery on restart.
//
// If we cannot persist the state, we must NOT dispatch: a successful but
// un-persisted mark would let the same event be redelivered (and re-handled)
// on the next restart, violating at-most-once inbound delivery. Skip this
// event and let the relay redeliver it later, when persistence may succeed.
if c.seen != nil {
if err := c.seen.markHandled(chatID, evt.ID, evt.CreatedAt); err != nil {
logger.WarnCF("buzz", "Failed to persist seen state; skipping dispatch", map[string]any{
"error": err.Error(),
"event_id": evt.ID,
"chat_id": chatID,
})
return
}
logger.DebugCF("buzz", "Persisted seen state for event", map[string]any{
"chat_id": chatID,
"event_id": evt.ID,
"created_at": int64(evt.CreatedAt),
"high_water": int64(c.seen.highWaterMark()),
"seen_path": c.seen.path,
})
}
inboundCtx := bus.InboundContext{
Channel: "buzz",
ChatID: chatID,
ChatType: "channel",
SenderID: evt.PubKey,
MessageID: evt.ID,
Mentioned: isMentioned,
Raw: map[string]string{
"platform": "buzz",
"relay": c.config.RelayURL,
"channel": chatID,
},
}
if err := c.HandleInboundContext(c.ctx, chatID, content, nil, inboundCtx, sender); err != nil {
logger.WarnCF("buzz", "Inbound handling failed", map[string]any{
"error": err.Error(),
"event_id": evt.ID,
})
}
}
// firstTagValue returns the value of the first tag with the given key.
func firstTagValue(tags nostr.Tags, key string) string {
for _, t := range tags {
if len(t) >= 2 && t[0] == key {
return t[1]
}
}
return ""
}
// hasPTag reports whether any "p" tag references the given pubkey.
func hasPTag(tags nostr.Tags, pubkey string) bool {
for _, t := range tags {
if len(t) >= 2 && t[0] == "p" && strings.EqualFold(t[1], pubkey) {
return true
}
}
return false
}
// shortPubkey renders a pubkey as a readable handle for display purposes.
func shortPubkey(pubkey string) string {
if len(pubkey) <= 12 {
return pubkey
}
return pubkey[:8] + "…" + pubkey[len(pubkey)-4:]
}

35
pkg/channels/buzz/init.go Normal file
View file

@ -0,0 +1,35 @@
package buzz
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory(
config.ChannelBuzz,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
if bc == nil || !bc.Enabled {
return nil, nil
}
decoded, err := bc.GetDecoded()
if err != nil {
return nil, err
}
c, ok := decoded.(*config.BuzzSettings)
if !ok {
return nil, channels.ErrSendFailed
}
ch, err := NewBuzzChannel(bc, c, b)
if err != nil {
return nil, err
}
if channelName != config.ChannelBuzz {
ch.SetName(channelName)
}
return ch, nil
},
)
}

View file

@ -0,0 +1,191 @@
package buzz
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/nbd-wtf/go-nostr"
"github.com/sipeed/picoclaw/pkg/config"
)
// recentIDWindow is how many recently-handled event IDs we keep per channel to
// survive the inclusive-Since boundary (NIP-01 redelivers events with
// created_at >= Since) and relays that silently ignore the Since filter.
// A few hundred is ample: it covers the burst of events at the high-water
// timestamp plus any stragglers a misbehaving relay re-sends.
const recentIDWindow = 256
// channelHighWater records the highest seen event created_at and a bounded
// ring of recently handled event IDs for a single Buzz channel ("h" tag).
type channelHighWater struct {
MaxCreatedAt int64 `json:"max_created_at"`
RecentIDs []string `json:"recent_ids,omitempty"`
}
// seenStore persists per-channel high-water state so a service restart does
// not redeliver already-handled kind:9 events to HandleInboundContext.
//
// The store tracks, per "h" tag value:
// - MaxCreatedAt: the largest event.CreatedAt we have handled, used to set a
// Since filter on resubscribe so the relay skips most stored history.
// - RecentIDs: a bounded ring of the most recently handled event IDs. This
// is the authoritative dedup guard: Since is inclusive in NIP-01, so events
// at exactly the high-water timestamp are redelivered, and some relays
// ignore Since entirely. The ring absorbs both cases without unbounded
// growth.
//
// The store is JSON-backed under ~/.picoclaw/buzz/seen-<channel-name>.json,
// matching the persistence pattern used by the wecom reqID store.
type seenStore struct {
mu sync.Mutex
path string
highWater map[string]*channelHighWater
}
// newSeenStore opens (or creates) the persistent seen-state store at path.
// If path is empty it defaults to ~/.picoclaw/buzz/seen-<defaultName>.json.
// A missing file is not an error; the store starts empty. Any other read or
// parse failure is returned so callers can refuse to start a channel whose
// replay state cannot be loaded — silently running with a broken store would
// risk redelivering already-handled events.
func newSeenStore(path, defaultName string) (*seenStore, error) {
if path == "" {
path = defaultSeenStorePath(defaultName)
}
s := &seenStore{
path: path,
highWater: make(map[string]*channelHighWater),
}
if err := s.load(); err != nil {
return nil, fmt.Errorf("buzz seen store load %q: %w", path, err)
}
return s, nil
}
func defaultSeenStorePath(channelName string) string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return filepath.Join(os.TempDir(), "picoclaw-buzz-seen-"+channelName+".json")
}
name := channelName
if name == "" {
name = config.ChannelBuzz
}
return filepath.Join(home, ".picoclaw", "buzz", "seen-"+name+".json")
}
// highWaterMark returns the maximum seen CreatedAt across all channels, or
// zero if nothing has been recorded. Callers use it to set the subscription
// Since filter so the relay skips events older than what we have handled.
func (s *seenStore) highWaterMark() nostr.Timestamp {
s.mu.Lock()
defer s.mu.Unlock()
var max int64
for _, hw := range s.highWater {
if hw.MaxCreatedAt > max {
max = hw.MaxCreatedAt
}
}
return nostr.Timestamp(max)
}
// seen reports whether an event has already been handled for chatID. Events
// older than the channel's high-water timestamp are necessarily replayed
// history. Events at the inclusive high-water boundary require an ID lookup.
func (s *seenStore) seen(chatID, eventID string, createdAt nostr.Timestamp) bool {
if eventID == "" {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
hw, ok := s.highWater[chatID]
if !ok {
return false
}
if int64(createdAt) < hw.MaxCreatedAt {
return true
}
for _, id := range hw.RecentIDs {
if id == eventID {
return true
}
}
return false
}
// markHandled records that eventID (with createdAt) has been handled for
// chatID and persists the store. It updates MaxCreatedAt when createdAt is
// newer and trims the RecentIDs ring to recentIDWindow entries.
func (s *seenStore) markHandled(chatID, eventID string, createdAt nostr.Timestamp) error {
if chatID == "" || eventID == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
hw, ok := s.highWater[chatID]
if !ok {
hw = &channelHighWater{}
s.highWater[chatID] = hw
}
ts := int64(createdAt)
if ts > hw.MaxCreatedAt {
hw.MaxCreatedAt = ts
}
// Avoid duplicate entries in the ring.
for _, id := range hw.RecentIDs {
if id == eventID {
return s.saveLocked()
}
}
hw.RecentIDs = append(hw.RecentIDs, eventID)
if len(hw.RecentIDs) > recentIDWindow {
// Drop the oldest entries, keeping only the most recent window.
drop := len(hw.RecentIDs) - recentIDWindow
hw.RecentIDs = hw.RecentIDs[drop:]
}
return s.saveLocked()
}
// load reads the persisted high-water state. A missing file is not an error.
func (s *seenStore) load() error {
s.mu.Lock()
defer s.mu.Unlock()
data, err := os.ReadFile(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
var hw map[string]*channelHighWater
if err := json.Unmarshal(data, &hw); err != nil {
return err
}
s.highWater = hw
if s.highWater == nil {
s.highWater = make(map[string]*channelHighWater)
}
return nil
}
// saveLocked writes the high-water state to disk. Caller must hold s.mu.
func (s *seenStore) saveLocked() error {
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(s.highWater, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}

View file

@ -706,6 +706,28 @@ type IRCSettings struct {
RequestCaps FlexibleStringSlice `json:"request_caps,omitempty" yaml:"-"` RequestCaps FlexibleStringSlice `json:"request_caps,omitempty" yaml:"-"`
} }
// BuzzSettings configures the Buzz channel, a Nostr-based relay chat.
//
// Messages are NIP-29 style: kind:9 events scoped to a channel by an "h" tag,
// with mentions carried as "p" tags. The relay requires NIP-42 authentication
// (kind:22242) before it will accept a subscription.
type BuzzSettings struct {
// RelayURL is the relay websocket endpoint, e.g. wss://relay.example.com.
RelayURL string `json:"relay_url" yaml:"-" env:"PICOCLAW_CHANNELS_BUZZ_RELAY_URL"`
// PrivateKey is the bot identity as a 64-char hex secret key or an nsec1 string.
PrivateKey SecureString `json:"private_key,omitzero" yaml:"private_key,omitempty" env:"PICOCLAW_CHANNELS_BUZZ_PRIVATE_KEY"`
// Channels lists the channel IDs ("h" tag values) to join.
Channels FlexibleStringSlice `json:"channels" yaml:"-" env:"PICOCLAW_CHANNELS_BUZZ_CHANNELS"`
// ReplyInThread emits an "e" tag referencing the triggering event so replies
// thread under it rather than appearing as top-level channel messages.
ReplyInThread bool `json:"reply_in_thread,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_BUZZ_REPLY_IN_THREAD"`
// SeenStatePath overrides the on-disk location of the replay-suppression
// store. When empty it defaults to ~/.picoclaw/buzz/seen-<channel-name>.json.
// The store records the highest handled event timestamp per channel so a
// service restart does not redeliver already-handled kind:9 events.
SeenStatePath string `json:"seen_state_path,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_BUZZ_SEEN_STATE_PATH"`
}
type VKSettings struct { type VKSettings struct {
Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_VK_TOKEN"` Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_VK_TOKEN"`
GroupID int `json:"group_id" yaml:"-" env:"PICOCLAW_CHANNELS_VK_GROUP_ID"` GroupID int `json:"group_id" yaml:"-" env:"PICOCLAW_CHANNELS_VK_GROUP_ID"`

View file

@ -39,6 +39,7 @@ const (
ChannelTeamsWebHook = "teams_webhook" ChannelTeamsWebHook = "teams_webhook"
ChannelMQTT = "mqtt" ChannelMQTT = "mqtt"
ChannelSlackWebHook = "slack_webhook" ChannelSlackWebHook = "slack_webhook"
ChannelBuzz = "buzz"
) )
func initChannel() { func initChannel() {
@ -682,6 +683,7 @@ var channelSettingsFactory = map[string]any{
ChannelTeamsWebHook: (TeamsWebhookSettings{}), ChannelTeamsWebHook: (TeamsWebhookSettings{}),
ChannelMQTT: (MQTTSettings{}), ChannelMQTT: (MQTTSettings{}),
ChannelSlackWebHook: (SlackWebhookSettings{}), ChannelSlackWebHook: (SlackWebhookSettings{}),
ChannelBuzz: (BuzzSettings{}),
} }
// RegisterChannelSettings registers a settings struct prototype for a custom // RegisterChannelSettings registers a settings struct prototype for a custom

View file

@ -19,6 +19,7 @@ import (
"github.com/sipeed/picoclaw/pkg/audio/tts" "github.com/sipeed/picoclaw/pkg/audio/tts"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/channels"
_ "github.com/sipeed/picoclaw/pkg/channels/buzz"
_ "github.com/sipeed/picoclaw/pkg/channels/deltachat" _ "github.com/sipeed/picoclaw/pkg/channels/deltachat"
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk" _ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
_ "github.com/sipeed/picoclaw/pkg/channels/discord" _ "github.com/sipeed/picoclaw/pkg/channels/discord"