Compare commits
No commits in common. "feat/buzz-channel" and "main" have entirely different histories.
feat/buzz-
...
main
10 changed files with 0 additions and 1352 deletions
13
go.mod
13
go.mod
|
|
@ -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
72
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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:]
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue