diff --git a/.gitignore b/.gitignore index 00e4b74..02ed68c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ tokens/*token +tokens/*logins bin/* !./bin/.gitkeep !./bin/* diff --git a/changelog.md b/changelog.md index 368fc5d..728e083 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,57 @@ # Changelog +## 5.1.0 - 2020-03-18 +### Added +- PetCat twitch chat handler +- DailyEmote twitch chat handler + +## 5.0.1 - 2020-03-08 +### Changed +- Updated `go-twitch-irc` lib to v2.2.2 + + +## 5.0.0 - 2020-01-11 +### Added +- Twitch point song request feature with dedicated web server and twitch +chat handler +- Mentions of `!galched` command in bot messages +- Chiki-briki song for discord bot + +### Fixed +- Typos in the bot messages + +### Changed +- Updated `go-twitch-irc` lib to v2.2.1 + +## 4.2.0 - 2019-10-20 +### Added +- Universal song handler with polka and sax commands + +### Changed +- Updated discordgo library up to v0.20.1 + +## 4.1.0 - 2019-07-28 +### Added +- Song player handler + +## 4.0.2 - 2019-07-28 +### Changed +- Info about 7th subday + +## 4.0.1 - 2019-07-22 +### Changed +- Twitch chat library version from v1 to v2 + +## 4.0.0 - 2019-06-23 +### Added +- Twitch chat module +- Twitch chat handler that calculates duplicates in chat + +## 3.0.2 - 2019-06-03 +### Added +- Command !subhistory +- Info about 5th and 6th subday + ## 3.0.1 - 2019-05-10 ### Added - Readme file diff --git a/go.mod b/go.mod index 2008330..154df17 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,19 @@ module galched-bot require ( - github.com/bwmarrin/discordgo v0.19.0 + github.com/bwmarrin/discordgo v0.20.1 + github.com/gempir/go-twitch-irc/v2 v2.2.2 + github.com/gorilla/websocket v1.4.1 // indirect github.com/pkg/errors v0.8.1 github.com/stretchr/testify v1.3.0 // indirect - go.uber.org/atomic v1.3.2 // indirect + go.uber.org/atomic v1.3.2 go.uber.org/dig v1.7.0 // indirect go.uber.org/fx v1.9.0 go.uber.org/goleak v0.10.0 // indirect go.uber.org/multierr v1.1.0 // indirect + golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 // indirect + golang.org/x/sys v0.0.0-20191018095205-727590c5006e // indirect + google.golang.org/api v0.15.0 ) + +go 1.13 diff --git a/go.sum b/go.sum index 31b3e85..bf28298 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,39 @@ -github.com/bwmarrin/discordgo v0.19.0 h1:kMED/DB0NR1QhRcalb85w0Cu3Ep2OrGAqZH1R5awQiY= -github.com/bwmarrin/discordgo v0.19.0/go.mod h1:O9S4p+ofTFwB02em7jkpkV8M3R0/PUVOwN61zSZ0r4Q= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0 h1:ROfEUZz+Gh5pa62DJWXSaonyu3StP6EA6lPEXPI6mCo= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/bwmarrin/discordgo v0.20.1 h1:Ihh3/mVoRwy3otmaoPDUioILBJq4fdWkpsi83oj2Lmk= +github.com/bwmarrin/discordgo v0.20.1/go.mod h1:O9S4p+ofTFwB02em7jkpkV8M3R0/PUVOwN61zSZ0r4Q= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gempir/go-twitch-irc/v2 v2.2.1 h1:jMiEgw6zzrgiz4viG7lgj148J6enLls5aicF+zsi1bk= +github.com/gempir/go-twitch-irc/v2 v2.2.1/go.mod h1:0HXoEr9l7gNjwajosptV0w0xGpHeU6gsD7JDlfvjTYI= +github.com/gempir/go-twitch-irc/v2 v2.2.2 h1:uzinel2qApXL1UVfr3QcZ3dJsf+YU+PaUp0qJk03qNo= +github.com/gempir/go-twitch-irc/v2 v2.2.2/go.mod h1:0HXoEr9l7gNjwajosptV0w0xGpHeU6gsD7JDlfvjTYI= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -11,6 +41,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +go.opencensus.io v0.21.0 h1:mU6zScU4U1YAFPHEHYk+3JC4SY7JxgkqS10ZOSyksNg= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.uber.org/atomic v1.3.2 h1:2Oa65PReHzfn29GpvgsYwloV9AVFHPDk8tYxt2c2tr4= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/dig v1.7.0 h1:E5/L92iQTNJTjfgJF2KgU+/JpMaiuvK2DHLBj0+kSZk= @@ -23,3 +55,64 @@ go.uber.org/multierr v1.1.0 h1:HoEmRHQPVSqub6w2z2d2EOVs2fjyFRGyofhKuyDq0QI= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16 h1:y6ce7gCWtnH+m3dCjzQ1PCuwl28DDIc3VNnvY29DlIA= golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c h1:uOCk1iQW6Vc18bnC13MfzScl+wdKBmM9Y9kU7Z83/lw= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191018095205-727590c5006e h1:ZtoklVMHQy6BFRHkbG6JzK+S6rX82//Yeok1vMlizfQ= +golang.org/x/sys v0.0.0-20191018095205-727590c5006e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.15.0 h1:yzlyyDW/J0w8yNFJIhiAJy4kq74S+1DOLdawELNxFMA= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873 h1:nfPFGzJkUDX6uBmpN/pSw7MbOAWegH5QDQuoXFHedLg= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1 h1:Hz2g2wirWK7H0qIIhGIqRGTuMwTE8HEKFnDZZ7lm9NU= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/main.go b/main.go index 5b7b8e0..81705ed 100644 --- a/main.go +++ b/main.go @@ -4,12 +4,16 @@ import ( "context" "log" + "go.uber.org/fx" + "galched-bot/modules/discord" "galched-bot/modules/grace" + "galched-bot/modules/patpet" "galched-bot/modules/settings" "galched-bot/modules/subday" - - "go.uber.org/fx" + "galched-bot/modules/twitchat" + "galched-bot/modules/web" + "galched-bot/modules/youtube" ) type ( @@ -21,6 +25,8 @@ type ( Context context.Context Discord *discord.Discord Settings *settings.Settings + Chat *twitchat.TwitchIRC + Server *web.WebServer } ) @@ -35,14 +41,39 @@ func start(p appParam) error { if err != nil { log.Print("discord: cannot start instance", err) return err - } log.Printf("main: discord instance running") - log.Printf("main: — — —") + err = p.Chat.Start() + if err != nil { + log.Print("chat: cannot start instance", err) + return err + } + log.Printf("main: twitch chat instance running") + + err = p.Server.Start() + if err != nil { + log.Print("web: cannot start instance", err) + return err + } + log.Printf("main: web server instance running") + + log.Printf("main: — — —") <-p.Context.Done() log.Print("main: stopping galched-bot") + err = p.Server.Stop(p.Context) + if err != nil { + log.Print("web: cannot stop instance", err) + return err + } + + err = p.Chat.Stop() + if err != nil { + log.Print("chat: cannot stop instance", err) + return err + } + err = p.Discord.Stop() if err != nil { log.Print("discord: cannot stop instance", err) @@ -57,7 +88,8 @@ func main() { var err error app := fx.New( fx.Logger(new(silentPrinter)), - fx.Provide(settings.New, grace.New, discord.New, subday.New), + fx.Provide(settings.New, grace.New, discord.New, subday.New, + twitchat.New, web.New, youtube.New, patpet.New), fx.Invoke(start)) err = app.Start(context.Background()) diff --git a/modules/discord/discord.go b/modules/discord/discord.go index 37a40e0..62c266e 100644 --- a/modules/discord/discord.go +++ b/modules/discord/discord.go @@ -31,6 +31,10 @@ func New(s *settings.Settings, subday *subday.Subday) (*Discord, error) { processor.AddHandler(subdayHandler) } + for _, songHandler := range SongHandlers(s) { + processor.AddHandler(songHandler) + } + log.Printf("discord: added %d message handlers", len(processor.handlers)) if len(processor.handlers) > 0 { for i := range processor.handlers { diff --git a/modules/discord/handlers.go b/modules/discord/handlers.go index 37b2dde..05a241c 100644 --- a/modules/discord/handlers.go +++ b/modules/discord/handlers.go @@ -53,6 +53,7 @@ func (h *HandlerProcessor) Process(s *discordgo.Session, m *discordgo.MessageCre if strings.HasPrefix(m.Content, "!galched") { LogMessage(m) SendMessage(s, m, h.HelpMessage()) + return } for i := range h.handlers { diff --git a/modules/discord/songhandlers.go b/modules/discord/songhandlers.go new file mode 100644 index 0000000..cc76886 --- /dev/null +++ b/modules/discord/songhandlers.go @@ -0,0 +1,191 @@ +package discord + +import ( + "encoding/binary" + "io" + "log" + "os" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/pkg/errors" + "go.uber.org/atomic" + + "galched-bot/modules/settings" +) + +type ( + songData [][]byte + + SongHandler struct { + globalLock *atomic.Bool + songLock *atomic.Bool + + song songData + signature string + description string + voiceChannel string + permissions []string + timeout time.Duration + } +) + +func (h *SongHandler) Signature() string { + return h.signature +} + +func (h *SongHandler) Description() string { + return h.description +} + +func (h *SongHandler) IsValid(msg string) bool { + return msg == h.signature +} + +func (h *SongHandler) Handle(s *discordgo.Session, m *discordgo.MessageCreate) { + var permitted bool + for i := range h.permissions { + if m.Author.Username == h.permissions[i] { + permitted = true + break + } + } + if len(h.permissions) > 0 && !permitted { + log.Printf("discord: unathorized %s message from %s", + h.signature, m.Author.Username) + return + } + + // Find the channel that the message came from. + c, err := s.State.Channel(m.ChannelID) + if err != nil { + // Could not find channel. + return + } + + // Find the guild for that channel. + g, err := s.State.Guild(c.GuildID) + if err != nil { + // Could not find guild. + return + } + + // Look for the message sender in that guild's current voice states. + LogMessage(m) + if h.globalLock.CAS(false, true) { + if h.songLock.CAS(false, true) { + + err = playSound(s, g.ID, h.voiceChannel, h.song) + if err != nil { + log.Println("discord: error playing sound:", err) + } + + h.globalLock.Store(false) + time.Sleep(h.timeout) + defer h.songLock.Store(false) + + return + } + h.globalLock.Store(false) + } +} + +func SongHandlers(s *settings.Settings) []MessageHandler { + result := make([]MessageHandler, 0, len(s.Songs)) + g := new(atomic.Bool) + + for i := range s.Songs { + song, err := loadSong(s.Songs[i].Path) + if err != nil { + log.Println("discord: error loading song file", err) + continue + } + handler := &SongHandler{ + globalLock: g, + songLock: new(atomic.Bool), + song: song, + signature: s.Songs[i].Signature, + description: s.Songs[i].Description, + voiceChannel: s.DiscordVoiceChannel, + permissions: s.Songs[i].Permissions, + timeout: s.Songs[i].Timeout, + } + result = append(result, handler) + } + + return result +} + +// playSound plays the current buffer to the provided channel. +func playSound(s *discordgo.Session, guildID, channelID string, song songData) error { + + // Join the provided voice channel. + vc, err := s.ChannelVoiceJoin(guildID, channelID, false, true) + if err != nil { + return err + } + + // Sleep for a specified amount of time before playing the sound + time.Sleep(250 * time.Millisecond) + + // Start speaking. + vc.Speaking(true) + + // Send the buffer data. + for _, buff := range song { + vc.OpusSend <- buff + } + + // Stop speaking + vc.Speaking(false) + + // Sleep for a specified amount of time before ending. + time.Sleep(250 * time.Millisecond) + + // Disconnect from the provided voice channel. + vc.Disconnect() + + return nil +} + +func loadSong(path string) (songData, error) { + file, err := os.Open(path) + if err != nil { + return nil, errors.Wrap(err, "error opening dca file") + } + + var ( + opuslen int16 + buffer = make(songData, 0) + ) + + for { + // Read opus frame length from dca file. + err = binary.Read(file, binary.LittleEndian, &opuslen) + + // If this is the end of the file, just return. + if err == io.EOF || err == io.ErrUnexpectedEOF { + err := file.Close() + if err != nil { + return nil, err + } + return buffer, nil + } + + if err != nil { + return nil, errors.Wrap(err, "error reading from dca file") + } + + // Read encoded pcm from dca file. + InBuf := make([]byte, opuslen) + err = binary.Read(file, binary.LittleEndian, &InBuf) + + // Should not be any end of file errors + if err != nil { + return nil, errors.Wrap(err, "error reading from dca file") + } + + // Append encoded pcm data to the buffer. + buffer = append(buffer, InBuf) + } +} diff --git a/modules/discord/subdayhandlers.go b/modules/discord/subdayhandlers.go index 91329da..70c5821 100644 --- a/modules/discord/subdayhandlers.go +++ b/modules/discord/subdayhandlers.go @@ -38,10 +38,7 @@ func (h *SubdayListHandler) Handle(s *discordgo.Session, m *discordgo.MessageCre log.Print("discord: cannot obtain guild", err) return } - message := "Игры предыдущих сабдеев:\n**20.10.18**: _DmC_ -> _Fable 1_ -> _Overcooked 2_\n" + - "**17.11.18**: _The Witcher_ -> _Xenus: Белое Золото_ -> _NFS: Underground 2_\n" + - "**22.12.18**: _True Crime: Streets of LA_ -> _Serious Sam 3_ -> _Kholat_\n" + - "**26.01.19**: _Disney’s Aladdin_ -> _~~Gothic~~_ -> _Scrapland_ -> _Donut County_\n\n" + + message := "Игры предыдущих сабдеев доступны по команде **!subhistory**\n" + "Список игр для следующего сабдея:\n" for k, v := range h.subday.Database() { nickname := " " @@ -58,6 +55,7 @@ func (h *SubdayListHandler) Handle(s *discordgo.Session, m *discordgo.MessageCre message += fmt.Sprintf(" **- %s** от _%s_\n", game, nickname) } } + message += "\nВсе команды бота: !galched\n" SendMessage(s, m, strings.Trim(message, "\n")) } @@ -138,10 +136,35 @@ loop: } } +type SubdayHistoryHandler struct{} + +func (h *SubdayHistoryHandler) Signature() string { + return "!subhistory" +} +func (h *SubdayHistoryHandler) Description() string { + return "история прошлых сабдеев" +} +func (h *SubdayHistoryHandler) IsValid(msg string) bool { + return strings.HasPrefix(msg, "!subhistory") +} +func (h *SubdayHistoryHandler) Handle(s *discordgo.Session, m *discordgo.MessageCreate) { + LogMessage(m) + message := "Игры предыдущих сабдеев:\n**20.10.18**: _DmC_ -> _Fable 1_ -> _Overcooked 2_\n" + + "**17.11.18**: _The Witcher_ -> _Xenus: Белое Золото_ -> _NFS: Underground 2_\n" + + "**22.12.18**: _True Crime: Streets of LA_ -> _Serious Sam 3_ -> _Kholat_\n" + + "**26.01.19**: _Disney’s Aladdin_ -> _~~Gothic~~_ -> _Scrapland_ -> _Donut County_\n" + + "**24.02.19**: _Tetris 99_ -> _~~Bully~~_ -> _~~GTA: Vice City~~_\n" + + "**02.06.19**: _Spec Ops: The Line_ -> _Escape from Tarkov_\n" + + "**28.07.19**: _Crypt of the Necrodancer_ -> _My Friend Pedro_ -> _Ape Out_\n" + + "\nВсе команды бота: !galched\n" + SendMessage(s, m, message) +} + func SubdayHandlers(s *subday.Subday, r []string) []MessageHandler { var result []MessageHandler addHandler := &SubdayAddHandler{s, r} listHandler := &SubdayListHandler{s} - return append(result, addHandler, listHandler) + histHandler := new(SubdayHistoryHandler) + return append(result, addHandler, listHandler, histHandler) } diff --git a/modules/patpet/patpet.go b/modules/patpet/patpet.go new file mode 100644 index 0000000..35a9592 --- /dev/null +++ b/modules/patpet/patpet.go @@ -0,0 +1,88 @@ +package patpet + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "os" + "sync" + + "galched-bot/modules/settings" +) + +type ( + Pet struct { + sync.RWMutex + path string + counter int + } +) + +func New(s *settings.Settings) (*Pet, error) { + var ( + err error + counter int + ) + + petData, err := ioutil.ReadFile(s.PetDataPath) + if err != nil { + log.Print("pet: cannot read data file", err) + log.Print("pet: creating new counter") + } else { + err = json.Unmarshal(petData, &counter) + if err != nil { + counter = 0 + log.Print("pet: cannot unmarshal data file", err) + log.Print("pet: creating new counter") + } else { + log.Print("pet: using previously saved counter") + } + } + + return &Pet{ + RWMutex: sync.RWMutex{}, + path: s.PetDataPath, + counter: counter, + }, nil +} + +func (p *Pet) Pet() int { + p.Lock() + defer p.Unlock() + + p.counter++ + return p.counter +} + +func (p *Pet) Counter() int { + p.RUnlock() + defer p.RUnlock() + + return p.counter +} + +func (p *Pet) Dump() { + p.RLock() + defer p.RUnlock() + + data, err := json.Marshal(p.counter) + if err != nil { + log.Print("pet: cannot marshal counter", err) + return + } + file, err := os.Create(p.path) + if err != nil { + log.Print("pet: cannot open counter file", err) + return + } + _, err = fmt.Fprintf(file, string(data)) + if err != nil { + log.Print("pet: cannot write to counter file") + } + err = file.Close() + if err != nil { + log.Print("pet: cannot close counter file") + } + log.Print("pet: counter dumped to file:", p.counter) +} diff --git a/modules/settings/settings.go b/modules/settings/settings.go index 6a3c9af..39d5152 100644 --- a/modules/settings/settings.go +++ b/modules/settings/settings.go @@ -1,28 +1,56 @@ package settings import ( + "encoding/json" "io/ioutil" "log" + "time" ) const ( - version = "3.0.1" + version = "5.1.0" + twitchUser = "galchedbot" + twitchIRCRoom = "galched" discordTokenPath = "./tokens/.discordtoken" + twitchTokenPath = "./tokens/.twitchtoken" subdayDataPath = "./backups/subday" + petDataPath = "./backups/pets" + youtubeTokenPath = "./tokens/.youtubetoken" + webLoginsPath = "./tokens/.weblogins" // Permitted roles in discord for subday subRole1 = "433672344737677322" subRole2 = "433680494635515904" galchedRole = "301467455497175041" smorcRole = "301470784491356172" + + defaultQueueAddr = ":8888" ) type ( + SongInfo struct { + Path string + Signature string + Description string + Permissions []string + Timeout time.Duration + } + Settings struct { - Version string - DiscordToken string - SubdayDataPath string - PermittedRoles []string + Version string + DiscordToken string + TwitchUser string + TwitchIRCRoom string + TwitchToken string + YoutubeToken string + SubdayDataPath string + PetDataPath string + PermittedRoles []string + DiscordVoiceChannel string + Songs []SongInfo + + QueueAddress string + LoginUsers map[string]string } ) @@ -31,11 +59,59 @@ func New() (*Settings, error) { if err != nil { log.Print("settings: cannot read discord token file", err) } + twitchToken, err := ioutil.ReadFile(twitchTokenPath) + if err != nil { + log.Print("settings: cannot read twitch token file", err) + } + youtubetoken, err := ioutil.ReadFile(youtubeTokenPath) + if err != nil { + log.Print("settings: cannot read twitch token file", err) + } + + webLogins := make(map[string]string) + webLoginsRaw, err := ioutil.ReadFile(webLoginsPath) + if err != nil { + log.Print("settings: cannot read web login file", err) + } else { + err = json.Unmarshal(webLoginsRaw, &webLogins) + if err != nil { + log.Print("settings: cannot parse web login file", err) + } + } return &Settings{ - Version: version, - DiscordToken: string(discordToken), - SubdayDataPath: subdayDataPath, - PermittedRoles: []string{subRole1, subRole2, galchedRole, smorcRole}, + Version: version, + DiscordToken: string(discordToken), + TwitchToken: string(twitchToken), + YoutubeToken: string(youtubetoken), + TwitchUser: twitchUser, + TwitchIRCRoom: twitchIRCRoom, + SubdayDataPath: subdayDataPath, + PetDataPath: petDataPath, + DiscordVoiceChannel: "301793085522706432", + PermittedRoles: []string{subRole1, subRole2, galchedRole, smorcRole}, + Songs: []SongInfo{ + { + Path: "songs/polka.dca", + Signature: "!song", + Description: "сыграть гимн галчед (только для избранных)", + Permissions: []string{"AlexV", "Rummy_Quamox", "Lidiya_owl"}, + Timeout: 10 * time.Second, + }, + { + Path: "songs/whisper.dca", + Signature: "!sax", + Description: "kreygasm", + Timeout: 20 * time.Second, + }, + { + Path: "songs/st.dca", + Signature: "!chiki", + Description: "briki v damki", + Timeout: 20 * time.Second, + }, + }, + QueueAddress: defaultQueueAddr, + LoginUsers: webLogins, }, nil } diff --git a/modules/twitchat/dailyemote.go b/modules/twitchat/dailyemote.go new file mode 100644 index 0000000..810ecec --- /dev/null +++ b/modules/twitchat/dailyemote.go @@ -0,0 +1,257 @@ +package twitchat + +import ( + "fmt" + "hash/fnv" + "math/rand" + "time" + + "github.com/gempir/go-twitch-irc/v2" +) + +const emoteMsg = "!emote" + +var ( + emotes = []string{ + "4Head", + "ANELE", + "ArgieB8", + "ArsonNoSexy", + "AsexualPride", + "AsianGlow", + "BCWarrior", + "BOP", + "BabyRage", + "BatChest", + "BegWan", + "BibleThump", + "BigBrother", + "BigPhish", + "BisexualPride", + "BlargNaut", + "BlessRNG", + "BloodTrail", + "BrainSlug", + "BrokeBack", + "BuddhaBar", + "CarlSmile", + "ChefFrank", + "CoolCat", + "CoolStoryBob", + "CorgiDerp", + "CrreamAwk", + "CurseLit", + "DAESuppy", + "DBstyle", + "DansGame", + "DarkMode", + "DatSheffy", + "DendiFace", + "DogFace", + "DoritosChip", + "DrinkPurple", + "DxCat", + "EarthDay", + "EleGiggle", + "EntropyWins", + "FBBlock", + "FBCatch", + "FBChallenge", + "FBPass", + "FBPenalty", + "FBRun", + "FBSpiral", + "FBtouchdown", + "FUNgineer", + "FailFish", + "FrankerZ", + "FreakinStinkin", + "FutureMan", + "GayPride", + "GenderFluidPride", + "GingerPower", + "GivePLZ", + "GrammarKing", + "GreenTeam", + "GunRun", + "HSCheers", + "HSWP", + "HassaanChop", + "HassanChop", + "HeyGuys", + "HolidayCookie", + "HolidayLog", + "HolidayOrnament", + "HolidayPresent", + "HolidaySanta", + "HolidayTree", + "HotPokket", + "HumbleLife", + "IntersexPride", + "InuyoFace", + "ItsBoshyTime", + "JKanStyle", + "Jebaited", + "JonCarnage", + "KAPOW", + "Kappa", + "KappaClaus", + "KappaPride", + "KappaRoss", + "KappaWealth", + "Kappu", + "Keepo", + "KevinTurtle", + "Kippa", + "KomodoHype", + "KonCha", + "Kreygasm", + "LUL", + "LesbianPride", + "MVGame", + "Mau5", + "MaxLOL", + "MercyWing1", + "MercyWing2", + "MikeHogu", + "MingLee", + "MorphinTime", + "MrDestructoid", + "NinjaGrumpy", + "NomNom", + "NonBinaryPride", + "NotATK", + "NotLikeThis", + "OSFrog", + "OhMyDog", + "OneHand", + "OpieOP", + "OptimizePrime", + "PJSalt", + "PJSugar", + "PMSTwin", + "PRChase", + "PanicVis", + "PansexualPride", + "PartyHat", + "PartyTime", + "PeoplesChamp", + "PermaSmug", + "PicoMause", + "PinkMercy", + "PipeHype", + "PixelBob", + "PogChamp", + "Poooound", + "PopCorn", + "PorscheWIN", + "PowerUpL", + "PowerUpR", + "PraiseIt", + "PrimeMe", + "PunOko", + "PunchTrees", + "PurpleStar", + "RaccAttack", + "RalpherZ", + "RedCoat", + "RedTeam", + "ResidentSleeper", + "RitzMitz", + "RlyTho", + "RuleFive", + "SMOrc", + "SSSsss", + "SabaPing", + "SeemsGood", + "SeriousSloth", + "ShadyLulu", + "ShazBotstix", + "SingsMic", + "SingsNote", + "SmoocherZ", + "SoBayed", + "SoonerLater", + "Squid1", + "Squid2", + "Squid3", + "Squid4", + "StinkyCheese", + "StoneLightning", + "StrawBeary", + "SuperVinlin", + "SwiftRage", + "TBAngel", + "TF2John", + "TPFufun", + "TPcrunchyroll", + "TTours", + "TakeNRG", + "TearGlove", + "TehePelo", + "ThankEgg", + "TheIlluminati", + "TheRinger", + "TheTarFu", + "TheThing", + "ThunBeast", + "TinyFace", + "TombRaid", + "TooSpicy", + "TransgenderPride", + "TriHard", + "TwitchLit", + "TwitchRPG", + "TwitchSings", + "TwitchUnity", + "TwitchVotes", + "UWot", + "UnSane", + "UncleNox", + "VoHiYo", + "VoteNay", + "VoteYea", + "WTRuck", + "WholeWheat", + "WutFace", + "YouDontSay", + "YouWHY", + "bleedPurple", + "cmonBruh", + "copyThis", + "duDudu", + "imGlitch", + "mcaT", + "panicBasket", + "pastaThat", + "riPepperonis", + "twitchRaid", + } +) + +type ( + dailyEmote struct{} +) + +func DailyEmote() *dailyEmote { + return new(dailyEmote) +} + +func (h *dailyEmote) IsValid(m *twitch.PrivateMessage) bool { + return (m.Tags["msg-id"] == "highlighted-message") && m.Message == emoteMsg +} + +func (h *dailyEmote) Handle(m *twitch.PrivateMessage, r Responser) { + data := time.Now().Format("2006-01-02") + m.User.DisplayName + rng := rand.New(rand.NewSource(hashSeed(data))) + emote := emotes[rng.Intn(len(emotes))] + + msg := fmt.Sprintf("@%s твой эмоут дня: %s", m.User.DisplayName, emote) + r.Say(m.Channel, msg) +} + +func hashSeed(s string) int64 { + h := fnv.New64() + h.Write([]byte(s)) + return int64(h.Sum64()) +} diff --git a/modules/twitchat/duphandler.go b/modules/twitchat/duphandler.go new file mode 100644 index 0000000..699964a --- /dev/null +++ b/modules/twitchat/duphandler.go @@ -0,0 +1,48 @@ +package twitchat + +import ( + "fmt" + "log" + "strings" + + "github.com/gempir/go-twitch-irc/v2" +) + +type ( + dupHandler struct { + lastMessage string + counter int + dupMinimal int + } +) + +const DupMinimal = 3 + +func DupHandler() PrivateMessageHandler { + return &dupHandler{ + lastMessage: "", + counter: 0, + dupMinimal: DupMinimal, + } +} + +func (h *dupHandler) IsValid(m *twitch.PrivateMessage) bool { + return true +} + +func (h *dupHandler) Handle(m *twitch.PrivateMessage, r Responser) { + data := strings.Fields(m.Message) + for i := range data { + if data[i] == h.lastMessage { + h.counter++ + } else { + if h.counter >= h.dupMinimal { + msg := fmt.Sprintf("%d %s подряд", h.counter, h.lastMessage) + r.Say(m.Channel, msg) + log.Print("chat: ", msg) + } + h.counter = 1 + h.lastMessage = data[i] + } + } +} diff --git a/modules/twitchat/handlers.go b/modules/twitchat/handlers.go new file mode 100644 index 0000000..4e3d327 --- /dev/null +++ b/modules/twitchat/handlers.go @@ -0,0 +1,16 @@ +package twitchat + +import ( + "github.com/gempir/go-twitch-irc/v2" +) + +type ( + Responser interface { + Say(channel, message string) + } + + PrivateMessageHandler interface { + IsValid(m *twitch.PrivateMessage) bool + Handle(m *twitch.PrivateMessage, r Responser) + } +) diff --git a/modules/twitchat/logcheck.go b/modules/twitchat/logcheck.go new file mode 100644 index 0000000..98ef08a --- /dev/null +++ b/modules/twitchat/logcheck.go @@ -0,0 +1,23 @@ +package twitchat + +import ( + "log" + + "github.com/gempir/go-twitch-irc/v2" +) + +type ( + logCheck struct{} +) + +func LogCheck() PrivateMessageHandler { + return new(logCheck) +} + +func (h *logCheck) IsValid(m *twitch.PrivateMessage) bool { + return true +} + +func (h *logCheck) Handle(m *twitch.PrivateMessage, r Responser) { + log.Print("chat <", m.User.DisplayName, "> : ", m.Message) +} diff --git a/modules/twitchat/petcat.go b/modules/twitchat/petcat.go new file mode 100644 index 0000000..e9aeb13 --- /dev/null +++ b/modules/twitchat/petcat.go @@ -0,0 +1,39 @@ +package twitchat + +import ( + "fmt" + "strings" + + "galched-bot/modules/patpet" + "github.com/gempir/go-twitch-irc/v2" +) + +const ( + petMsg1 = "!погладь" + petMsg2 = "!гладь" + petMsg3 = "!погладить" +) + +type ( + petCat struct { + cat *patpet.Pet + } +) + +func PetCat(pet *patpet.Pet) *petCat { + return &petCat{ + cat: pet, + } +} + +func (h *petCat) IsValid(m *twitch.PrivateMessage) bool { + return (m.Tags["msg-id"] == "highlighted-message") && (strings.HasPrefix(m.Message, petMsg1) || + strings.HasPrefix(m.Message, petMsg2) || + strings.HasPrefix(m.Message, petMsg3)) +} + +func (h *petCat) Handle(m *twitch.PrivateMessage, r Responser) { + msg := fmt.Sprintf("Котэ поглажен уже %d раз(а) InuyoFace", h.cat.Pet()) + r.Say(m.Channel, msg) + h.cat.Dump() +} diff --git a/modules/twitchat/songrequest.go b/modules/twitchat/songrequest.go new file mode 100644 index 0000000..f9c5612 --- /dev/null +++ b/modules/twitchat/songrequest.go @@ -0,0 +1,61 @@ +package twitchat + +import ( + "fmt" + "log" + "strings" + + "github.com/gempir/go-twitch-irc/v2" + + "galched-bot/modules/youtube" +) + +const ( + songMsg = "!song" + reqPrefix = "!req " // space in the end is important +) + +type ( + songRequest struct { + r *youtube.Requester + } +) + +func SongRequest(r *youtube.Requester) PrivateMessageHandler { + return &songRequest{r: r} +} + +func (h *songRequest) IsValid(m *twitch.PrivateMessage) bool { + return (strings.HasPrefix(m.Message, reqPrefix) && m.Tags["msg-id"] == "highlighted-message") || + strings.TrimSpace(m.Message) == songMsg + // return strings.HasPrefix(m.Message, reqPrefix) || strings.TrimSpace(m.Message) == songMsg +} + +func (h *songRequest) Handle(m *twitch.PrivateMessage, r Responser) { + if strings.TrimSpace(m.Message) == "!song" { + list := h.r.List() + if len(list) > 0 { + line := fmt.Sprintf("Сейчас играет: <%s>", list[0].Title) + r.Say(m.Channel, line) + } else { + r.Say(m.Channel, "Очередь видео пуста") + } + return + } + + query := strings.TrimPrefix(m.Message, "!req ") + if len(query) == 0 { + return + } + + chatMsg, err := h.r.AddVideo(query, m.User.DisplayName) + if err != nil { + log.Printf("yt: cannot add song from msg <%s>, err: %v", m.Message, err) + if len(chatMsg) > 0 { + r.Say(m.Channel, m.User.DisplayName+" "+chatMsg) + } + return + } + r.Say(m.Channel, m.User.DisplayName+" добавил "+chatMsg) + return +} diff --git a/modules/twitchat/twitchat.go b/modules/twitchat/twitchat.go new file mode 100644 index 0000000..e64063a --- /dev/null +++ b/modules/twitchat/twitchat.go @@ -0,0 +1,58 @@ +package twitchat + +import ( + "galched-bot/modules/patpet" + "galched-bot/modules/settings" + "galched-bot/modules/youtube" + + "github.com/gempir/go-twitch-irc/v2" +) + +type ( + TwitchIRC struct { + username string + chat *twitch.Client + handlers []PrivateMessageHandler + } +) + +func New(s *settings.Settings, r *youtube.Requester, pet *patpet.Pet) (*TwitchIRC, error) { + var irc = new(TwitchIRC) + + irc.username = s.TwitchUser + + irc.handlers = append(irc.handlers, DupHandler()) + irc.handlers = append(irc.handlers, DailyEmote()) + irc.handlers = append(irc.handlers, SongRequest(r)) + irc.handlers = append(irc.handlers, PetCat(pet)) + // irc.handlers = append(irc.handlers, LogCheck()) + + irc.chat = twitch.NewClient(s.TwitchUser, s.TwitchToken) + irc.chat.OnPrivateMessage(irc.PrivateMessageHandler) + irc.chat.Join(s.TwitchIRCRoom) + + return irc, nil +} + +func (c *TwitchIRC) Start() error { + go func() { + err := c.chat.Connect() + _ = err // no point in error because disconnect will be called anyway + }() + return nil +} + +func (c *TwitchIRC) Stop() error { + return c.chat.Disconnect() +} + +func (c *TwitchIRC) PrivateMessageHandler(msg twitch.PrivateMessage) { + if msg.User.Name == c.username { + return + } + for i := range c.handlers { + if c.handlers[i].IsValid(&msg) { + c.handlers[i].Handle(&msg, c.chat) + } + } +} diff --git a/modules/web/server.go b/modules/web/server.go new file mode 100644 index 0000000..b7b3551 --- /dev/null +++ b/modules/web/server.go @@ -0,0 +1,159 @@ +package web + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "io/ioutil" + "log" + "net/http" + "time" + + "galched-bot/modules/settings" + "galched-bot/modules/youtube" +) + +type ( + WebServer struct { + server http.Server + r *youtube.Requester + + users map[string]string + authed map[string]struct{} + } +) + +func New(s *settings.Settings, r *youtube.Requester) *WebServer { + srv := http.Server{ + Addr: s.QueueAddress, + } + + webServer := &WebServer{ + server: srv, + r: r, + + users: s.LoginUsers, + authed: make(map[string]struct{}, 10), + } + + http.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + return + } + + if webServer.IsAuthorized(request) { + http.ServeFile(writer, request, "web/index.html") + } else { + http.Redirect(writer, request, "/login", 301) + } + }) + + http.HandleFunc("/login", func(writer http.ResponseWriter, request *http.Request) { + if request.Method == http.MethodGet { + http.ServeFile(writer, request, "web/login.html") + return + } else if request.Method != http.MethodPost { + return + } + + login := request.FormValue("login") + pwd := request.FormValue("password") + + log.Print("web: trying to log in with user: ", login) + + if webServer.IsRegistered(login, pwd) { + webServer.Authorize(writer) + } else { + log.Print("web: incorrect password attempt") + } + http.Redirect(writer, request, "/", http.StatusSeeOther) + }) + + http.HandleFunc("/scripts.js", func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + return + } + + http.ServeFile(writer, request, "web/scripts.js") + }) + + http.HandleFunc("/style.css", func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + return + } + + http.ServeFile(writer, request, "web/style.css") + }) + + http.HandleFunc("/queue", func(writer http.ResponseWriter, request *http.Request) { + if !webServer.IsAuthorized(request) { + http.Error(writer, "not authorized", http.StatusUnauthorized) + return + } + + switch request.Method { + case http.MethodGet: + case http.MethodPost: + body, err := ioutil.ReadAll(request.Body) + if err != nil { + log.Print("web: cannot read body msg, %v", err) + return + } + id := string(body) + if len(id) != youtube.YoutubeIDLength && len(id) > 0 { + log.Printf("web: incorrect data in body, <%s>", id) + return + } + r.Remove(id) + default: + return + } + writer.Header().Set("Content-Type", "application/json") + writer.Header().Set("Access-Control-Allow-Origin", "*") + json.NewEncoder(writer).Encode(webServer.r.List()) + }) + + return webServer +} + +func (s WebServer) Start() error { + go func() { + s.server.ListenAndServe() + }() + return nil +} + +func (s WebServer) Stop(ctx context.Context) error { + return s.server.Shutdown(ctx) +} + +func (s WebServer) IsAuthorized(request *http.Request) bool { + if cookie, err := request.Cookie("session"); err == nil { + if _, ok := s.authed[cookie.Value]; ok { + return true + } + } + return false +} + +func (s WebServer) IsRegistered(login, pwd string) bool { + return s.users[login] == pwd +} + +func (s WebServer) Authorize(response http.ResponseWriter) { + var byteKey = make([]byte, 16) + rand.Read(byteKey[:]) + stringKey := hex.EncodeToString(byteKey) + + s.authed[stringKey] = struct{}{} + log.Print("web: authenticated new user") + + expires := time.Now().AddDate(0, 1, 0) + http.SetCookie(response, &http.Cookie{ + Name: "session", + Value: stringKey, + Path: "/", + Expires: expires, + }) +} diff --git a/modules/youtube/requester.go b/modules/youtube/requester.go new file mode 100644 index 0000000..e50ed93 --- /dev/null +++ b/modules/youtube/requester.go @@ -0,0 +1,195 @@ +package youtube + +import ( + "context" + "errors" + "fmt" + "log" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "google.golang.org/api/option" + "google.golang.org/api/youtube/v3" + + "galched-bot/modules/settings" +) + +const ( + YoutubeIDLength = 11 + youtubeRegexpID = `^.*((youtu.be\/)|(embed\/)|(watch\?))\??v?=?([^#\&\?\s]*).*` +) + +var ( + urlRegex = regexp.MustCompile(youtubeRegexpID) + durationRegex = regexp.MustCompile(`P(?P\d+Y)?(?P\d+M)?(?P\d+D)?T?(?P\d+H)?(?P\d+M)?(?P\d+S)?`) +) + +type ( + Video struct { + ID string + Title string + From string + Duration string + + Upvotes uint64 + Downvotes uint64 + Views uint64 + } + + Requester struct { + mu *sync.RWMutex + + srv *youtube.Service + requests []Video + } +) + +func New(ctx context.Context, s *settings.Settings) (*Requester, error) { + srv, err := youtube.NewService(ctx, option.WithAPIKey(s.YoutubeToken)) + if err != nil { + return nil, err + } + + return &Requester{ + mu: new(sync.RWMutex), + srv: srv, + }, nil +} + +func (r *Requester) AddVideo(query, from string) (string, error) { + var ( + id string + err error + ) + + // try parse video id from the query + id, err = videoID(query) + if err != nil { + // if we can't fo that, then search for the query + resp, err := r.srv.Search.List("snippet").Type("video").MaxResults(1).Q(query).Do() + if err != nil || len(resp.Items) == 0 || resp.Items[0].Id == nil { + return "", fmt.Errorf("cannot parse youtube id: %w", err) + } + + id = resp.Items[0].Id.VideoId + } + + // get video info from api + resp, err := r.srv.Videos.List("snippet,statistics,contentDetails").Id(id).Do() + if err != nil { + return "", fmt.Errorf("cannot send request to youtube api: %w", err) + } + + // check if response have all required fields + if len(resp.Items) == 0 { + return "", errors.New("youtube api response does not contain items") + } + if resp.Items[0].Snippet == nil { + return "", errors.New("youtube api response does not contain snippet") + } + if resp.Items[0].Statistics == nil { + return "", errors.New("youtube api response does not contain statistics") + } + if resp.Items[0].ContentDetails == nil { + return "", errors.New("youtube api response does not contain content details") + } + + // check length of the video not more than 5 minutes + if parseDuration(resp.Items[0].ContentDetails.Duration) == 0 { + err = errors.New("видео не должно быть трансляцией") + return err.Error(), err + } + + // check video is not live + if parseDuration(resp.Items[0].ContentDetails.Duration) > time.Minute*5 { + err = errors.New("видео должно быть короче 5 минут") + return err.Error(), err + } + + r.mu.Lock() + defer r.mu.Unlock() + + // check if video already in the queue + for i := range r.requests { + if r.requests[i].ID == id { + err = errors.New("видео уже есть в очереди") + return err.Error(), err + } + } + + r.requests = append(r.requests, Video{ + ID: id, + From: from, + Duration: strings.ToLower(resp.Items[0].ContentDetails.Duration[2:]), + Title: resp.Items[0].Snippet.Title, + Upvotes: resp.Items[0].Statistics.LikeCount, + Views: resp.Items[0].Statistics.ViewCount, + Downvotes: resp.Items[0].Statistics.DislikeCount, + }) + log.Printf("yt: added video < %s > from < %s >\n", resp.Items[0].Snippet.Title, from) + + return resp.Items[0].Snippet.Title, nil +} + +func (r *Requester) List() []Video { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]Video, len(r.requests)) + copy(result, r.requests) + + return result +} + +func (r *Requester) Remove(id string) { + r.mu.Lock() + defer r.mu.Unlock() + + for i := range r.requests { + if r.requests[i].ID == id { + r.requests = append(r.requests[:i], r.requests[i+1:]...) + return + } + } +} + +func videoID(url string) (string, error) { + result := urlRegex.FindStringSubmatch(url) + + ln := len(result) + if ln == 0 || len(result[ln-1]) != YoutubeIDLength { + return "", fmt.Errorf("id haven't matched in \"%s\"", url) + } + + return result[ln-1], nil +} + +func parseDuration(str string) time.Duration { + matches := durationRegex.FindStringSubmatch(str) + + years := parseInt64(matches[1]) + months := parseInt64(matches[2]) + days := parseInt64(matches[3]) + hours := parseInt64(matches[4]) + minutes := parseInt64(matches[5]) + seconds := parseInt64(matches[6]) + + hour := int64(time.Hour) + minute := int64(time.Minute) + second := int64(time.Second) + return time.Duration(years*24*365*hour + months*30*24*hour + days*24*hour + hours*hour + minutes*minute + seconds*second) +} + +func parseInt64(value string) int64 { + if len(value) == 0 { + return 0 + } + parsed, err := strconv.Atoi(value[:len(value)-1]) + if err != nil { + return 0 + } + return int64(parsed) +} diff --git a/modules/youtube/requester_test.go b/modules/youtube/requester_test.go new file mode 100644 index 0000000..6e786d5 --- /dev/null +++ b/modules/youtube/requester_test.go @@ -0,0 +1,67 @@ +package youtube + +import ( + "io/ioutil" + "testing" +) + +func TestPlayground(t *testing.T) { + youtubeTokenPath := "../../tokens/.youtubetoken" + youtubetoken, err := ioutil.ReadFile(youtubeTokenPath) + if err != nil { + t.Errorf("cannot read youtube token: %v", err) + } + _ = youtubetoken +} + +func TestFetchID(t *testing.T) { + positiveCases := [][]string{ + { + "https://www.youtube.com/watch?v=_v5IzvVTw7A&feature=feedrec_grec_index", + "_v5IzvVTw7A", + }, + { + "https://www.youtube.com/watch?v=_v5IzvVTw7A#t=0m10s", + "_v5IzvVTw7A", + }, + { + "https://www.youtube.com/embed/_v5IzvVTw7A?rel=0", + "_v5IzvVTw7A", + }, + { + "https://www.youtube.com/watch?v=_v5IzvVTw7A ", // multiple spaces in the end + "_v5IzvVTw7A", + }, + { + "https://youtu.be/_v5IzvVTw7A", + "_v5IzvVTw7A", + }, + { + "https://www.youtube.com/watch?v=PCp2iXA1uLE&list=PLvx4lPhqncyf10ymYz8Ph8EId0cafzhdZ&index=2&t=0s", + "PCp2iXA1uLE", + }, + } + + negativeCases := []string{ + "https://youtu.be/_vIzvVTw7A", // short video + "https://vimeo.com/_v5IzvVTw7A", // incorrect domain + "youtube prime video", + } + + for _, testCase := range positiveCases { + res, err := videoID(testCase[0]) + if err != nil { + t.Errorf("expecting error: , got: <%v>, url: %s\n", err, testCase[0]) + } + if res != testCase[1] { + t.Errorf("expecting result: %s, got: %s\n", testCase[0], res) + } + } + + for _, testCase := range negativeCases { + _, err := videoID(testCase) + if err == nil { + t.Error("expecting error: , got: ") + } + } +} diff --git a/songs/.gitkeep b/songs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..f73dd9a --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + +
+
+
+ + + \ No newline at end of file diff --git a/web/login.html b/web/login.html new file mode 100644 index 0000000..c406fd2 --- /dev/null +++ b/web/login.html @@ -0,0 +1,14 @@ + + + +
+
+

+ +

+ + +
+
+ + diff --git a/web/scripts.js b/web/scripts.js new file mode 100644 index 0000000..17481a0 --- /dev/null +++ b/web/scripts.js @@ -0,0 +1,120 @@ +// queue is used to play videos +var queue +// player is a iframe youtube player +var player +// vid is a current playing video id +var vid = "" + +function updateTable() { + var myTableDiv = document.getElementById("video_queue") + myTableDiv.innerHTML = "" + + var table = document.createElement('TABLE') + var tableBody = document.createElement('TBODY') + table.border = '1' + table.appendChild(tableBody); + + for (i = 0; i < queue.length; i++) { + var tr = document.createElement('TR'); + + var td = document.createElement('TD'); + td.appendChild(document.createTextNode(queue[i].Duration)); + tr.appendChild(td); + + var td = document.createElement('TD'); + td.appendChild(document.createTextNode(queue[i].Title)); + tr.appendChild(td) + + var td = document.createElement('TD'); + td.appendChild(document.createTextNode( queue[i].From)); + tr.appendChild(td) + + var td = document.createElement('TD'); + td.appendChild(document.createTextNode( queue[i].Views)); + tr.appendChild(td) + + var td = document.createElement('TD'); + td.appendChild(document.createTextNode(((queue[i].Upvotes/(queue[i].Upvotes + queue[i].Downvotes)) * 100).toFixed(2) + '%')); + tr.appendChild(td) + + tableBody.appendChild(tr); + } + + myTableDiv.appendChild(table) +} + +function bgUpdateQueue() { + initQueue() + updateTable() + + if (vid === "" && queue.length > 0) { + vid = queue[0].ID + player.loadVideoById(vid) + } +} + +function initQueue() { + var request = new XMLHttpRequest() + request.open('GET', './queue', false) + request.onload = function () { + queue = JSON.parse(this.response) + } + request.send() + updateTable() +} + +function updateQueue() { + var request = new XMLHttpRequest() + request.open('POST', './queue', false) + request.onload = function () { + queue = JSON.parse(this.response) + } + request.send(vid) + updateTable() +} + +function nextVideo() { + updateQueue() + if (queue.length > 0) { + vid = queue[0].ID + } else { + vid = "" + } + + player.loadVideoById(vid) + updateTable() +} + +function onYouTubeIframeAPIReady() { + if (queue.length != 0) { + vid = queue[0].ID + } + console.log(vid) + player = new YT.Player('player', { + height: '390', + width: '640', + videoId: vid, + events: { + 'onReady': onPlayerReady, + 'onStateChange': onPlayerStateChange + } + }); + +} + +function onPlayerReady(event) { + event.target.playVideo(); +} + +function onPlayerStateChange(event) { + if (event.data === 0) { + nextVideo() + } +} + +initQueue() +setInterval(bgUpdateQueue, 4000); +var tag = document.createElement('script'); +tag.src = "https://www.youtube.com/iframe_api"; +var firstScriptTag = document.getElementsByTagName('script')[0]; +firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..c771414 --- /dev/null +++ b/web/style.css @@ -0,0 +1,4 @@ +body { + background-color: #000000; + color: #ffffff; +} \ No newline at end of file