Switch to properly tested database interface

and with that support all databases the bot does support

Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
Knut Ahlers 2024-03-24 13:29:57 +01:00
parent f14be1e73f
commit f1d35ce7c0
Signed by: luzifer
SSH Key Fingerprint: SHA256:/xtE5lCgiRDQr8SLxHMS92ZBlACmATUmF1crK16Ks4E
13 changed files with 540 additions and 231 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
twitch-bot-streak
twitch-bot-streak_linux_amd64.tgz

View File

@ -7,3 +7,4 @@ build:
-mod=readonly \
-modcacherw \
-trimpath
tar -czf twitch-bot-streak_linux_amd64.tgz twitch-bot-streak

View File

@ -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

33
go.mod
View File

@ -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
)

93
go.sum
View File

@ -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=

35
main.go
View File

@ -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")
}

70
pkg/database/db.go Normal file
View File

@ -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
}

138
pkg/database/query.go Normal file
View File

@ -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
}

168
pkg/database/query_test.go Normal file
View File

@ -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)
}

25
pkg/database/struct.go Normal file
View File

@ -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)
)

163
query.go
View File

@ -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
}

View File

@ -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;

View File

@ -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)
)