diff --git a/.oapi-codegen.yaml b/.oapi-codegen.yaml new file mode 100644 index 0000000..6a1dafc --- /dev/null +++ b/.oapi-codegen.yaml @@ -0,0 +1,6 @@ +package: desc +generate: + chi-server: true + strict-server: true + models: true +output: ./internal/api/gen/api-server.gen.go \ No newline at end of file diff --git a/Makefile b/Makefile index 78e1f5c..bf22b76 100644 --- a/Makefile +++ b/Makefile @@ -19,3 +19,7 @@ build: .PHONY: lint lint: golangci-lint run + +.PHONY: generate +generate: + oapi-codegen -config ".oapi-codegen.yaml" ./api/specification.yml \ No newline at end of file diff --git a/api/specification.yml b/api/specification.yml new file mode 100644 index 0000000..9c0700b --- /dev/null +++ b/api/specification.yml @@ -0,0 +1,49 @@ +openapi: 3.0.2 +servers: + - url: /v1 +info: + version: 1.0.0 + title: multichat bot api +tags: + - name: twitch + description: Twitch service api + - name: youtube + description: youtube service api +paths: + '/twitch/{chat}': + post: + tags: + - twitch + summary: Join the twitch chat for the specified channel + description: '' + operationId: joinTwitchChat + responses: + '200': + description: Successful operation + '500': + description: Unable to join chat + parameters: + - name: chat + in: path + description: the username to connect to the chat + required: true + schema: + type: string + delete: + tags: + - twitch + summary: Leave the twitch chat for the specified channel + description: '' + operationId: leaveTwitchChat + responses: + '200': + description: Successful operation + '500': + description: Unable to leave chat + parameters: + - name: chat + in: path + description: the username to leave to the chat + required: true + schema: + type: string \ No newline at end of file diff --git a/cmd/multichat_bot/main.go b/cmd/multichat_bot/main.go index b0080eb..b66eadc 100644 --- a/cmd/multichat_bot/main.go +++ b/cmd/multichat_bot/main.go @@ -27,10 +27,11 @@ func main() { log.Fatalf("error parsing config: %s", err.Error()) } - _, err = bootstrap.Twitch(appCtx, cfg.Twitch) + twitchService, err := bootstrap.Twitch(appCtx, cfg.Twitch) if err != nil { log.Fatalf("can not start twitch service: %s", err.Error()) } - <-appCtx.Done() + // should be last + bootstrap.API(cfg.Api, twitchService) } diff --git a/go.mod b/go.mod index 8ce6808..ad04ffa 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,17 @@ module multichat_bot go 1.21 require ( + github.com/go-chi/chi/v5 v5.0.11 + github.com/oapi-codegen/runtime v1.1.1 golang.org/x/net v0.20.0 golang.org/x/oauth2 v0.16.0 ) require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/golang/protobuf v1.5.3 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/uuid v1.5.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.31.0 // indirect ) diff --git a/go.sum b/go.sum index 208af78..4d50063 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,90 @@ cloud.google.com/go/compute v1.20.1/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= +github.com/CloudyKit/jet/v6 v6.2.0/go.mod h1:d3ypHeIRNo2+XyqnGA8s+aphtcVpjP5hPwP/Lzo7Ro4= +github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06/go.mod h1:7erjKLwalezA0k99cWs5L11HWOAPNjdUZ6RxH1BXbbM= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/bytedance/sonic v1.10.0-rc3/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= +github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= +github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= +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/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/flosch/pongo2/v4 v4.0.2/go.mod h1:B5ObFANs/36VwxxlgKpdchIJHMvHB562PW+BWPhwZD8= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA= +github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/gomarkdown/markdown v0.0.0-20230922112808-5421fefb8386/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +github.com/iris-contrib/schema v0.0.6/go.mod h1:iYszG0IOsuIsfzjymw1kMzTL8YQcCWlm65f3wX8J5iA= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/kataras/blocks v0.0.7/go.mod h1:UJIU97CluDo0f+zEjbnbkeMRlvYORtmc1304EeyXf4I= +github.com/kataras/golog v0.1.9/go.mod h1:jlpk/bOaYCyqDqH18pgDHdaJab72yBE6i0O3s30hpWY= +github.com/kataras/iris/v12 v12.2.6-0.20230908161203-24ba4e8933b9/go.mod h1:ldkoR3iXABBeqlTibQ3MYaviA1oSlPvim6f55biwBh4= +github.com/kataras/pio v0.0.12/go.mod h1:ODK/8XBhhQ5WqrAhKy+9lTPS7sBf6O3KcLhc9klfRcY= +github.com/kataras/sitemap v0.0.6/go.mod h1:dW4dOCNs896OR1HmG+dMLdT7JjDk7mYBzoIRwuj5jA4= +github.com/kataras/tunnel v0.0.4/go.mod h1:9FkU4LaeifdMWqZu7o20ojmW4B7hdhv2CMLwfnHGpYw= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/mailgun/raymond/v2 v2.0.48/go.mod h1:lsgvL50kgt1ylcFJYZiULi5fjPBkkhNfj4KA0W54Z18= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= +github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= +github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +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.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tdewolff/minify/v2 v2.12.9/go.mod h1:qOqdlDfL+7v0/fyymB+OP497nIxJYSvX4MQWA8OoiXU= +github.com/tdewolff/parse/v2 v2.6.8/go.mod h1:XHDhaU6IBgsryfdnpzUXBlT6leW/l25yrFBTEb4eIyM= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/yosssi/ace v0.0.5/go.mod h1:ALfIzm2vT7t5ZE7uoIZqF3TQ7SAOyupFZnkrF5id+K0= +golang.org/x/arch v0.4.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= @@ -20,11 +98,16 @@ golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/gen/api-server.gen.go b/internal/api/gen/api-server.gen.go new file mode 100644 index 0000000..12bdc2e --- /dev/null +++ b/internal/api/gen/api-server.gen.go @@ -0,0 +1,363 @@ +// Package desc provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/deepmap/oapi-codegen/v2 version v2.0.0 DO NOT EDIT. +package desc + +import ( + "context" + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/oapi-codegen/runtime" + strictnethttp "github.com/oapi-codegen/runtime/strictmiddleware/nethttp" +) + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Leave the twitch chat for the specified channel + // (DELETE /twitch/{chat}) + LeaveTwitchChat(w http.ResponseWriter, r *http.Request, chat string) + // Join the twitch chat for the specified channel + // (POST /twitch/{chat}) + JoinTwitchChat(w http.ResponseWriter, r *http.Request, chat string) +} + +// Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. + +type Unimplemented struct{} + +// Leave the twitch chat for the specified channel +// (DELETE /twitch/{chat}) +func (_ Unimplemented) LeaveTwitchChat(w http.ResponseWriter, r *http.Request, chat string) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Join the twitch chat for the specified channel +// (POST /twitch/{chat}) +func (_ Unimplemented) JoinTwitchChat(w http.ResponseWriter, r *http.Request, chat string) { + w.WriteHeader(http.StatusNotImplemented) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +// LeaveTwitchChat operation middleware +func (siw *ServerInterfaceWrapper) LeaveTwitchChat(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var err error + + // ------------- Path parameter "chat" ------------- + var chat string + + err = runtime.BindStyledParameterWithLocation("simple", false, "chat", runtime.ParamLocationPath, chi.URLParam(r, "chat"), &chat) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "chat", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.LeaveTwitchChat(w, r, chat) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r.WithContext(ctx)) +} + +// JoinTwitchChat operation middleware +func (siw *ServerInterfaceWrapper) JoinTwitchChat(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var err error + + // ------------- Path parameter "chat" ------------- + var chat string + + err = runtime.BindStyledParameterWithLocation("simple", false, "chat", runtime.ParamLocationPath, chi.URLParam(r, "chat"), &chat) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "chat", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.JoinTwitchChat(w, r, chat) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r.WithContext(ctx)) +} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{}) +} + +type ChiServerOptions struct { + BaseURL string + BaseRouter chi.Router + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseRouter: r, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, r chi.Router, baseURL string) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseURL: baseURL, + BaseRouter: r, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handler { + r := options.BaseRouter + + if r == nil { + r = chi.NewRouter() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandlerFunc: options.ErrorHandlerFunc, + } + + r.Group(func(r chi.Router) { + r.Delete(options.BaseURL+"/twitch/{chat}", wrapper.LeaveTwitchChat) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/twitch/{chat}", wrapper.JoinTwitchChat) + }) + + return r +} + +type LeaveTwitchChatRequestObject struct { + Chat string `json:"chat"` +} + +type LeaveTwitchChatResponseObject interface { + VisitLeaveTwitchChatResponse(w http.ResponseWriter) error +} + +type LeaveTwitchChat200Response struct { +} + +func (response LeaveTwitchChat200Response) VisitLeaveTwitchChatResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +type LeaveTwitchChat500Response struct { +} + +func (response LeaveTwitchChat500Response) VisitLeaveTwitchChatResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + +type JoinTwitchChatRequestObject struct { + Chat string `json:"chat"` +} + +type JoinTwitchChatResponseObject interface { + VisitJoinTwitchChatResponse(w http.ResponseWriter) error +} + +type JoinTwitchChat200Response struct { +} + +func (response JoinTwitchChat200Response) VisitJoinTwitchChatResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +type JoinTwitchChat500Response struct { +} + +func (response JoinTwitchChat500Response) VisitJoinTwitchChatResponse(w http.ResponseWriter) error { + w.WriteHeader(500) + return nil +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // Leave the twitch chat for the specified channel + // (DELETE /twitch/{chat}) + LeaveTwitchChat(ctx context.Context, request LeaveTwitchChatRequestObject) (LeaveTwitchChatResponseObject, error) + // Join the twitch chat for the specified channel + // (POST /twitch/{chat}) + JoinTwitchChat(ctx context.Context, request JoinTwitchChatRequestObject) (JoinTwitchChatResponseObject, error) +} + +type StrictHandlerFunc = strictnethttp.StrictHttpHandlerFunc +type StrictMiddlewareFunc = strictnethttp.StrictHttpMiddlewareFunc + +type StrictHTTPServerOptions struct { + RequestErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) + ResponseErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares, options: StrictHTTPServerOptions{ + RequestErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + }, + ResponseErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusInternalServerError) + }, + }} +} + +func NewStrictHandlerWithOptions(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc, options StrictHTTPServerOptions) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares, options: options} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc + options StrictHTTPServerOptions +} + +// LeaveTwitchChat operation middleware +func (sh *strictHandler) LeaveTwitchChat(w http.ResponseWriter, r *http.Request, chat string) { + var request LeaveTwitchChatRequestObject + + request.Chat = chat + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.LeaveTwitchChat(ctx, request.(LeaveTwitchChatRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "LeaveTwitchChat") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(LeaveTwitchChatResponseObject); ok { + if err := validResponse.VisitLeaveTwitchChatResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// JoinTwitchChat operation middleware +func (sh *strictHandler) JoinTwitchChat(w http.ResponseWriter, r *http.Request, chat string) { + var request JoinTwitchChatRequestObject + + request.Chat = chat + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.JoinTwitchChat(ctx, request.(JoinTwitchChatRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "JoinTwitchChat") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(JoinTwitchChatResponseObject); ok { + if err := validResponse.VisitJoinTwitchChatResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..e7dba48 --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,47 @@ +package api + +import ( + "context" + "log/slog" + "time" + + desc "multichat_bot/internal/api/gen" +) + +type twitchService interface { + JoinChat(ctx context.Context, chat string) error + LeaveChat(chat string) error +} + +type Server struct { + twitch twitchService +} + +func NewServer(twitch twitchService) *Server { + return &Server{ + twitch: twitch, + } +} + +func (s *Server) LeaveTwitchChat(_ context.Context, request desc.LeaveTwitchChatRequestObject) (desc.LeaveTwitchChatResponseObject, error) { + slog.Info("LeaveTwitchChat", slog.String("chat", request.Chat)) + + if err := s.twitch.LeaveChat(request.Chat); err != nil { + return desc.LeaveTwitchChat500Response{}, err + } + + return desc.LeaveTwitchChat200Response{}, nil +} + +func (s *Server) JoinTwitchChat(ctx context.Context, request desc.JoinTwitchChatRequestObject) (desc.JoinTwitchChatResponseObject, error) { + slog.Info("JoinTwitchChat", slog.String("chat", request.Chat)) + + ctx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + + if err := s.twitch.JoinChat(ctx, request.Chat); err != nil { + return desc.JoinTwitchChat500Response{}, err + } + + return desc.JoinTwitchChat200Response{}, nil +} diff --git a/internal/bootstrap/api.go b/internal/bootstrap/api.go new file mode 100644 index 0000000..94e1561 --- /dev/null +++ b/internal/bootstrap/api.go @@ -0,0 +1,33 @@ +package bootstrap + +import ( + "log" + "log/slog" + "net" + "net/http" + + "github.com/go-chi/chi/v5" + + "multichat_bot/internal/api" + desc "multichat_bot/internal/api/gen" + "multichat_bot/internal/config" + twitch "multichat_bot/internal/twitch/service" +) + +func API(cfg config.Api, twitchService *twitch.Service) { + server := api.NewServer(twitchService) + + handler := desc.NewStrictHandler(server, nil) + + router := chi.NewRouter() + + s := &http.Server{ + Handler: desc.HandlerFromMux(handler, router), + Addr: net.JoinHostPort(cfg.Host, cfg.Port), + } + + slog.Info("starting http server", slog.String("PATH", s.Addr)) + if err := s.ListenAndServe(); err != nil { + log.Fatalf("error while serving api: %s", err.Error()) + } +} diff --git a/internal/bootstrap/twitch.go b/internal/bootstrap/twitch.go index d17643c..8184af9 100644 --- a/internal/bootstrap/twitch.go +++ b/internal/bootstrap/twitch.go @@ -38,12 +38,12 @@ func Twitch(ctx context.Context, cfg config.Twitch) (*twitch.Service, error) { ) if err := ircClient.Connect(ctx, cfg.IRCServer); err != nil { - slog.Error("1", slog.StringValue(err.Error())) + slog.Error("unable to connect to twitch irc server", slog.String("error", err.Error())) return nil, err } - if err := twitchService.Connect(ctx, cfg); err != nil { - slog.Error("2", slog.StringValue(err.Error())) + if err := twitchService.Connect(cfg); err != nil { + slog.Error("unable to bootstrap twitch service", slog.String("error", err.Error())) return nil, err } diff --git a/internal/config/structs.go b/internal/config/structs.go index 52137cf..715f34c 100644 --- a/internal/config/structs.go +++ b/internal/config/structs.go @@ -1,7 +1,8 @@ package config type Config struct { - Twitch Twitch + Twitch Twitch `json:"twitch"` + Api Api `json:"api"` } type Twitch struct { @@ -24,3 +25,8 @@ type Oauth struct { Scopes []string `json:"scopes"` RedirectURL string `json:"redirect_url"` } + +type Api struct { + Host string `json:"host"` + Port string `json:"port"` +} diff --git a/internal/twitch/service/message_manager/manager.go b/internal/twitch/service/message_manager/manager.go index d7dac53..94e79f2 100644 --- a/internal/twitch/service/message_manager/manager.go +++ b/internal/twitch/service/message_manager/manager.go @@ -4,6 +4,14 @@ import ( "errors" "multichat_bot/internal/twitch/domain" + "multichat_bot/internal/twitch/service/message_manager/rate_limit" +) + +const ( + rateLimitAuthAttempts = 20 + rateLimitJoinAttempts = 20 + rateLimitChatDefault = 20 + rateLimitChatMod = 100 ) var ( @@ -17,23 +25,31 @@ type ircClient interface { type Manager struct { ircClient ircClient - chatsRL map[string]windowRateLimit - authRL windowRateLimit - joinRL windowRateLimit + chatsRL *rate_limit.Map + authRL *rate_limit.Checker + joinRL *rate_limit.Checker } func New(client ircClient) *Manager { return &Manager{ ircClient: client, + + chatsRL: rate_limit.NewMapChecker(rateLimitChatDefault), + joinRL: rate_limit.NewChecker(rateLimitJoinAttempts), + authRL: rate_limit.NewChecker(rateLimitAuthAttempts), } } -func (m *Manager) SendChatMessage(msg domain.IRCMessage) error { +func (m *Manager) SendChatMessage(chat string, msg domain.IRCMessage) error { + if !m.chatsRL.IsLimitExceeded(chat) { + return ErrorRateLimitExited + } + return m.ircClient.Send(msg.ToString()) } func (m *Manager) SendAuthMessage(msg domain.IRCMessage) error { - if !m.authRL.isSendAllowed() { + if !m.authRL.IsLimitExceeded() { return ErrorRateLimitExited } @@ -41,7 +57,7 @@ func (m *Manager) SendAuthMessage(msg domain.IRCMessage) error { } func (m *Manager) SendJoinMessage(msg domain.IRCMessage) error { - if !m.joinRL.isSendAllowed() { + if !m.joinRL.IsLimitExceeded() { return ErrorRateLimitExited } diff --git a/internal/twitch/service/message_manager/rate_limit/checker.go b/internal/twitch/service/message_manager/rate_limit/checker.go new file mode 100644 index 0000000..30d02e4 --- /dev/null +++ b/internal/twitch/service/message_manager/rate_limit/checker.go @@ -0,0 +1,50 @@ +package rate_limit + +import ( + "sync" + "time" +) + +const windowDuration = 30 * time.Second + +type Checker struct { + m sync.Mutex + + rl checkerUnsafe +} + +func NewChecker(limit int) *Checker { + return &Checker{ + rl: checkerUnsafe{limit: limit}, + } +} + +func (w *Checker) IsLimitExceeded() bool { + w.m.Lock() + defer w.m.Unlock() + + return w.rl.isLimitExceeded() +} + +type checkerUnsafe struct { + endAt time.Time + limit int + current int +} + +func (w *checkerUnsafe) isLimitExceeded() bool { + now := time.Now() + + if now.After(w.endAt) { + w.current = 1 + w.endAt = now.Add(windowDuration) + return true + } + + if w.current < w.limit { + w.current++ + return true + } + + return false +} diff --git a/internal/twitch/service/message_manager/rate_limit/map.go b/internal/twitch/service/message_manager/rate_limit/map.go new file mode 100644 index 0000000..ee08473 --- /dev/null +++ b/internal/twitch/service/message_manager/rate_limit/map.go @@ -0,0 +1,45 @@ +package rate_limit + +import ( + "sync" +) + +type Map struct { + values map[string]checkerUnsafe + defaultLimit int + m sync.Mutex +} + +func NewMapChecker(defaultLimit int) *Map { + return &Map{ + defaultLimit: defaultLimit, + } +} + +func (m *Map) IsLimitExceeded(key string) bool { + m.m.Lock() + defer m.m.Unlock() + + rl, isExist := m.values[key] + if isExist { + return rl.isLimitExceeded() + } + + m.values[key] = checkerUnsafe{ + limit: m.defaultLimit, + } + + return true +} + +func (m *Map) UpgradeRateLimit(key string, limit int) { + m.m.Lock() + defer m.m.Unlock() + + rl, isExist := m.values[key] + if !isExist { + return + } + + rl.limit = limit +} diff --git a/internal/twitch/service/message_manager/threshold.go b/internal/twitch/service/message_manager/threshold.go deleted file mode 100644 index 234ec10..0000000 --- a/internal/twitch/service/message_manager/threshold.go +++ /dev/null @@ -1,29 +0,0 @@ -package message_manager - -import ( - "time" -) - -type windowRateLimit struct { - endAt time.Time - duration time.Duration - capacity int - current int -} - -func (w *windowRateLimit) isSendAllowed() bool { - now := time.Now() - - if now.After(w.endAt) { - w.current = 1 - w.endAt = now.Add(w.duration) - return true - } - - if w.current < w.capacity { - w.current++ - return true - } - - return false -} diff --git a/internal/twitch/service/service.go b/internal/twitch/service/service.go index 16e7495..d199766 100644 --- a/internal/twitch/service/service.go +++ b/internal/twitch/service/service.go @@ -13,7 +13,7 @@ import ( ) type messageManager interface { - SendChatMessage(msg domain.IRCMessage) error + SendChatMessage(chat string, msg domain.IRCMessage) error SendAuthMessage(msg domain.IRCMessage) error SendJoinMessage(msg domain.IRCMessage) error } @@ -21,16 +21,18 @@ type messageManager interface { type Service struct { messageManager messageManager token oauth2.TokenSource + chats chats } func New(manager messageManager, token oauth2.TokenSource) *Service { return &Service{ messageManager: manager, token: token, + chats: newChats(), } } -func (s *Service) Connect(ctx context.Context, cfg config.Twitch) error { +func (s *Service) Connect(cfg config.Twitch) error { token, err := s.token.Token() if err != nil { return err @@ -51,11 +53,14 @@ func (s *Service) Connect(ctx context.Context, cfg config.Twitch) error { return nil } -func (s *Service) JoinChat(chat string) error { - msg := &domain.JoinMessage{chat} +func (s *Service) JoinChat(ctx context.Context, chat string) error { + ctx, cancel := context.WithCancelCause(ctx) + if err := s.chats.processJoinRequest(chat, cancel); err != nil { + return err + } - err := s.messageManager.SendJoinMessage(msg) - if err != nil { + msg := &domain.JoinMessage{chat} + if err := s.messageManager.SendJoinMessage(msg); err != nil { slog.Error( "[message_processor] unable to join chat", slog.String("chat", chat), @@ -63,12 +68,16 @@ func (s *Service) JoinChat(chat string) error { slog.String("type", domain.IRCCommandJoin), slog.String("message", msg.ToString()), ) + + return err } - return err + <-ctx.Done() + + return context.Cause(ctx) } -func (s *Service) LeaveChat(chat string) { +func (s *Service) LeaveChat(chat string) error { msg := domain.PartMessage(chat) err := s.messageManager.SendJoinMessage(msg) @@ -81,6 +90,8 @@ func (s *Service) LeaveChat(chat string) { slog.String("message", msg.ToString()), ) } + + return err } func (s *Service) SendPongMessage(rawPingMessage string) { @@ -104,7 +115,7 @@ func (s *Service) SendTextMessage(chat, text string) { Channel: chat, } - err := s.messageManager.SendChatMessage(msg) + err := s.messageManager.SendChatMessage(chat, msg) if err != nil { slog.Error( "[message_processor] unable to send PrivMessage", diff --git a/internal/twitch/service/user.go b/internal/twitch/service/user.go new file mode 100644 index 0000000..1541e63 --- /dev/null +++ b/internal/twitch/service/user.go @@ -0,0 +1,109 @@ +package service + +import ( + "errors" + "sync" + "time" +) + +const ( + minDurationAfterLastJoin = time.Second +) + +//var ( +// errNotExist = errors.New("this chat does not exit in the system") +//) + +type chats struct { + chats map[string]*chatInfo + m sync.RWMutex +} + +type chatInfo struct { + isJoined bool + latJoinTry time.Time + joinCancelFn func(error) +} + +func newChats() chats { + return chats{ + chats: make(map[string]*chatInfo), + } +} + +// +//func (c *chats) updateToConnected(chat string) error { +// c.m.Lock() +// defer c.m.Unlock() +// +// info, isExist := c.chats[chat] +// if !isExist { +// return errNotExist +// } +// +// info.isJoined = true +// return nil +//} +// +//func (c *chats) updateToDisconnected(chat string) error { +// c.m.Lock() +// defer c.m.Unlock() +// +// info, isExist := c.chats[chat] +// if !isExist { +// return errNotExist +// } +// +// info.isJoined = false +// info.disconnectedAt = time.Now() +// return nil +//} +// +//func (c *chats) cancelJoinRequest(chat string, err error) error { +// c.m.Lock() +// defer c.m.Unlock() +// +// info, isExist := c.chats[chat] +// if !isExist { +// return errNotExist +// } +// +// info.joinCancelFn(err) +// return nil +//} + +func (c *chats) processJoinRequest(chat string, cancelFn func(error)) error { + c.m.Lock() + defer c.m.Unlock() + + prevInfo, isExist := c.chats[chat] + if !isExist { + c.chats[chat] = &chatInfo{ + isJoined: false, + latJoinTry: time.Now(), + joinCancelFn: cancelFn, + } + return nil + } + + if err := validateExistingRecord(prevInfo); err != nil { + return err + } + + prevInfo.latJoinTry = time.Now() + + return nil +} + +func validateExistingRecord(info *chatInfo) error { + if info.isJoined { + return errors.New("already connected to the chat") + } + + nextRequestThreshold := info.latJoinTry.Add(minDurationAfterLastJoin) + if time.Now().Before(nextRequestThreshold) { + return errors.New("to many request, please try later") + } + + return nil +}