diff --git a/.gitignore b/.gitignore index ee48d47..32af1d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ twitch-bot-streak +twitch-bot-streak_linux_amd64.tgz diff --git a/Makefile b/Makefile index 696c412..def29d7 100644 --- a/Makefile +++ b/Makefile @@ -7,3 +7,4 @@ build: -mod=readonly \ -modcacherw \ -trimpath + tar -czf twitch-bot-streak_linux_amd64.tgz twitch-bot-streak diff --git a/README.md b/README.md index a2d5bec..c30e7e1 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ Addon for my [twitch-bot](https://github.com/Luzifer/twitch-bot) executed throug ## Features - By default uses same database connection as the bot through the same environment variable -- Needs two tables (see `schema.sql`) to store its data +- Needs two extra tables to store its data (does not interfere with the bot) - Reward is only counted once per stream (in case Twitch messes up stuff) -- If a stream goes offline for a few minutes a grace period (30m) is taken which will not reset the stream streak +- If a stream goes offline for a few minutes a grace period is taken which will not reset the stream streak ## Usage diff --git a/go.mod b/go.mod index 2ffe2e4..4a151bf 100644 --- a/go.mod +++ b/go.mod @@ -4,15 +4,42 @@ go 1.22.1 require ( github.com/Luzifer/rconfig/v2 v2.5.0 - github.com/go-sql-driver/mysql v1.6.0 - github.com/jmoiron/sqlx v1.3.5 + github.com/glebarez/sqlite v1.11.0 + github.com/go-sql-driver/mysql v1.8.0 github.com/pkg/errors v0.9.1 github.com/sirupsen/logrus v1.9.3 + github.com/stretchr/testify v1.8.4 + gorm.io/driver/mysql v1.5.6 + gorm.io/driver/postgres v1.5.7 + gorm.io/gorm v1.25.8 ) require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/glebarez/go-sqlite v1.22.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect + github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect + golang.org/x/crypto v0.21.0 // indirect + golang.org/x/sync v0.6.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect gopkg.in/validator.v2 v2.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.47.0 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.2 // indirect + modernc.org/sqlite v1.29.5 // indirect ) diff --git a/go.sum b/go.sum index 796752e..ea4d2f2 100644 --- a/go.sum +++ b/go.sum @@ -1,34 +1,74 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Luzifer/rconfig/v2 v2.5.0 h1:zx5lfQbNX3za4VegID97IeY+M+BmfgHxWJTYA94sxok= github.com/Luzifer/rconfig/v2 v2.5.0/go.mod h1:eGWUPQeCPv/Pr/p0hjmwFgI20uqvwi/Szen69hUzGzU= 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/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= -github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= -github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= -github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= -github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +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/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= +github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.8.0 h1:UtktXaU2Nb64z/pLiGIxY4431SJ4/dR5cjMmlVHgnT4= +github.com/go-sql-driver/mysql v1.8.0/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= -github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= -github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -37,3 +77,34 @@ gopkg.in/validator.v2 v2.0.1/go.mod h1:lIUZBlB3Im4s/eYp39Ry/wkR02yOPhZ9IwIRBjuPu gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8= +gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM= +gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.8 h1:WAGEZ/aEcznN4D03laj8DKnehe1e9gYQAjW8xyPRdeo= +gorm.io/gorm v1.25.8/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +modernc.org/cc/v4 v4.19.5 h1:QlsZyQ1zf78DGeqnQ9ILi9hXyMdoC5e1qoGNUyBjHQw= +modernc.org/cc/v4 v4.19.5/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.13.0 h1:99E8QHRoPrXN8VpS0zgAgJ5nSjpXrPKpsJIMvGL/2Oc= +modernc.org/ccgo/v4 v4.13.0/go.mod h1:Td6RI9W9G2ZpKHaJ7UeGEiB2aIpoDqLBnm4wtkbJTbQ= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/libc v1.47.0 h1:BXrzId9fOOkBtS+uFQ5aZyVGmt7WcSEPrXF5Kwsho90= +modernc.org/libc v1.47.0/go.mod h1:gzCncw0a74aCiVqHeWAYHHaW//fkSHHS/3S/gfhLlCI= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.29.5 h1:8l/SQKAjDtZFo9lkJLdk8g9JEOeYRG4/ghStDCCTiTE= +modernc.org/sqlite v1.29.5/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go index d8d5e7c..c303d09 100644 --- a/main.go +++ b/main.go @@ -7,24 +7,35 @@ import ( "os" "regexp" "text/template" + "time" + "git.luzifer.io/luzifer/twitch-bot-streak/pkg/database" _ "github.com/go-sql-driver/mysql" - "github.com/jmoiron/sqlx" "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/Luzifer/rconfig/v2" ) +type ( + botResponse struct { + Type string `json:"type"` + Attributes map[string]any `json:"attributes"` + } +) + var ( cfg = struct { - Action string `flag:"action,a" description:"Which action to perform"` - TwitchID uint64 `flag:"twitch-id" description:"ID of the user to access the streak of"` - TwitchUsername string `flag:"twitch-username" description:"Username of the user to update in streaks table"` + Action string `flag:"action,a" description:"Which action to perform"` + TwitchID uint64 `flag:"twitch-id" description:"ID of the user to access the streak of"` + TwitchUsername string `flag:"twitch-username" description:"Username of the user to update in streaks table"` + StreamOfflineGrace time.Duration `flag:"stream-offline-grace" default:"30m" description:"How long to not start a new stream after previous went offline"` - StorageConnString string `flag:"storage-conn-string" description:"How to connect to the database"` - LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"` - VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"` + StorageConnString string `flag:"storage-conn-string" default:"./storage.db" description:"Connection string for the database"` + StorageConnType string `flag:"storage-conn-type" default:"sqlite" description:"One of: mysql, postgres, sqlite"` + + LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"` + VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"` }{} version = "dev" @@ -64,28 +75,28 @@ func main() { logrus.WithError(err).Fatal("parsing message template") } - db, err := sqlx.Open("mysql", cfg.StorageConnString) + db, err := database.New(cfg.StorageConnType, cfg.StorageConnString) if err != nil { logrus.WithError(err).Fatal("connecting to database") } switch cfg.Action { case "stream_start": - if err = startStream(db); err != nil { + if err = db.StartStream(cfg.StreamOfflineGrace); err != nil { logrus.WithError(err).Fatal("starting stream") } case "stream_offline": - if err = setStreamOffline(db); err != nil { + if err = db.SetStreamOffline(); err != nil { logrus.WithError(err).Fatal("stopping stream") } case "count_stream": - if err = startStream(db); err != nil { + if err = db.StartStream(cfg.StreamOfflineGrace); err != nil { logrus.WithError(err).Fatal("starting stream") } - user, err := countStreak(db, cfg.TwitchID, cfg.TwitchUsername) + user, err := db.CountStreak(cfg.TwitchID, cfg.TwitchUsername) if err != nil { logrus.WithError(err).Fatal("counting streak") } diff --git a/pkg/database/db.go b/pkg/database/db.go new file mode 100644 index 0000000..8342701 --- /dev/null +++ b/pkg/database/db.go @@ -0,0 +1,70 @@ +package database + +import ( + "fmt" + + "github.com/glebarez/sqlite" + "github.com/pkg/errors" + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +type ( + DB struct { + db *gorm.DB + } +) + +// New creates a new DB with the given driver and database +func New(driverName, connString string) (d *DB, err error) { + var innerDB gorm.Dialector + + switch driverName { + case "mysql": + innerDB = mysql.Open(connString) + + case "postgres": + innerDB = postgres.Open(connString) + + case "sqlite": + innerDB = sqlite.Open(connString) + + default: + return nil, errors.Errorf("unknown database driver %s", driverName) + } + + db, err := gorm.Open(innerDB, &gorm.Config{ + DisableForeignKeyConstraintWhenMigrating: true, + }) + if err != nil { + return nil, errors.Wrap(err, "connecting database") + } + + d = &DB{ + db: db, + } + + if err = db.AutoMigrate( + &StreakMeta{}, + &StreakUser{}, + ); err != nil { + return nil, fmt.Errorf("applying schema: %w", err) + } + + return d, nil +} + +// Close closes the underlying database +func (d DB) Close() error { + sdb, err := d.db.DB() + if err != nil { + return fmt.Errorf("getting underlying database: %w", err) + } + + if err = sdb.Close(); err != nil { + return fmt.Errorf("closing db: %w", err) + } + + return nil +} diff --git a/pkg/database/query.go b/pkg/database/query.go new file mode 100644 index 0000000..b872b22 --- /dev/null +++ b/pkg/database/query.go @@ -0,0 +1,138 @@ +package database + +import ( + "errors" + "fmt" + "time" + + "gorm.io/gorm" +) + +var defaultMetaTime = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + +func (d DB) CountStreak(twitchID uint64, username string) (user StreakUser, err error) { + if err = d.db.Transaction(func(tx *gorm.DB) (err error) { + if err = tx.First(&user, "twitch_id = ?", twitchID).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("getting user: %w", err) + } + + // User was not yet inserted + user = StreakUser{ + TwitchID: twitchID, + Username: username, + StreamsCount: 0, + CurrentStreak: 0, + MaxStreak: 0, + StreakStatus: StatusBroken, + } + } + + switch user.StreakStatus { + case StatusActive: + // User has an active streak, do nothing + return nil + + case StatusBroken: + // User needs a new streak + user.CurrentStreak = 1 + + case StatusPending: + // User can prolong their streak + user.CurrentStreak += 1 + } + + // In any case set the streak active and count the current stream + user.StreamsCount++ + user.StreakStatus = StatusActive + if user.CurrentStreak > user.MaxStreak { + user.MaxStreak = user.CurrentStreak + } + + if err = tx.Save(&user).Error; err != nil { + return fmt.Errorf("saving user: %w", err) + } + + return nil + }); err != nil { + return user, fmt.Errorf("counting streak for user: %w", err) + } + + return user, nil +} + +func (d DB) SetStreamOffline() (err error) { + return d.storeTimeToMeta(d.db, "stream_offline", time.Now()) +} + +func (d DB) StartStream(streamOfflineGrace time.Duration) (err error) { + if err = d.db.Transaction(func(tx *gorm.DB) (err error) { + lastOffline, err := d.getTimeFromMeta(tx, "stream_offline") + if err != nil { + return fmt.Errorf("getting offline time: %w", err) + } + + lastOnline, err := d.getTimeFromMeta(tx, "stream_online") + if err != nil { + return fmt.Errorf("getting online time: %w", err) + } + + if err = d.storeTimeToMeta(tx, "stream_online", time.Now()); err != nil { + return fmt.Errorf("storing stream start: %w", err) + } + + if time.Since(lastOffline) < streamOfflineGrace || lastOnline.After(lastOffline) { + // We only had a short break or the stream was already started + return nil + } + + if err = tx.Model(&StreakUser{}). + Where("streak_status = ?", StatusPending). + Update("streak_status", StatusBroken). + Error; err != nil { + return fmt.Errorf("breaking streaks for pending users: %w", err) + } + + if err = tx.Model(&StreakUser{}). + Where("streak_status = ?", StatusActive). + Update("streak_status", StatusPending). + Error; err != nil { + return fmt.Errorf("breaking streaks for pending users: %w", err) + } + + return nil + }); err != nil { + return fmt.Errorf("starting stream: %w", err) + } + + return nil +} + +func (d DB) getTimeFromMeta(db *gorm.DB, key string) (t time.Time, err error) { + var meta StreakMeta + if err = db.First(&meta, "name = ?", key).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return t, fmt.Errorf("getting last %s time: %w", key, err) + } + + meta.Value = defaultMetaTime.Format(time.RFC3339Nano) + } + + t, err = time.Parse(time.RFC3339Nano, meta.Value) + if err != nil { + return t, fmt.Errorf("parsing %s time: %w", key, err) + } + + return t, nil +} + +func (d DB) storeTimeToMeta(db *gorm.DB, key string, t time.Time) (err error) { + if err = db.Save(&StreakMeta{ + Name: key, + Value: t.Format(time.RFC3339Nano), + }).Error; err != nil { + return fmt.Errorf("updating stream meta: %w", err) + } + + return nil +} diff --git a/pkg/database/query_test.go b/pkg/database/query_test.go new file mode 100644 index 0000000..4b2a7b6 --- /dev/null +++ b/pkg/database/query_test.go @@ -0,0 +1,168 @@ +package database + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testStreamOfflineGrace = 30 * time.Second + +func TestCountStreak(t *testing.T) { + db, err := New("sqlite", "file::memory:?cache=shared") + require.NoError(t, err) + t.Cleanup(func() { assert.NoError(t, db.Close()) }) + + err = db.StartStream(testStreamOfflineGrace) + require.NoError(t, err) + + // First time the user registers + user, err := db.CountStreak(1, "test") + require.NoError(t, err) + assert.Equal(t, uint64(1), user.TwitchID) + assert.Equal(t, "test", user.Username) + assert.Equal(t, uint64(1), user.StreamsCount) + assert.Equal(t, uint64(1), user.MaxStreak) + assert.Equal(t, uint64(1), user.CurrentStreak) + assert.Equal(t, StatusActive, user.StreakStatus) + + // Register on the same stream should not change anything + user, err = db.CountStreak(1, "test") + assert.NoError(t, err) + assert.Equal(t, uint64(1), user.StreamsCount) + assert.Equal(t, uint64(1), user.MaxStreak) + assert.Equal(t, uint64(1), user.CurrentStreak) + assert.Equal(t, StatusActive, user.StreakStatus) + + // Interrupt the stream for less than the grace + err = db.SetStreamOffline() + require.NoError(t, err) + err = db.StartStream(testStreamOfflineGrace) + require.NoError(t, err) + + // Streak should still be active + err = db.db.First(&user).Error + require.NoError(t, err) + assert.Equal(t, StatusActive, user.StreakStatus) + + // Register on the same stream should not change anything + user, err = db.CountStreak(1, "test") + assert.NoError(t, err) + assert.Equal(t, uint64(1), user.StreamsCount) + assert.Equal(t, uint64(1), user.MaxStreak) + assert.Equal(t, uint64(1), user.CurrentStreak) + assert.Equal(t, StatusActive, user.StreakStatus) + + // Interrupt the stream and start a new one + err = db.SetStreamOffline() + require.NoError(t, err) + time.Sleep(20 * time.Millisecond) + err = db.StartStream(10 * time.Millisecond) + require.NoError(t, err) + + // Streak should now be pending + err = db.db.First(&user).Error + require.NoError(t, err) + assert.Equal(t, StatusPending, user.StreakStatus) + + // Register on the next stream should not break the streak + user, err = db.CountStreak(1, "test") + assert.NoError(t, err) + assert.Equal(t, uint64(2), user.StreamsCount) + assert.Equal(t, uint64(2), user.MaxStreak) + assert.Equal(t, uint64(2), user.CurrentStreak) + assert.Equal(t, StatusActive, user.StreakStatus) + + // Interrupt the stream and start a new one + err = db.SetStreamOffline() + require.NoError(t, err) + time.Sleep(20 * time.Millisecond) + err = db.StartStream(10 * time.Millisecond) + require.NoError(t, err) + + // Streak should now be pending + err = db.db.First(&user).Error + require.NoError(t, err) + assert.Equal(t, StatusPending, user.StreakStatus) + + // Interrupt the stream and start a new one (twice) + err = db.SetStreamOffline() + require.NoError(t, err) + time.Sleep(20 * time.Millisecond) + err = db.StartStream(10 * time.Millisecond) + require.NoError(t, err) + + // Streak should now be broken + err = db.db.First(&user).Error + require.NoError(t, err) + assert.Equal(t, StatusBroken, user.StreakStatus) + + // Register with one stream left out should break the streak + user, err = db.CountStreak(1, "test") + assert.NoError(t, err) + assert.Equal(t, uint64(3), user.StreamsCount) + assert.Equal(t, uint64(2), user.MaxStreak) + assert.Equal(t, uint64(1), user.CurrentStreak) + assert.Equal(t, StatusActive, user.StreakStatus) +} + +func TestMetaTimestore(t *testing.T) { + db, err := New("sqlite", "file::memory:?cache=shared") + require.NoError(t, err) + t.Cleanup(func() { assert.NoError(t, db.Close()) }) + + parsed, err := db.getTimeFromMeta(db.db, "test") + assert.NoError(t, err) + assert.True(t, defaultMetaTime.Equal(parsed)) + + now := time.Now() + + err = db.storeTimeToMeta(db.db, "test", now) + assert.NoError(t, err) + + var meta StreakMeta + err = db.db.First(&meta, "name = ?", "test").Error + assert.NoError(t, err) + assert.Equal(t, "test", meta.Name) + assert.Equal(t, now.Format(time.RFC3339Nano), meta.Value) + + parsed, err = db.getTimeFromMeta(db.db, "test") + assert.NoError(t, err) + assert.True(t, now.Equal(parsed)) +} + +func TestStreamOffline(t *testing.T) { + db, err := New("sqlite", "file::memory:?cache=shared") + require.NoError(t, err) + t.Cleanup(func() { assert.NoError(t, db.Close()) }) + + parsed, err := db.getTimeFromMeta(db.db, "stream_offline") + assert.NoError(t, err) + assert.True(t, defaultMetaTime.Equal(parsed)) + + err = db.SetStreamOffline() + require.NoError(t, err) + + parsed, err = db.getTimeFromMeta(db.db, "stream_offline") + assert.NoError(t, err) + assert.Less(t, time.Since(parsed), testStreamOfflineGrace) +} + +func TestStreamOnline(t *testing.T) { + db, err := New("sqlite", "file::memory:?cache=shared") + require.NoError(t, err) + t.Cleanup(func() { assert.NoError(t, db.Close()) }) + + parsed, err := db.getTimeFromMeta(db.db, "stream_online") + assert.NoError(t, err) + assert.True(t, defaultMetaTime.Equal(parsed)) + + err = db.StartStream(testStreamOfflineGrace) + require.NoError(t, err) + + parsed, err = db.getTimeFromMeta(db.db, "stream_online") + assert.NoError(t, err) + assert.Less(t, time.Since(parsed), testStreamOfflineGrace) +} diff --git a/pkg/database/struct.go b/pkg/database/struct.go new file mode 100644 index 0000000..46379dc --- /dev/null +++ b/pkg/database/struct.go @@ -0,0 +1,25 @@ +package database + +type ( + StreakMeta struct { + Name string `gorm:"primaryKey"` + Value string + } + + StreakUser struct { + TwitchID uint64 `gorm:"primaryKey"` + Username string + StreamsCount uint64 + CurrentStreak uint64 + MaxStreak uint64 + StreakStatus Status + } + + Status string +) + +const ( + StatusBroken Status = "broken" // Streak is broken and must be started anew + StatusPending Status = "pending" // Streak is pending to be broken and can be continued + StatusActive Status = "active" // Streak is active (renewed in current stream) +) diff --git a/query.go b/query.go deleted file mode 100644 index a4c3c53..0000000 --- a/query.go +++ /dev/null @@ -1,163 +0,0 @@ -package main - -import ( - "database/sql" - "errors" - "fmt" - "time" - - "github.com/jmoiron/sqlx" -) - -const streamOfflineGrace = 30 * time.Minute - -func countStreak(db *sqlx.DB, twitchID uint64, username string) (user streakUser, err error) { - if err = withTx(db, func(tx *sqlx.Tx) (err error) { - if err = tx.Get(&user, "SELECT * FROM streak_users WHERE twitch_id = ?", twitchID); err != nil { - if !errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("getting user: %w", err) - } - - // User was not yet inserted - user = streakUser{ - TwitchID: twitchID, - Username: username, - StreamsCount: 0, - CurrentStreak: 0, - MaxStreak: 0, - StreakStatus: statusBroken, - } - } - - switch user.StreakStatus { - case statusActive: - // User has an active streak, do nothing - return nil - - case statusBroken: - // User needs a new streak - user.CurrentStreak = 1 - - case statusPending: - // User can prolong their streak - user.CurrentStreak += 1 - } - - // In any case set the streak active and count the current stream - user.StreamsCount++ - user.StreakStatus = statusActive - if user.CurrentStreak > user.MaxStreak { - user.MaxStreak = user.CurrentStreak - } - - if _, err = db.NamedExec( - `INSERT INTO streak_users VALUES (:twitch_id, :username, :streams_count, :current_streak, :max_streak, :streak_status) - ON DUPLICATE KEY UPDATE username=:username, streams_count=:streams_count, current_streak=:current_streak, max_streak=:max_streak, streak_status=:streak_status`, - user, - ); err != nil { - return fmt.Errorf("updating user streak status: %w", err) - } - - return nil - }); err != nil { - return user, fmt.Errorf("counting streak for user: %w", err) - } - - return user, nil -} - -func getTimeFromMeta(tx *sqlx.Tx, key string) (t time.Time, err error) { - var lastOfflineStr string - if err = tx.Get(&lastOfflineStr, "SELECT value FROM streak_meta WHERE `key` = ?", key); err != nil { - if !errors.Is(err, sql.ErrNoRows) { - return t, fmt.Errorf("getting last %s time: %w", key, err) - } - - lastOfflineStr = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano) - } - - t, err = time.Parse(time.RFC3339Nano, lastOfflineStr) - if err != nil { - return t, fmt.Errorf("parsing offline time: %w", err) - } - - return t, nil -} - -func setStreamOffline(db *sqlx.DB) (err error) { - return withTx(db, func(tx *sqlx.Tx) error { - return storeTimeToMeta(tx, "stream_offline", time.Now()) - }) -} - -func startStream(db *sqlx.DB) (err error) { - if err = withTx(db, func(tx *sqlx.Tx) (err error) { - lastOffline, err := getTimeFromMeta(tx, "stream_offline") - if err != nil { - return fmt.Errorf("getting offline time: %w", err) - } - - lastOnline, err := getTimeFromMeta(tx, "stream_online") - if err != nil { - return fmt.Errorf("getting online time: %w", err) - } - - if err = storeTimeToMeta(tx, "stream_online", time.Now()); err != nil { - return fmt.Errorf("storing stream start: %w", err) - } - - if time.Since(lastOffline) < streamOfflineGrace || lastOnline.After(lastOffline) { - // We only had a short break or the stream was already started - return nil - } - - if _, err = tx.Exec("UPDATE streak_users SET streak_status = ?, current_streak = 0 WHERE streak_status = ?", statusBroken, statusPending); err != nil { - return fmt.Errorf("breaking streaks for pending users: %w", err) - } - - if _, err = tx.Exec("UPDATE streak_users SET streak_status = ? WHERE streak_status = ?", statusPending, statusActive); err != nil { - return fmt.Errorf("breaking streaks for pending users: %w", err) - } - - return nil - }); err != nil { - return fmt.Errorf("starting stream: %w", err) - } - - return nil -} - -func storeTimeToMeta(tx *sqlx.Tx, key string, t time.Time) (err error) { - if _, err = tx.NamedExec( - `INSERT INTO streak_meta VALUES (:key, :value) - ON DUPLICATE KEY UPDATE value = :value`, - map[string]any{ - "key": key, - "value": t.Format(time.RFC3339Nano), - }, - ); err != nil { - return fmt.Errorf("updating stream meta: %w", err) - } - - return nil -} - -func withTx(db *sqlx.DB, fn func(*sqlx.Tx) error) error { - tx, err := db.Beginx() - if err != nil { - return fmt.Errorf("starting transaction: %w", err) - } - - if err = fn(tx); err != nil { - if rerr := tx.Rollback(); rerr != nil { - return fmt.Errorf("rolling back after error: %w", rerr) - } - return fmt.Errorf("executing transaction (rolled back): %w", err) - } - - if err = tx.Commit(); err != nil { - return fmt.Errorf("committing transaction: %w", err) - } - - return nil -} diff --git a/schema.sql b/schema.sql deleted file mode 100644 index 33bbc65..0000000 --- a/schema.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE TABLE `streak_meta` ( - `key` varchar(255) NOT NULL, - `value` varchar(255) DEFAULT NULL, - PRIMARY KEY (`key`) -) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - -CREATE TABLE `streak_users` ( - `twitch_id` bigint(20) unsigned NOT NULL, - `username` varchar(255) NOT NULL, - `streams_count` int(10) unsigned NOT NULL DEFAULT 0, - `current_streak` int(10) unsigned NOT NULL DEFAULT 0, - `max_streak` int(10) unsigned NOT NULL DEFAULT 0, - `streak_status` enum('broken','pending','active') NOT NULL DEFAULT 'broken', - PRIMARY KEY (`twitch_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/struct.go b/struct.go deleted file mode 100644 index 29210e6..0000000 --- a/struct.go +++ /dev/null @@ -1,25 +0,0 @@ -package main - -type ( - botResponse struct { - Type string `json:"type"` - Attributes map[string]any `json:"attributes"` - } - - streakUser struct { - TwitchID uint64 `db:"twitch_id"` - Username string `db:"username"` - StreamsCount uint64 `db:"streams_count"` - CurrentStreak uint64 `db:"current_streak"` - MaxStreak uint64 `db:"max_streak"` - StreakStatus status `db:"streak_status"` - } - - status string -) - -const ( - statusBroken status = "broken" // Streak is broken and must be started anew - statusPending status = "pending" // Streak is pending to be broken and can be continued - statusActive status = "active" // Streak is active (renewed in current stream) -)