diff --git a/config.yml.dist b/config.yml.dist index 5169cfb..336a1f0 100644 --- a/config.yml.dist +++ b/config.yml.dist @@ -18,6 +18,10 @@ signal_group: api_url: "" avatar: "" account: "" +# matrix configuration +matrix: + api_url: "" + token: "" # listen port for prometheus metrics exporter metrics_listen: ":8181" upload: diff --git a/go.mod b/go.mod index f1b784d..fe53147 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 + maunium.net/go/mautrix v0.26.1 ) require ( @@ -79,6 +80,7 @@ require ( github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.32 // indirect github.com/minio/sha256-simd v1.0.1 // indirect @@ -99,14 +101,20 @@ require ( github.com/prometheus/procfs v0.19.2 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.57.1 // indirect + github.com/rs/zerolog v1.34.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/objx v0.5.3 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/tomnomnom/linkheader v0.0.0-20250811210735-e5fe3b51442e // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/whyrusleeping/cbor-gen v0.3.1 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.mau.fi/util v0.9.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect @@ -118,12 +126,13 @@ require ( go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/arch v0.23.0 // indirect + golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect - google.golang.org/protobuf v1.36.10 // indirect + google.golang.org/protobuf v1.36.11 // indirect lukechampine.com/blake3 v1.4.1 // indirect ) diff --git a/go.sum b/go.sum index e2f0d6d..7b505a9 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,7 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -64,6 +65,7 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE= github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= @@ -157,8 +159,11 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +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.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-mastodon v0.0.10 h1:wz1d/aCkJOIkz46iv4eAqXHVreUMxydY1xBWrPBdDeE= @@ -193,6 +198,7 @@ github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYr github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= @@ -212,6 +218,9 @@ github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlo github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sethvargo/go-password v0.3.1 h1:WqrLTjo7X6AcVYfC6R7GtSyuUQR9hGyAj/f1PYQZCJU= @@ -247,12 +256,16 @@ 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/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= -github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tomnomnom/linkheader v0.0.0-20250811210735-e5fe3b51442e h1:tD38/4xg4nuQCASJ/JxcvCHNb46w0cdAaJfkzQOO1bA= github.com/tomnomnom/linkheader v0.0.0-20250811210735-e5fe3b51442e/go.mod h1:krvJ5AY/MjdPkTeRgMYbIDhbbbVvnPQPzsIsDJO8xrY= github.com/toorop/gin-logrus v0.0.0-20210225092905-2c785434f26f h1:oqdnd6OGlOUu1InG37hWcCB3a+Jy3fwjylyVboaNMwY= @@ -272,6 +285,8 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zU github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.mau.fi/util v0.9.4 h1:gWdUff+K2rCynRPysXalqqQyr2ahkSWaestH6YhSpso= +go.mau.fi/util v0.9.4/go.mod h1:647nVfwUvuhlZFOnro3aRNPmRd2y3iDha9USb8aKSmM= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= @@ -313,6 +328,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM= +golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= @@ -336,7 +353,9 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -360,8 +379,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -382,3 +401,5 @@ gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= +maunium.net/go/mautrix v0.26.1 h1:FWCC1xY5vwJ5ou3duEBjB6w9IIlwfc9el3q3Mju3Dlg= +maunium.net/go/mautrix v0.26.1/go.mod h1:UySSpb8OqXG1sMJ6dDqyzmfcqr2ayZK+KzwqOTAkAOM= diff --git a/internal/api/api.go b/internal/api/api.go index f6c0a4b..4910c3d 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -90,6 +90,8 @@ func API(config config.Config, store storage.Storage) *Server { admin.PUT(`/tickers/:tickerID/signal_group`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.PutTickerSignalGroup) admin.DELETE(`/tickers/:tickerID/signal_group`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.DeleteTickerSignalGroup) admin.PUT(`/tickers/:tickerID/signal_group/admin`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.PutTickerSignalGroupAdmin) + admin.PUT(`/tickers/:tickerID/matrix`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.PutTickerMatrix) + admin.DELETE(`/tickers/:tickerID/matrix`, ticker.PrefetchTicker(store, storage.WithPreload()), handler.DeleteTickerMatrix) admin.DELETE(`/tickers/:tickerID`, user.NeedAdmin(), ticker.PrefetchTicker(store), handler.DeleteTicker) admin.PUT(`/tickers/:tickerID/reset`, ticker.PrefetchTicker(store, storage.WithPreload()), ticker.PrefetchTicker(store), handler.ResetTicker) admin.GET(`/tickers/:tickerID/users`, ticker.PrefetchTicker(store), handler.GetTickerUsers) diff --git a/internal/api/response/response.go b/internal/api/response/response.go index 12492ef..3e22293 100644 --- a/internal/api/response/response.go +++ b/internal/api/response/response.go @@ -25,6 +25,7 @@ const ( BlueskyError ErrorMessage = "unable to connect to bluesky" SignalGroupError ErrorMessage = "unable to connect to signal" SignalGroupDeleteError ErrorMessage = "unable to delete signal group" + MatrixError ErrorMessage = "unable to connect to matrix" PasswordError ErrorMessage = "could not authenticate password" StatusSuccess Status = `success` diff --git a/internal/api/response/ticker.go b/internal/api/response/ticker.go index b4a1080..906c445 100644 --- a/internal/api/response/ticker.go +++ b/internal/api/response/ticker.go @@ -19,6 +19,7 @@ type Ticker struct { Mastodon Mastodon `json:"mastodon"` Bluesky Bluesky `json:"bluesky"` SignalGroup SignalGroup `json:"signalGroup"` + Matrix Matrix `json:"matrix"` Location Location `json:"location"` } @@ -71,6 +72,13 @@ type SignalGroup struct { GroupInviteLink string `json:"groupInviteLink"` } +type Matrix struct { + Active bool `json:"active"` + Connected bool `json:"connected"` + RoomID string `json:"roomID"` + RoomName string `json:"roomName"` +} + type Location struct { Lat float64 `json:"lat"` Lon float64 `json:"lon"` @@ -130,6 +138,12 @@ func TickerResponse(t storage.Ticker, config config.Config) Ticker { GroupID: t.SignalGroup.GroupID, GroupInviteLink: t.SignalGroup.GroupInviteLink, }, + Matrix: Matrix{ + Active: t.Matrix.Active, + Connected: t.Matrix.Connected(), + RoomID: t.Matrix.RoomID, + RoomName: t.Matrix.RoomName, + }, Location: Location{ Lat: t.Location.Lat, Lon: t.Location.Lon, diff --git a/internal/api/tickers.go b/internal/api/tickers.go index 0af1e87..18368a6 100644 --- a/internal/api/tickers.go +++ b/internal/api/tickers.go @@ -11,6 +11,7 @@ import ( "github.com/systemli/ticker/internal/api/helper" "github.com/systemli/ticker/internal/api/response" "github.com/systemli/ticker/internal/bluesky" + "github.com/systemli/ticker/internal/matrix" "github.com/systemli/ticker/internal/signal" "github.com/systemli/ticker/internal/storage" ) @@ -486,6 +487,72 @@ func (h *handler) PutTickerSignalGroupAdmin(c *gin.Context) { c.JSON(http.StatusOK, response.SuccessResponse(map[string]interface{}{})) } +func (h *handler) PutTickerMatrix(c *gin.Context) { + ticker, err := helper.Ticker(c) + if err != nil { + c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeDefault, response.TickerNotFound)) + return + } + + var body storage.TickerMatrix + err = c.Bind(&body) + if err != nil { + c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeNotFound, response.FormError)) + return + } + + // Only create the room if it doesn't exist yet + if ticker.Matrix.RoomID == "" { + roomID, roomName, err := matrix.CreateRoom(h.config, &ticker) + if err != nil { + log.WithError(err).Error("failed to create matrix room") + c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.MatrixError)) + return + } + + ticker.Matrix.RoomID = roomID + ticker.Matrix.RoomName = roomName + } + + ticker.Matrix.Active = body.Active + + err = h.storage.SaveTicker(&ticker) + if err != nil { + c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.StorageError)) + return + } + + c.JSON(http.StatusOK, response.SuccessResponse(map[string]interface{}{"ticker": response.TickerResponse(ticker, h.config)})) +} + +func (h *handler) DeleteTickerMatrix(c *gin.Context) { + ticker, err := helper.Ticker(c) + if err != nil { + c.JSON(http.StatusNotFound, response.ErrorResponse(response.CodeDefault, response.TickerNotFound)) + return + } + + err = matrix.RemoveAllMembers(h.config, ticker.Matrix.RoomID) + if err != nil { + log.WithError(err).Error("failed to remove members") + return + } + + err = matrix.LeaveRoom(h.config, ticker.Matrix.RoomID) + if err != nil { + log.WithError(err).Error("failed to leave room") + return + } + + err = h.storage.DeleteMatrix(&ticker) + if err != nil { + c.JSON(http.StatusBadRequest, response.ErrorResponse(response.CodeDefault, response.StorageError)) + return + } + + c.JSON(http.StatusOK, response.SuccessResponse(map[string]interface{}{"ticker": response.TickerResponse(ticker, h.config)})) +} + func (h *handler) DeleteTicker(c *gin.Context) { ticker, err := helper.Ticker(c) if err != nil { diff --git a/internal/bridge/bridge.go b/internal/bridge/bridge.go index b2466eb..bd77eba 100644 --- a/internal/bridge/bridge.go +++ b/internal/bridge/bridge.go @@ -21,8 +21,9 @@ func RegisterBridges(config config.Config, storage storage.Storage) Bridges { mastodon := MastodonBridge{config, storage} bluesky := BlueskyBridge{config, storage} signalGroup := SignalGroupBridge{config, storage} + matrixRoom := MatrixRoomBridge{config, storage} - return Bridges{"telegram": &telegram, "mastodon": &mastodon, "bluesky": &bluesky, "signalGroup": &signalGroup} + return Bridges{"telegram": &telegram, "mastodon": &mastodon, "bluesky": &bluesky, "signalGroup": &signalGroup, "matrixRoom": &matrixRoom} } func (b *Bridges) Update(ticker storage.Ticker) error { diff --git a/internal/bridge/bridge_test.go b/internal/bridge/bridge_test.go index 792e388..5095374 100644 --- a/internal/bridge/bridge_test.go +++ b/internal/bridge/bridge_test.go @@ -171,7 +171,7 @@ func (s *BridgeTestSuite) TestDelete() { func (s *BridgeTestSuite) TestRegisterBridges() { bridges := RegisterBridges(config.Config{}, nil) - s.Equal(4, len(bridges)) + s.Equal(5, len(bridges)) } func TestBrigde(t *testing.T) { diff --git a/internal/bridge/matrix_room.go b/internal/bridge/matrix_room.go new file mode 100644 index 0000000..469572a --- /dev/null +++ b/internal/bridge/matrix_room.go @@ -0,0 +1,59 @@ +package bridge + +import ( + "github.com/systemli/ticker/internal/config" + "github.com/systemli/ticker/internal/matrix" + "github.com/systemli/ticker/internal/storage" +) + +type MatrixRoomBridge struct { + config config.Config + storage storage.Storage +} + +func (mb *MatrixRoomBridge) Update(ticker storage.Ticker) error { + if !mb.config.Matrix.Enabled() || !ticker.Matrix.Connected() { + return nil + } + + // Rename room if title has changed + err := matrix.UpdateRoomName(mb.config, ticker.Matrix.RoomID, ticker.Title) + if err != nil { + return err + } + + return nil +} + +func (mb *MatrixRoomBridge) Send(ticker storage.Ticker, message *storage.Message) error { + if !mb.config.Matrix.Enabled() || !ticker.Matrix.Connected() || !ticker.Matrix.Active { + return nil + } + + eventIDs, err := matrix.SendMessage(mb.config, mb.storage, ticker.Matrix.RoomID, message) + if err != nil { + return err + } + + // Store all event IDs (images + text) so we can delete them later + message.MatrixRoom.EventIDs = eventIDs + err = mb.storage.SaveMessage(message) + if err != nil { + return err + } + + return nil +} + +func (mb *MatrixRoomBridge) Delete(ticker storage.Ticker, message *storage.Message) error { + if !mb.config.Matrix.Enabled() || !ticker.Matrix.Connected() || !ticker.Matrix.Active { + return nil + } + + err := matrix.DeleteMessage(mb.config, ticker.Matrix.RoomID, message.MatrixRoom.EventIDs) + if err != nil { + return err + } + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 2e21b51..f7a0230 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,7 @@ type Config struct { Database Database `yaml:"database"` Telegram Telegram `yaml:"telegram"` SignalGroup SignalGroup `yaml:"signal_group"` + Matrix Matrix `yaml:"matrix"` MetricsListen string `yaml:"metrics_listen"` Upload Upload `yaml:"upload"` FileBackend afero.Fs @@ -42,6 +43,11 @@ type SignalGroup struct { Account string `yaml:"account"` } +type Matrix struct { + ApiUrl string `yaml:"api_url"` + Token string `yaml:"token"` +} + type Upload struct { Path string `yaml:"path"` URL string `yaml:"url"` @@ -75,6 +81,11 @@ func (t *SignalGroup) Enabled() bool { return t.ApiUrl != "" && t.Account != "" } +// Enabled returns true if required API URL and token are set. +func (m *Matrix) Enabled() bool { + return m.ApiUrl != "" && m.Token != "" +} + // LoadConfig loads config from file. func LoadConfig(path string) Config { c := defaultConfig() @@ -129,6 +140,12 @@ func LoadConfig(path string) Config { if os.Getenv("TICKER_SIGNAL_GROUP_AVATAR") != "" { c.SignalGroup.ApiUrl = os.Getenv("TICKER_SIGNAL_GROUP_AVATAR") } + if os.Getenv("TICKER_MATRIX_API_URL") != "" { + c.Matrix.ApiUrl = os.Getenv("TICKER_MATRIX_API_URL") + } + if os.Getenv("TICKER_MATRIX_TOKEN") != "" { + c.Matrix.Token = os.Getenv("TICKER_MATRIX_TOKEN") + } return c } diff --git a/internal/matrix/matrix.go b/internal/matrix/matrix.go new file mode 100644 index 0000000..943c54a --- /dev/null +++ b/internal/matrix/matrix.go @@ -0,0 +1,306 @@ +package matrix + +import ( + "bytes" + "context" + "fmt" + "os" + "regexp" + "strings" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/systemli/ticker/internal/config" + "github.com/systemli/ticker/internal/logger" + "github.com/systemli/ticker/internal/storage" +) + +var log = logger.GetWithPackage("matrix") + +// getClient creates a new mautrix client with the configured credentials +func getClient(cfg config.Config) (*mautrix.Client, error) { + if !cfg.Matrix.Enabled() { + return nil, fmt.Errorf("matrix bridge is not configured") + } + + client, err := mautrix.NewClient(cfg.Matrix.ApiUrl, "", cfg.Matrix.Token) + if err != nil { + return nil, fmt.Errorf("failed to create matrix client: %w", err) + } + return client, nil +} + +// CreateRoom creates a new public room in Matrix using the Synapse API +func CreateRoom(cfg config.Config, t *storage.Ticker) (string, string, error) { + client, err := getClient(cfg) + if err != nil { + return "", "", err + } + + // Sanitize room name: convert to ASCII-only, remove spaces and special characters + baseRoomName := sanitizeRoomName(t.Title) + + // Try to create the room, incrementing a suffix if the alias is already taken + const maxRetries = 10 + for i := 0; i < maxRetries; i++ { + roomAliasName := baseRoomName + if i > 0 { + roomAliasName = fmt.Sprintf("%s-%d", baseRoomName, i) + } + + roomID, err := attemptCreateRoom(client, t.Title, roomAliasName) + if err == nil { + // Get the canonical alias from the Matrix API + roomName, err := getCanonicalAlias(client, roomID) + if err != nil { + log.WithError(err).WithField("room_id", roomID).Warn("failed to get canonical alias, using constructed alias") + return string(roomID), roomAliasName, nil + } + return string(roomID), roomName, nil + } + + // Check if the error is due to room alias already being taken + if !isRoomInUseError(err) { + return "", "", err + } + + // If it's a room in use error, try again with incremented suffix + log.WithField("room_alias", roomAliasName).Debug("Room alias already taken, trying with incremented suffix") + } + + return "", "", fmt.Errorf("failed to create room after %d attempts: all aliases taken", maxRetries) +} + +// attemptCreateRoom attempts to create a Matrix room with the given alias name +func attemptCreateRoom(client *mautrix.Client, title, roomAliasName string) (id.RoomID, error) { + inviteLevel := 0 + resp, err := client.CreateRoom(context.Background(), &mautrix.ReqCreateRoom{ + Name: title, + RoomAliasName: roomAliasName, + Preset: "public_chat", + Visibility: "public", + // Adjust room permissions to allow anyone to invite and only moderators and above to send messages + PowerLevelOverride: &event.PowerLevelsEventContent{ + InvitePtr: &inviteLevel, + EventsDefault: 50, + }, + }) + if err != nil { + return "", err + } + + log.WithField("room_id", resp.RoomID).WithField("room_name", title).WithField("room_alias", roomAliasName).Debug("Matrix room created successfully") + + return resp.RoomID, nil +} + +// isRoomInUseError checks if the error is due to the room alias already being taken +func isRoomInUseError(err error) bool { + if httpErr, ok := err.(mautrix.HTTPError); ok { + if respErr := httpErr.RespError; respErr != nil { + return respErr.ErrCode == "M_ROOM_IN_USE" + } + } + return false +} + +// sanitizeRoomName converts a room name to an ASCII-only variant without spaces +func sanitizeRoomName(name string) string { + // Convert to lowercase + name = strings.ToLower(name) + + // Remove non-ASCII characters + asciiOnly := regexp.MustCompile(`[^a-z0-9-_]`) + name = asciiOnly.ReplaceAllString(name, "") + + // Trim leading and trailing special characters + name = strings.Trim(name, "-_") + + // If the name is empty after sanitization, use a default + if name == "" { + name = "ticker-room" + } + + return name +} + +// getCanonicalAlias gets the canonical alias for a Matrix room +func getCanonicalAlias(client *mautrix.Client, roomID id.RoomID) (string, error) { + var content event.CanonicalAliasEventContent + err := client.StateEvent(context.Background(), roomID, event.StateCanonicalAlias, "", &content) + if err != nil { + return "", fmt.Errorf("failed to get canonical alias: %w", err) + } + + return content.Alias.String(), nil +} + +// UpdateRoomName updates the name of a Matrix room +func UpdateRoomName(cfg config.Config, roomID, name string) error { + client, err := getClient(cfg) + if err != nil { + return err + } + + _, err = client.SendStateEvent(context.Background(), id.RoomID(roomID), event.StateRoomName, "", &event.RoomNameEventContent{ + Name: name, + }) + if err != nil { + return fmt.Errorf("failed to update room name: %w", err) + } + + log.WithField("room_id", roomID).WithField("room_name", name).Debug("Matrix room name updated successfully") + + return nil +} + +// RemoveAllMembers removes all members from a Matrix room except the bot itself +func RemoveAllMembers(cfg config.Config, roomID string) error { + client, err := getClient(cfg) + if err != nil { + return err + } + + // First, get the current user ID to avoid kicking ourselves + whoami, err := client.Whoami(context.Background()) + if err != nil { + return fmt.Errorf("failed to get current user: %w", err) + } + myUserID := whoami.UserID + + // Get all room members + members, err := client.JoinedMembers(context.Background(), id.RoomID(roomID)) + if err != nil { + return fmt.Errorf("failed to get room members: %w", err) + } + + // Kick each member except ourselves + for userID := range members.Joined { + if userID != myUserID { + _, err := client.KickUser(context.Background(), id.RoomID(roomID), &mautrix.ReqKickUser{ + UserID: userID, + Reason: "Room is being deleted", + }) + if err != nil { + log.WithError(err).WithField("user_id", userID).Error("failed to kick user") + // Continue kicking other users even if one fails + } else { + log.WithField("user_id", userID).Debug("kicked user from Matrix room") + } + } + } + + return nil +} + +// LeaveRoom leaves a Matrix room +func LeaveRoom(cfg config.Config, roomID string) error { + client, err := getClient(cfg) + if err != nil { + return err + } + + _, err = client.LeaveRoom(context.Background(), id.RoomID(roomID)) + if err != nil { + return fmt.Errorf("failed to leave room: %w", err) + } + + log.WithField("room_id", roomID).Debug("left Matrix room") + + return nil +} + +func SendMessage(cfg config.Config, store storage.Storage, roomID string, message *storage.Message) ([]string, error) { + client, err := getClient(cfg) + if err != nil { + return nil, err + } + + var eventIDs []string + + // Send image attachments first (one per message in Matrix) + for _, attachment := range message.Attachments { + upload, err := store.FindUploadByUUID(attachment.UUID) + if err != nil { + log.WithError(err).WithField("uuid", attachment.UUID).Error("failed to find upload") + continue + } + + // Read file from disk + fileContent, err := os.ReadFile(upload.FullPath(cfg.Upload.Path)) + if err != nil { + log.WithError(err).WithField("path", upload.FullPath(cfg.Upload.Path)).Error("failed to read file") + continue + } + + // Upload file to Matrix + uploadResp, err := client.UploadMedia(context.Background(), mautrix.ReqUploadMedia{ + Content: bytes.NewReader(fileContent), + ContentType: upload.ContentType, + FileName: upload.FileName(), + }) + if err != nil { + log.WithError(err).WithField("file", upload.FileName()).Error("failed to upload file to Matrix") + continue + } + + // Send image message + imageResp, err := client.SendMessageEvent(context.Background(), id.RoomID(roomID), event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgImage, + Body: upload.FileName(), + URL: uploadResp.ContentURI.CUString(), + Info: &event.FileInfo{ + MimeType: upload.ContentType, + Size: len(fileContent), + }, + }) + if err != nil { + log.WithError(err).WithField("file", upload.FileName()).Error("failed to send image message") + continue + } + + // Store image event ID + eventIDs = append(eventIDs, imageResp.EventID.String()) + log.WithField("room_id", roomID).WithField("file", upload.FileName()).WithField("event_id", imageResp.EventID).Debug("sent image to Matrix room") + } + + // Send text message last + resp, err := client.SendMessageEvent(context.Background(), id.RoomID(roomID), event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgText, + Body: message.Text, + }) + if err != nil { + return nil, fmt.Errorf("failed to send message: %w", err) + } + + // Store text message event ID + eventIDs = append(eventIDs, resp.EventID.String()) + log.WithField("room_id", roomID).WithField("event_id", resp.EventID).Debug("sent message to Matrix room") + + return eventIDs, nil +} + +// DeleteMessage redacts multiple messages (images + text) from a Matrix room +func DeleteMessage(cfg config.Config, roomID string, eventIDs []string) error { + client, err := getClient(cfg) + if err != nil { + return err + } + + // Redact all event IDs (images and text message) + for _, eventID := range eventIDs { + _, err = client.RedactEvent(context.Background(), id.RoomID(roomID), id.EventID(eventID), mautrix.ReqRedact{ + Reason: "Message deleted", + }) + if err != nil { + log.WithError(err).WithField("event_id", eventID).Error("failed to redact event") + // Continue with other events even if one fails + continue + } + log.WithField("room_id", roomID).WithField("event_id", eventID).Debug("deleted message from Matrix room") + } + + return nil +} diff --git a/internal/matrix/matrix_test.go b/internal/matrix/matrix_test.go new file mode 100644 index 0000000..aa95667 --- /dev/null +++ b/internal/matrix/matrix_test.go @@ -0,0 +1,105 @@ +package matrix + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/systemli/ticker/internal/config" + "github.com/systemli/ticker/internal/storage" +) + +func TestCreateRoom(t *testing.T) { + // Mock server to simulate Matrix API + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/_matrix/client/v3/createRoom" { + // Verify request method and headers + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + // Send response + response := map[string]string{ + "room_id": "!test-room-id:example.com", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + } else if r.URL.Path == "/_matrix/client/v3/rooms/!test-room-id:example.com/state/m.room.canonical_alias/" { + // Return canonical alias (mautrix uses trailing slash) + response := map[string]string{ + "alias": "#test-room:example.com", + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + } else { + // Return empty response for other requests + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + } + })) + defer server.Close() + + // Create config + cfg := config.Config{ + Matrix: config.Matrix{ + ApiUrl: server.URL, + Token: "test-token", + }, + } + + // Create a ticker + ticker := &storage.Ticker{ + Title: "Test Room", + } + + // Test CreateRoom + roomID, roomName, err := CreateRoom(cfg, ticker) + assert.NoError(t, err) + assert.Equal(t, "!test-room-id:example.com", roomID) + assert.NotEmpty(t, roomName) // Just verify we got something back +} + +func TestCreateRoom_NotConfigured(t *testing.T) { + // Create config without Matrix configuration + cfg := config.Config{} + + ticker := &storage.Ticker{ + Title: "Test Room", + } + + // Test CreateRoom with unconfigured bridge + _, _, err := CreateRoom(cfg, ticker) + assert.Error(t, err) + assert.Contains(t, err.Error(), "matrix bridge is not configured") +} + +func TestCreateRoom_APIError(t *testing.T) { + // Mock server that returns an error + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"errcode":"M_FORBIDDEN","error":"Invalid access token"}`)) + })) + defer server.Close() + + // Create config + cfg := config.Config{ + Matrix: config.Matrix{ + ApiUrl: server.URL, + Token: "invalid-token", + }, + } + + ticker := &storage.Ticker{ + Title: "Test Room", + } + + // Test CreateRoom with invalid token + _, _, err := CreateRoom(cfg, ticker) + assert.Error(t, err) +} diff --git a/internal/storage/message.go b/internal/storage/message.go index ee41467..8f8db06 100644 --- a/internal/storage/message.go +++ b/internal/storage/message.go @@ -20,6 +20,7 @@ type Message struct { Mastodon MastodonMeta `gorm:"serializer:json"` Bluesky BlueskyMeta `gorm:"serializer:json"` SignalGroup SignalGroupMeta `gorm:"serializer:json"` + MatrixRoom MatrixRoomMeta `gorm:"serializer:json"` } func NewMessage() Message { @@ -31,6 +32,7 @@ func (m *Message) AsMap() map[string]interface{} { mastodon, _ := json.Marshal(m.Mastodon) bluesky, _ := json.Marshal(m.Bluesky) signalGroup, _ := json.Marshal(m.SignalGroup) + matrixRoom, _ := json.Marshal(m.MatrixRoom) return map[string]interface{}{ "id": m.ID, @@ -42,6 +44,7 @@ func (m *Message) AsMap() map[string]interface{} { "mastodon": mastodon, "bluesky": bluesky, "signal_group": signalGroup, + "matrix_room": matrixRoom, } } @@ -65,6 +68,10 @@ type SignalGroupMeta struct { Timestamp int } +type MatrixRoomMeta struct { + EventIDs []string +} + type Attachment struct { ID int `gorm:"primaryKey"` CreatedAt time.Time diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go index cd0a47a..a0a0de7 100644 --- a/internal/storage/migrations.go +++ b/internal/storage/migrations.go @@ -12,6 +12,7 @@ func MigrateDB(db *gorm.DB) error { &TickerTelegram{}, &TickerBluesky{}, &TickerSignalGroup{}, + &TickerMatrix{}, &TickerWebsite{}, &User{}, &Setting{}, diff --git a/internal/storage/mock_Storage.go b/internal/storage/mock_Storage.go index 9e962a5..3ec12e5 100644 --- a/internal/storage/mock_Storage.go +++ b/internal/storage/mock_Storage.go @@ -400,6 +400,57 @@ func (_c *MockStorage_DeleteSignalGroup_Call) RunAndReturn(run func(ticker *Tick return _c } +// DeleteMatrix provides a mock function for the type MockStorage +func (_mock *MockStorage) DeleteMatrix(ticker *Ticker) error { + ret := _mock.Called(ticker) + + if len(ret) == 0 { + panic("no return value specified for DeleteMatrix") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(*Ticker) error); ok { + r0 = returnFunc(ticker) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockStorage_DeleteMatrix_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteMatrix' +type MockStorage_DeleteMatrix_Call struct { + *mock.Call +} + +// DeleteMatrix is a helper method to define mock.On call +// - ticker *Ticker +func (_e *MockStorage_Expecter) DeleteMatrix(ticker interface{}) *MockStorage_DeleteMatrix_Call { + return &MockStorage_DeleteMatrix_Call{Call: _e.mock.On("DeleteMatrix", ticker)} +} + +func (_c *MockStorage_DeleteMatrix_Call) Run(run func(ticker *Ticker)) *MockStorage_DeleteMatrix_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 *Ticker + if args[0] != nil { + arg0 = args[0].(*Ticker) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockStorage_DeleteMatrix_Call) Return(err error) *MockStorage_DeleteMatrix_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockStorage_DeleteMatrix_Call) RunAndReturn(run func(ticker *Ticker) error) *MockStorage_DeleteMatrix_Call { + _c.Call.Return(run) + return _c +} + // DeleteTelegram provides a mock function for the type MockStorage func (_mock *MockStorage) DeleteTelegram(ticker *Ticker) error { ret := _mock.Called(ticker) diff --git a/internal/storage/sql_storage.go b/internal/storage/sql_storage.go index ea5ceea..f447f64 100644 --- a/internal/storage/sql_storage.go +++ b/internal/storage/sql_storage.go @@ -318,6 +318,10 @@ func (s *SqlStorage) DeleteIntegrations(ticker *Ticker) error { return err } + if err := s.DeleteMatrix(ticker); err != nil { + return err + } + return nil } @@ -345,6 +349,12 @@ func (s *SqlStorage) DeleteSignalGroup(ticker *Ticker) error { return s.DB.Delete(TickerSignalGroup{}, EqualTickerID, ticker.ID).Error } +func (s *SqlStorage) DeleteMatrix(ticker *Ticker) error { + ticker.Matrix = TickerMatrix{} + + return s.DB.Delete(TickerMatrix{}, EqualTickerID, ticker.ID).Error +} + func (s *SqlStorage) FindUploadByUUID(uuid string) (Upload, error) { var upload Upload diff --git a/internal/storage/sql_storage_test.go b/internal/storage/sql_storage_test.go index 99131e8..5ebadf4 100644 --- a/internal/storage/sql_storage_test.go +++ b/internal/storage/sql_storage_test.go @@ -31,6 +31,7 @@ func (s *SqlStorageTestSuite) SetupTest() { &TickerMastodon{}, &TickerBluesky{}, &TickerSignalGroup{}, + &TickerMatrix{}, &TickerWebsite{}, &User{}, &Message{}, diff --git a/internal/storage/storage.go b/internal/storage/storage.go index ab48ebe..37a6e2d 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -34,6 +34,7 @@ type Storage interface { DeleteTelegram(ticker *Ticker) error DeleteBluesky(ticker *Ticker) error DeleteSignalGroup(ticker *Ticker) error + DeleteMatrix(ticker *Ticker) error SaveUpload(upload *Upload) error FindUploadByUUID(uuid string) (Upload, error) FindUploadsByIDs(ids []int) ([]Upload, error) diff --git a/internal/storage/ticker.go b/internal/storage/ticker.go index 385546b..adf18af 100644 --- a/internal/storage/ticker.go +++ b/internal/storage/ticker.go @@ -20,6 +20,7 @@ type Ticker struct { Mastodon TickerMastodon Bluesky TickerBluesky SignalGroup TickerSignalGroup + Matrix TickerMatrix Websites []TickerWebsite `gorm:"foreignKey:TickerID;"` Users []User `gorm:"many2many:ticker_users;"` } @@ -139,6 +140,20 @@ func (s *TickerSignalGroup) Connected() bool { return s.GroupID != "" } +type TickerMatrix struct { + ID int `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + TickerID int `gorm:"index"` + Active bool + RoomID string + RoomName string +} + +func (m *TickerMatrix) Connected() bool { + return m.RoomID != "" +} + type TickerLocation struct { Lat float64 Lon float64