From 9b4df8618208fb24c302c9bb88c63676f5805ed7 Mon Sep 17 00:00:00 2001 From: PeterChrz Date: Tue, 11 Aug 2026 21:41:16 -0400 Subject: [PATCH] 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 --- go.mod | 13 ++ go.sum | 72 +++++++++++ pkg/channels/buzz/buzz.go | 227 +++++++++++++++++++++++++++++++++ pkg/channels/buzz/buzz_test.go | 127 ++++++++++++++++++ pkg/channels/buzz/handler.go | 125 ++++++++++++++++++ pkg/channels/buzz/init.go | 35 +++++ pkg/config/config.go | 17 +++ pkg/config/config_channel.go | 2 + pkg/gateway/gateway.go | 1 + 9 files changed, 619 insertions(+) create mode 100644 pkg/channels/buzz/buzz.go create mode 100644 pkg/channels/buzz/buzz_test.go create mode 100644 pkg/channels/buzz/handler.go create mode 100644 pkg/channels/buzz/init.go diff --git a/go.mod b/go.mod index ab35e5d1..9d10b5fa 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,7 @@ require ( github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/muesli/termenv v0.16.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/openai/openai-go/v3 v3.22.0 github.com/pion/rtp v1.10.2 @@ -60,6 +61,7 @@ require ( filippo.io/edwards25519 v1.2.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/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/credentials v1.19.24 // 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/bahlo/generic-list-go v0.2.0 // 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/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // 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/coder/websocket v1.8.14 // 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/elliotchance/orderedmap/v3 v3.1.0 // 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/inconshreveable/mousetrap v1.1.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/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-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // 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/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect github.com/pion/randutil v0.1.0 // 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/rivo/uniseg v0.4.7 // indirect github.com/segmentio/asm v1.1.3 // indirect diff --git a/go.sum b/go.sum index 7fbeda96..6faa6266 100644 --- a/go.sum +++ b/go.sum @@ -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/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/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/go.mod h1:c6WaA5aocUYsXfkcUbg2qy45V9M1VDcqHHmHIN14NAw= github.com/adhocore/gronx v1.20.0 h1:PD13Mo0wekkZ7ZZR9yb1TqeqTfybs7/K3ez9DmjQwEs= 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/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= 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/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/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/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= 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.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= 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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= 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/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/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= 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.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 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/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= 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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 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/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= 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/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= 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/go.mod h1:vONkS+clG730HSKOw3nZVa22TjB21r6csKYzYt0a9zI= 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/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/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/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= 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/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/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/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 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/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/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/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/mymmrac/telego v1.10.0 h1:Upe0TqYyiK+yE5RFXXuQWVHGfLZnqvUfj4KZVjTcgWE= 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/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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/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.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/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.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 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.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.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 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/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI= 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= golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y= 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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= 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-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-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-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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 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-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-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-20201119102817-f84b799fce68/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/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= 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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 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/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/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.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/pkg/channels/buzz/buzz.go b/pkg/channels/buzz/buzz.go new file mode 100644 index 00000000..2510495c --- /dev/null +++ b/pkg/channels/buzz/buzz.go @@ -0,0 +1,227 @@ +// 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" + + "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 + +// 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 +} + +// 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), + ) + + return &BuzzChannel{ + BaseChannel: base, + bc: bc, + config: cfg, + secretKey: sk, + publicKey: pk, + }, 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) + + 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 + + // NIP-42: the relay challenges, we sign the auth event with the bot identity. + // Buzz relays reject subscriptions from unauthenticated clients, so a failure + // here is fatal rather than advisory. + if err := relay.Auth(c.ctx, func(evt *nostr.Event) error { + return evt.Sign(c.secretKey) + }); err != nil { + _ = relay.Close() + c.cancel() + return fmt.Errorf("buzz NIP-42 auth failed: %w", err) + } + + channelIDs := []string(c.config.Channels) + filters := nostr.Filters{{ + Kinds: []int{kindStreamMessage}, + Tags: nostr.TagMap{"h": channelIDs}, + }} + + sub, err := relay.Subscribe(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 +} + +// 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) +} diff --git a/pkg/channels/buzz/buzz_test.go b/pkg/channels/buzz/buzz_test.go new file mode 100644 index 00000000..ef8ebff5 --- /dev/null +++ b/pkg/channels/buzz/buzz_test.go @@ -0,0 +1,127 @@ +package buzz + +import ( + "strings" + "testing" + + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip19" +) + +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) + } +} diff --git a/pkg/channels/buzz/handler.go b/pkg/channels/buzz/handler.go new file mode 100644 index 00000000..c8c9b511 --- /dev/null +++ b/pkg/channels/buzz/handler.go @@ -0,0 +1,125 @@ +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 + } + + 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 + } + + 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:] +} diff --git a/pkg/channels/buzz/init.go b/pkg/channels/buzz/init.go new file mode 100644 index 00000000..2b4dd121 --- /dev/null +++ b/pkg/channels/buzz/init.go @@ -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 + }, + ) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index df232f77..532bcad4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -706,6 +706,23 @@ type IRCSettings struct { 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"` +} + type VKSettings struct { 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"` diff --git a/pkg/config/config_channel.go b/pkg/config/config_channel.go index 232c37cd..ad14398e 100644 --- a/pkg/config/config_channel.go +++ b/pkg/config/config_channel.go @@ -39,6 +39,7 @@ const ( ChannelTeamsWebHook = "teams_webhook" ChannelMQTT = "mqtt" ChannelSlackWebHook = "slack_webhook" + ChannelBuzz = "buzz" ) func initChannel() { @@ -682,6 +683,7 @@ var channelSettingsFactory = map[string]any{ ChannelTeamsWebHook: (TeamsWebhookSettings{}), ChannelMQTT: (MQTTSettings{}), ChannelSlackWebHook: (SlackWebhookSettings{}), + ChannelBuzz: (BuzzSettings{}), } // RegisterChannelSettings registers a settings struct prototype for a custom diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index 41592937..2b3e6878 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -19,6 +19,7 @@ import ( "github.com/sipeed/picoclaw/pkg/audio/tts" "github.com/sipeed/picoclaw/pkg/bus" "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/dingtalk" _ "github.com/sipeed/picoclaw/pkg/channels/discord"