Compare commits

..

No commits in common. "feat/buzz-channel" and "main" have entirely different histories.

10 changed files with 0 additions and 1352 deletions

13
go.mod
View file

@ -32,7 +32,6 @@ 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
@ -61,7 +60,6 @@ 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
@ -78,9 +76,6 @@ 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
@ -89,8 +84,6 @@ 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
@ -99,23 +92,17 @@ 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

72
go.sum
View file

@ -19,13 +19,10 @@ 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=
@ -74,30 +71,6 @@ 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=
@ -130,23 +103,14 @@ 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=
@ -186,7 +150,6 @@ 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=
@ -197,7 +160,6 @@ 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=
@ -220,20 +182,12 @@ 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=
@ -255,8 +209,6 @@ 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=
@ -271,28 +223,17 @@ 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=
@ -315,8 +256,6 @@ 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=
@ -348,10 +287,7 @@ 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=
@ -359,7 +295,6 @@ 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=
@ -420,7 +355,6 @@ 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=
@ -438,13 +372,11 @@ 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=
@ -475,8 +407,6 @@ 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=
@ -502,7 +432,6 @@ 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=
@ -543,7 +472,6 @@ 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=

View file

@ -1,308 +0,0 @@
// 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

@ -1,547 +0,0 @@
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

@ -1,161 +0,0 @@
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:]
}

View file

@ -1,35 +0,0 @@
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

@ -1,191 +0,0 @@
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,28 +706,6 @@ 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"`
// 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 {
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"`

View file

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

View file

@ -19,7 +19,6 @@ 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"