From f0f31a8415220eb568a9f276ba3d5f75f49bf179 Mon Sep 17 00:00:00 2001 From: alexvanin Date: Sat, 11 Jan 2020 22:38:51 +0300 Subject: [PATCH] Add twitch point song requests to the bot This commits adds new feature for galchedbot: video requests in the twitch chat via highlighted chat messages. This messages parsed by the bot and added to the video queue, that can be accessed by the dedicated web server. Video queue requires authorization based on random token added to the cookies. --- changelog.md | 13 ++ go.mod | 3 +- go.sum | 81 ++++++++++++- main.go | 22 +++- modules/discord/subdayhandlers.go | 4 +- modules/settings/settings.go | 37 +++++- modules/twitchat/duphandler.go | 2 +- modules/twitchat/handlers.go | 2 +- modules/twitchat/logcheck.go | 23 ++++ modules/twitchat/songrequest.go | 61 ++++++++++ modules/twitchat/twitchat.go | 7 +- modules/web/server.go | 159 ++++++++++++++++++++++++ modules/youtube/requester.go | 195 ++++++++++++++++++++++++++++++ modules/youtube/requester_test.go | 67 ++++++++++ web/index.html | 12 ++ web/login.html | 14 +++ web/scripts.js | 120 ++++++++++++++++++ web/style.css | 4 + 18 files changed, 813 insertions(+), 13 deletions(-) create mode 100644 modules/twitchat/logcheck.go create mode 100644 modules/twitchat/songrequest.go create mode 100644 modules/web/server.go create mode 100644 modules/youtube/requester.go create mode 100644 modules/youtube/requester_test.go create mode 100644 web/index.html create mode 100644 web/login.html create mode 100644 web/scripts.js create mode 100644 web/style.css diff --git a/changelog.md b/changelog.md index a9a9260..b781298 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,18 @@ # Changelog +## 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 diff --git a/go.mod b/go.mod index 7449d4d..7482188 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module galched-bot require ( github.com/bwmarrin/discordgo v0.20.1 - github.com/gempir/go-twitch-irc/v2 v2.2.0 + github.com/gempir/go-twitch-irc/v2 v2.2.1 github.com/gorilla/websocket v1.4.1 // indirect github.com/pkg/errors v0.8.1 github.com/stretchr/testify v1.3.0 // indirect @@ -13,6 +13,7 @@ require ( 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 5faca35..a906d49 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,37 @@ +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.0 h1:9iYRr/PkT5tqnD9J0awBXtwS4R4DatA5cMQbsua6OvM= -github.com/gempir/go-twitch-irc/v2 v2.2.0/go.mod h1:0HXoEr9l7gNjwajosptV0w0xGpHeU6gsD7JDlfvjTYI= +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/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= @@ -15,6 +39,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= @@ -30,10 +56,61 @@ golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnf 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 2107535..df7009d 100644 --- a/main.go +++ b/main.go @@ -4,13 +4,15 @@ import ( "context" "log" + "go.uber.org/fx" + "galched-bot/modules/discord" "galched-bot/modules/grace" "galched-bot/modules/settings" "galched-bot/modules/subday" "galched-bot/modules/twitchat" - - "go.uber.org/fx" + "galched-bot/modules/web" + "galched-bot/modules/youtube" ) type ( @@ -23,6 +25,7 @@ type ( Discord *discord.Discord Settings *settings.Settings Chat *twitchat.TwitchIRC + Server *web.WebServer } ) @@ -47,10 +50,23 @@ func start(p appParam) error { } 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) @@ -71,7 +87,7 @@ func main() { var err error app := fx.New( fx.Logger(new(silentPrinter)), - fx.Provide(settings.New, grace.New, discord.New, subday.New, twitchat.New), + fx.Provide(settings.New, grace.New, discord.New, subday.New, twitchat.New, web.New, youtube.New), fx.Invoke(start)) err = app.Start(context.Background()) diff --git a/modules/discord/subdayhandlers.go b/modules/discord/subdayhandlers.go index 477cece..70c5821 100644 --- a/modules/discord/subdayhandlers.go +++ b/modules/discord/subdayhandlers.go @@ -55,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")) } @@ -154,7 +155,8 @@ func (h *SubdayHistoryHandler) Handle(s *discordgo.Session, m *discordgo.Message "**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" + "**28.07.19**: _Crypt of the Necrodancer_ -> _My Friend Pedro_ -> _Ape Out_\n" + + "\nВсе команды бота: !galched\n" SendMessage(s, m, message) } diff --git a/modules/settings/settings.go b/modules/settings/settings.go index f174a2d..b38af64 100644 --- a/modules/settings/settings.go +++ b/modules/settings/settings.go @@ -1,24 +1,29 @@ package settings import ( + "encoding/json" "io/ioutil" "log" "time" ) const ( - version = "4.2.0" + version = "5.0.0" twitchUser = "galchedbot" twitchIRCRoom = "galched" discordTokenPath = "./tokens/.discordtoken" twitchTokenPath = "./tokens/.twitchtoken" subdayDataPath = "./backups/subday" + youtubeTokenPath = "./tokens/.youtubetoken" + webLoginsPath = "./tokens/.weblogins" // Permitted roles in discord for subday subRole1 = "433672344737677322" subRole2 = "433680494635515904" galchedRole = "301467455497175041" smorcRole = "301470784491356172" + + defaultQueueAddr = ":8888" ) type ( @@ -36,10 +41,14 @@ type ( TwitchUser string TwitchIRCRoom string TwitchToken string + YoutubeToken string SubdayDataPath string PermittedRoles []string DiscordVoiceChannel string Songs []SongInfo + + QueueAddress string + LoginUsers map[string]string } ) @@ -52,11 +61,27 @@ func New() (*Settings, error) { 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), TwitchToken: string(twitchToken), + YoutubeToken: string(youtubetoken), TwitchUser: twitchUser, TwitchIRCRoom: twitchIRCRoom, SubdayDataPath: subdayDataPath, @@ -66,7 +91,7 @@ func New() (*Settings, error) { { Path: "songs/polka.dca", Signature: "!song", - Description: "сыграть гимн галчед (только для избранных", + Description: "сыграть гимн галчед (только для избранных)", Permissions: []string{"AlexV", "Rummy_Quamox", "Lidiya_owl"}, Timeout: 10 * time.Second, }, @@ -76,6 +101,14 @@ func New() (*Settings, error) { 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/duphandler.go b/modules/twitchat/duphandler.go index b9a1433..699964a 100644 --- a/modules/twitchat/duphandler.go +++ b/modules/twitchat/duphandler.go @@ -26,7 +26,7 @@ func DupHandler() PrivateMessageHandler { } } -func (h *dupHandler) IsValid(m string) bool { +func (h *dupHandler) IsValid(m *twitch.PrivateMessage) bool { return true } diff --git a/modules/twitchat/handlers.go b/modules/twitchat/handlers.go index d6c6134..4e3d327 100644 --- a/modules/twitchat/handlers.go +++ b/modules/twitchat/handlers.go @@ -10,7 +10,7 @@ type ( } PrivateMessageHandler interface { - IsValid(string) bool + 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/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 index ef70f7c..448b886 100644 --- a/modules/twitchat/twitchat.go +++ b/modules/twitchat/twitchat.go @@ -2,6 +2,7 @@ package twitchat import ( "galched-bot/modules/settings" + "galched-bot/modules/youtube" "github.com/gempir/go-twitch-irc/v2" ) @@ -14,12 +15,14 @@ type ( } ) -func New(s *settings.Settings) (*TwitchIRC, error) { +func New(s *settings.Settings, r *youtube.Requester) (*TwitchIRC, error) { var irc = new(TwitchIRC) irc.username = s.TwitchUser irc.handlers = append(irc.handlers, DupHandler()) + irc.handlers = append(irc.handlers, SongRequest(r)) + // irc.handlers = append(irc.handlers, LogCheck()) irc.chat = twitch.NewClient(s.TwitchUser, s.TwitchToken) irc.chat.OnPrivateMessage(irc.PrivateMessageHandler) @@ -45,7 +48,7 @@ func (c *TwitchIRC) PrivateMessageHandler(msg twitch.PrivateMessage) { return } for i := range c.handlers { - if c.handlers[i].IsValid(msg.Message) { + 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/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