diff --git a/apps/api/go.mod b/apps/api/go.mod index 44fa5fc84..2011085a7 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -76,6 +76,9 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/jackc/chunkreader/v2 v2.0.1 // indirect + github.com/jackc/pgio v1.0.0 // indirect + github.com/jackc/pgproto3/v2 v2.3.3 // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.7 // indirect @@ -136,6 +139,7 @@ require ( require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/jackc/pgconn v1.14.3 github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect github.com/jackc/pgx/v5 v5.5.2 // indirect diff --git a/apps/api/go.sum b/apps/api/go.sum index 9c24eee80..fb5bc554c 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -66,10 +66,12 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7 h1:y3N7Bm7Y9/CtpiVkw/ZWj6lSlDF3F74SfKwfTCer72Q= +github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -85,8 +87,17 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/imroc/req/v3 v3.42.3 h1:ryPG2AiwouutAopwPxKpWKyxgvO8fB3hts4JXlh3PaE= github.com/imroc/req/v3 v3.42.3/go.mod h1:Axz9Y/a2b++w5/Jht3IhQsdBzrG1ftJd1OJhu21bB2Q= +github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= +github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w= +github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM= +github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= +github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag= +github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.5.2 h1:iLlpgp4Cp/gC9Xuscl7lFL1PhhW+ZLtXZcrfCt4C3tA= @@ -104,6 +115,7 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= +github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= @@ -198,6 +210,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -215,6 +228,7 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.4 go.opentelemetry.io/contrib/instrumentation/runtime v0.46.1 h1:m9ReioVPIffxjJlGNRd0d5poy+9oTro3D+YbiEzUDOc= go.opentelemetry.io/contrib/instrumentation/runtime v0.46.1/go.mod h1:CANkrsXNzqOKXfOomu2zhOmc1/J5UZK9SGjrat6ZCG0= go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0 h1:jd0+5t/YynESZqsSyPz+7PAFdEop0dlN0+PkyHYo8oI= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0/go.mod h1:U707O40ee1FpQGyhvqnzmCJm1Wh6OX6GGBVn0E6Uyyk= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 h1:cl5P5/GIfFh4t6xyruOgJP5QiA1pw4fYYdv6nc6CBWw= @@ -224,10 +238,13 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.21.0/go.mod h go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.21.0 h1:VhlEQAPp9R1ktYfrPk5SOryw1e9LDDTZCbIPFrho0ec= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.21.0/go.mod h1:kB3ufRbfU+CQ4MlUcqtW8Z7YEOBeK2DJ6CmR5rYYF3E= go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= +go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw= +go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg= go.opentelemetry.io/otel/sdk/metric v1.21.0 h1:smhI5oD714d6jHE6Tie36fPx4WDFIg+Y6RfAY4ICcR0= go.opentelemetry.io/otel/sdk/metric v1.21.0/go.mod h1:FJ8RAsoPGv/wYMgBdUJXOm+6pzFY3YdljnXtv1SBE8Q= go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= @@ -241,13 +258,18 @@ go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.6.0 h1:S0JTfE48HbRj80+4tbvZDYsJ3tGv6BUU3XxyZ7CirAc= golang.org/x/arch v0.6.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ= +golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= @@ -257,18 +279,25 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/genproto/googleapis/api v0.0.0-20240304212257-790db918fca8 h1:8eadJkXbwDEMNwcB5O0s5Y5eCfyuCLdvaiOIaGTrWmQ= +google.golang.org/genproto/googleapis/api v0.0.0-20240304212257-790db918fca8/go.mod h1:O1cOfN1Cy6QEYr7VxtjOyP5AdAuR0aJ/MYZaaof623Y= google.golang.org/genproto/googleapis/rpc v0.0.0-20240304212257-790db918fca8 h1:IR+hp6ypxjH24bkMfEJ0yHR21+gwPWdV+/IBrPQyn3k= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240304212257-790db918fca8/go.mod h1:UCOku4NytXMJuLQE5VuqA5lX3PcHCBo8pxNyvkf4xBs= google.golang.org/grpc v1.62.0 h1:HQKZ/fa1bXkX1oFOvSjmZEUL8wLSaZTjCcLAlmZRtdk= +google.golang.org/grpc v1.62.0/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/apps/api/internal/impl_protected/integrations/nightbot.go b/apps/api/internal/impl_protected/integrations/nightbot.go new file mode 100644 index 000000000..ff2e2488c --- /dev/null +++ b/apps/api/internal/impl_protected/integrations/nightbot.go @@ -0,0 +1,625 @@ +package integrations + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/google/uuid" + "github.com/guregu/null" + "github.com/imroc/req/v3" + "github.com/jackc/pgx/v5/pgconn" + "github.com/lib/pq" + "github.com/samber/lo" + "github.com/satont/twir/apps/api/internal/helpers" + model "github.com/satont/twir/libs/gomodels" + "github.com/twirapp/twir/libs/api/messages/integrations_nightbot" + "google.golang.org/protobuf/types/known/emptypb" +) + +type nightbotTokensResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` +} + +type nightbotChannelResponse struct { + User struct { + DisplayName string `json:"displayName"` + Avatar string `json:"avatar"` + } `json:"user"` +} + +type nightbotCustomCommandsResponse struct { + Commands []struct { + Name string `json:"name"` + Message string `json:"message"` + CoolDown int `json:"coolDown"` + Count int `json:"count"` + UserLevel string `json:"userLevel"` + } `json:"commands"` + TotalCount int `json:"_total"` +} + +type nightbotTimersResponse struct { + TotalCount int `json:"_total"` + Timers []struct { + ID string `json:"_id"` + Name string `json:"name"` + Message string `json:"message"` + Interval string `json:"interval"` + Lines int `json:"lines"` + Enabled bool `json:"enabled"` + } +} + +type nightbotRefreshResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` +} + +func (c *Integrations) refreshNightbotTokens( + ctx context.Context, + integration *model.ChannelsIntegrations, +) error { + refreshData := nightbotRefreshResponse{} + resp, err := req.R(). + SetContext(ctx). + SetFormData( + map[string]string{ + "grant_type": "refresh_token", + "client_id": integration.ClientID.String, + "client_secret": integration.ClientSecret.String, + "refresh_token": integration.RefreshToken.String, + }, + ). + SetSuccessResult(&refreshData). + Post("https://api.nightbot.tv/oauth2/token") + if err != nil { + return err + } + + if !resp.IsSuccessState() { + return fmt.Errorf("nightbot integration error: %s", resp.String()) + } + + integration.AccessToken = null.StringFrom(refreshData.AccessToken) + integration.RefreshToken = null.StringFrom(refreshData.RefreshToken) + integration.Enabled = true + + err = c.Db.WithContext(ctx).Save(integration).Error + if err != nil { + return err + } + + return nil +} + +func (c *Integrations) IntegrationsNightbotImportCommands( + ctx context.Context, + _ *emptypb.Empty, +) (*integrations_nightbot.ImportCommandsResponse, error) { + dashboardId, err := helpers.GetSelectedDashboardIDFromContext(ctx) + if err != nil { + return nil, err + } + + integration, err := c.getChannelIntegrationByService( + ctx, + model.IntegrationServiceNightbot, + dashboardId, + ) + if err != nil { + return nil, err + } + + if !integration.AccessToken.Valid { + return nil, errors.New("enable nightbot integration first") + } + + commandsData := nightbotCustomCommandsResponse{} + resp, err := req.R(). + SetContext(ctx). + SetBearerAuthToken(integration.AccessToken.String). + SetSuccessResult(&commandsData). + Get("https://api.nightbot.tv/1/commands") + if err != nil { + return nil, err + } + if !resp.IsSuccessState() { + if resp.StatusCode == http.StatusUnauthorized { + err = c.refreshNightbotTokens(ctx, integration) + if err != nil { + return nil, err + } + } + return nil, fmt.Errorf("nightbot integration error: %s", resp.String()) + } + + if len(commandsData.Commands) == 0 { + return &integrations_nightbot.ImportCommandsResponse{ + ImportedCount: 0, + FailedCount: 0, + FailedCommandsNames: []string{}, + }, nil + } + + twirRoles := []model.ChannelRole{} + err = c.Db.Where(`"channelId" = ?`, dashboardId).Find(&twirRoles).Error + if err != nil { + return nil, errors.New("twir internal error") + } + broadcasterRole, ok := lo.Find(twirRoles, func(r model.ChannelRole) bool { + return r.Name == "BROADCASTER" + }) + if !ok { + return nil, errors.New("twir internal error") + } + moderatorRole, ok := lo.Find(twirRoles, func(r model.ChannelRole) bool { + return r.Name == "MODERATOR" + }) + if !ok { + return nil, errors.New("twir internal error") + } + subscriberRole, ok := lo.Find(twirRoles, func(r model.ChannelRole) bool { + return r.Name == "SUBSCRIBER" + }) + if !ok { + return nil, errors.New("twir internal error") + } + vipRole, ok := lo.Find(twirRoles, func(r model.ChannelRole) bool { + return r.Name == "VIP" + }) + if !ok { + return nil, errors.New("twir internal error") + } + + importedCount := 0 + failedCount := 0 + failedCommandsNames := []string{} + for _, command := range commandsData.Commands { + commandName := strings.ToLower(command.Name) + if command.Name[0] == '!' { + commandName = commandName[1:] + } + commandRoles := []string{} + commandResponse := command.Message + + twirCommand := model.ChannelsCommands{} + err = c.Db.Where(`"channelId" = ? AND "name" = ?`, dashboardId, commandName). + Find(&twirCommand). + Error + if err != nil { + failedCount++ + failedCommandsNames = append( + failedCommandsNames, + command.Name+" (twir internal error)", + ) + + continue + } + + if twirCommand.ID != "" { + failedCount++ + failedCommandsNames = append( + failedCommandsNames, + command.Name+" (command with this name already exists)", + ) + continue + } + + switch command.UserLevel { + case "admin": + failedCount++ + failedCommandsNames = append( + failedCommandsNames, + command.Name+" (command userlevel is not supported)", + ) + continue + case "owner": + commandRoles = append(commandRoles, broadcasterRole.ID) + case "moderator": + commandRoles = append(commandRoles, broadcasterRole.ID, moderatorRole.ID) + case "twitch_vip": + commandRoles = append(commandRoles, broadcasterRole.ID, moderatorRole.ID, vipRole.ID) + case "regular": + failedCount++ + failedCommandsNames = append( + failedCommandsNames, + command.Name+" (command userlevel is not supported)", + ) + continue + case "subscriber": + commandRoles = append( + commandRoles, + broadcasterRole.ID, + moderatorRole.ID, + subscriberRole.ID, + ) + case "everyone": + commandRoles = []string{} + case "default": + failedCount++ + failedCommandsNames = append( + failedCommandsNames, + command.Name+" (command userlevel is not supported)", + ) + } + + newCommand := model.ChannelsCommands{ + ID: uuid.NewString(), + Name: commandName, + Cooldown: null.IntFrom(int64(command.CoolDown)), + CooldownType: "GLOBAL", + Default: false, + DefaultName: null.String{}, + Module: "CUSTOM", + IsReply: true, + KeepResponsesOrder: true, + DeniedUsersIDS: []string{}, + AllowedUsersIDS: []string{}, + RolesIDS: commandRoles, + OnlineOnly: false, + RequiredWatchTime: 0, + RequiredMessages: 0, + RequiredUsedChannelPoints: 0, + Responses: make( + []*model.ChannelsCommandsResponses, + 0, + 1, + ), + GroupID: null.String{}, + EnabledCategories: pq.StringArray{}, + CooldownRolesIDs: pq.StringArray{}, + Enabled: true, + Aliases: pq.StringArray{}, + Visible: true, + ChannelID: dashboardId, + Description: null.String{}, + } + + newCommand.Responses = append(newCommand.Responses, &model.ChannelsCommandsResponses{ + ID: uuid.NewString(), + Text: null.StringFrom(commandResponse), + Order: 0, + }) + + err = c.Db.WithContext(ctx).Create(&newCommand).Error + if err != nil { + if pgerr, ok := err.(*pgconn.PgError); ok { + if pgerr.Code == "23505" { + failedCount++ + failedCommandsNames = append( + failedCommandsNames, + command.Name+" (command with this name already exists)", + ) + + continue + } + } + + failedCount++ + failedCommandsNames = append( + failedCommandsNames, + command.Name+" (twir internal error)", + ) + + continue + } + importedCount++ + } + + return &integrations_nightbot.ImportCommandsResponse{ + ImportedCount: int32(importedCount), + FailedCount: int32(failedCount), + FailedCommandsNames: failedCommandsNames, + }, nil +} + +func (c *Integrations) IntegrationsNightbotImportTimers( + ctx context.Context, + _ *emptypb.Empty, +) (*integrations_nightbot.ImportTimersResponse, error) { + dashboardId, err := helpers.GetSelectedDashboardIDFromContext(ctx) + if err != nil { + return nil, err + } + + integration, err := c.getChannelIntegrationByService( + ctx, + model.IntegrationServiceNightbot, + dashboardId, + ) + if err != nil { + return nil, err + } + + if !integration.AccessToken.Valid { + return nil, errors.New("enable nightbot integration first") + } + + timersData := nightbotTimersResponse{} + resp, err := req.R(). + SetContext(ctx). + SetBearerAuthToken(integration.AccessToken.String). + SetSuccessResult(&timersData). + Get("https://api.nightbot.tv/1/timers") + if err != nil { + return nil, err + } + if !resp.IsSuccessState() { + if resp.StatusCode == http.StatusUnauthorized { + err = c.refreshNightbotTokens(ctx, integration) + if err != nil { + return nil, err + } + } + return nil, fmt.Errorf("nightbot integration error: %s", resp.String()) + } + + if len(timersData.Timers) == 0 { + return &integrations_nightbot.ImportTimersResponse{ + ImportedCount: 0, + FailedCount: 0, + FailedTimersNames: []string{}, + }, nil + } + + importedCount := 0 + failedCount := 0 + failedTimersNames := []string{} + + var currentCount int64 + if err := c.Db.Model(&model.ChannelsTimers{}).Where( + `"channelId" = ?`, + dashboardId, + ).Count(¤tCount).Error; err != nil { + return nil, fmt.Errorf("cannot get timers count time: %w", err) + } + + spaceLeft := 10 - currentCount + re := regexp.MustCompile(`\*/(\d+)|(\d+) \* \* \* \*`) + for _, timer := range timersData.Timers { + if spaceLeft == 0 { + failedCount++ + failedTimersNames = append(failedTimersNames, timer.Name+" (no space left)") + continue + } + + var interval string + + match := re.FindStringSubmatch(timer.Interval) + for i := 1; i < len(match); i++ { + if match[i] != "" { + interval = match[i] + break + } + } + + if interval == "" { + failedCount++ + failedTimersNames = append( + failedTimersNames, + timer.Name+" (invalid timer interval)", + ) + continue + } + parsedInterval, err := strconv.Atoi(interval) + if parsedInterval == 0 { + parsedInterval = 60 + } + + if err != nil { + failedCount++ + failedTimersNames = append( + failedTimersNames, + timer.Name+" (invalid timer interval)", + ) + continue + } + + entity := &model.ChannelsTimers{ + ID: uuid.NewString(), + ChannelID: dashboardId, + Name: timer.Name, + Enabled: timer.Enabled, + TimeInterval: int32(parsedInterval), + MessageInterval: int32(timer.Lines), + Responses: []*model.ChannelsTimersResponses{ + { + ID: uuid.NewString(), + Text: timer.Message, + IsAnnounce: false, + }, + }, + } + + if err := c.Db.WithContext(ctx).Create(&entity).Error; err != nil { + if pgerr, ok := err.(*pgconn.PgError); ok { + if pgerr.Code == "23505" { + failedCount++ + failedTimersNames = append( + failedTimersNames, + timer.Name+" (timer already exists)", + ) + continue + } + } + + failedCount++ + failedTimersNames = append( + failedTimersNames, + timer.Name+" (twir internal error)", + ) + continue + } + + importedCount++ + spaceLeft-- + } + + return &integrations_nightbot.ImportTimersResponse{ + ImportedCount: int32(importedCount), + FailedCount: int32(failedCount), + FailedTimersNames: failedTimersNames, + }, nil +} + +func (c *Integrations) IntegrationsNightbotGetAuthLink( + ctx context.Context, + _ *emptypb.Empty, +) (*integrations_nightbot.GetAuthLink, error) { + integration, err := c.getIntegrationByService(ctx, model.IntegrationServiceNightbot) + if err != nil { + return nil, err + } + + if !integration.ClientID.Valid || !integration.ClientSecret.Valid || + !integration.RedirectURL.Valid { + return nil, errors.New("nightbot not enabled on our side, please be patient") + } + + link, _ := url.Parse("https://api.nightbot.tv/oauth2/authorize") + + q := link.Query() + q.Add("response_type", "code") + q.Add("client_id", integration.ClientID.String) + q.Add("scope", "commands commands_default timers regulars spam_protection") + q.Add("redirect_uri", integration.RedirectURL.String) + link.RawQuery = q.Encode() + + return &integrations_nightbot.GetAuthLink{ + Link: link.String(), + }, nil +} + +func (c *Integrations) IntegrationsNightbotGetData( + ctx context.Context, + _ *emptypb.Empty, +) (*integrations_nightbot.GetDataResponse, error) { + dashboardId, err := helpers.GetSelectedDashboardIDFromContext(ctx) + if err != nil { + return nil, err + } + + integration, err := c.getChannelIntegrationByService( + ctx, + model.IntegrationServiceNightbot, + dashboardId, + ) + if err != nil { + return nil, err + } + + return &integrations_nightbot.GetDataResponse{ + UserName: integration.Data.UserName, + Avatar: integration.Data.Avatar, + }, nil +} + +func (c *Integrations) IntegrationsNightbotPostCode( + ctx context.Context, + request *integrations_nightbot.PostCodeRequest, +) (*emptypb.Empty, error) { + dashboardId, err := helpers.GetSelectedDashboardIDFromContext(ctx) + if err != nil { + return nil, err + } + + channelIntegration, err := c.getChannelIntegrationByService( + ctx, + model.IntegrationServiceNightbot, + dashboardId, + ) + if err != nil { + return nil, err + } + + tokensData := nightbotTokensResponse{} + resp, err := req.R(). + SetContext(ctx). + SetFormData( + map[string]string{ + "grant_type": "authorization_code", + "client_id": channelIntegration.Integration.ClientID.String, + "client_secret": channelIntegration.Integration.ClientSecret.String, + "redirect_uri": channelIntegration.Integration.RedirectURL.String, + "code": request.Code, + }, + ). + SetSuccessResult(&tokensData). + Post("https://api.nightbot.tv/oauth2/token") + if err != nil { + return nil, err + } + if !resp.IsSuccessState() { + return nil, fmt.Errorf("nightbot token request failed: %s", resp.String()) + } + + channelData := &nightbotChannelResponse{} + resp, err = req.R(). + SetContext(ctx). + SetSuccessResult(channelData). + SetBearerAuthToken(tokensData.AccessToken). + Get("https://api.nightbot.tv/1/me") + if err != nil { + return nil, err + } + if !resp.IsSuccessState() { + return nil, fmt.Errorf("nightbot token request failed: %s", resp.String()) + } + + channelIntegration.Data = &model.ChannelsIntegrationsData{ + UserName: &channelData.User.DisplayName, + Avatar: &channelData.User.Avatar, + } + channelIntegration.AccessToken = null.StringFrom(tokensData.AccessToken) + channelIntegration.RefreshToken = null.StringFrom(tokensData.RefreshToken) + channelIntegration.Enabled = true + + if err = c.Db.WithContext(ctx).Save(channelIntegration).Error; err != nil { + return nil, err + } + + return &emptypb.Empty{}, nil +} + +func (c *Integrations) IntegrationsNightbotLogout( + ctx context.Context, + empty *emptypb.Empty, +) (*emptypb.Empty, error) { + dashboardId, err := helpers.GetSelectedDashboardIDFromContext(ctx) + if err != nil { + return nil, err + } + + integration, err := c.getChannelIntegrationByService( + ctx, + model.IntegrationServiceNightbot, + dashboardId, + ) + if err != nil { + return nil, err + } + + integration.Data = &model.ChannelsIntegrationsData{} + integration.AccessToken = null.String{} + integration.RefreshToken = null.String{} + integration.Enabled = false + + if err = c.Db.WithContext(ctx).Save(&integration).Error; err != nil { + return nil, err + } + + return &emptypb.Empty{}, nil +} diff --git a/apps/integrations/src/index.js b/apps/integrations/src/index.js index 072e560e1..c454788f6 100644 --- a/apps/integrations/src/index.js +++ b/apps/integrations/src/index.js @@ -23,6 +23,7 @@ for (const integration of integrations) { if (integration.integration.service === Services.DONATEPAY) { addDonatePayIntegration(integration); } + } /** diff --git a/frontend/dashboard/src/api/integrations/index.ts b/frontend/dashboard/src/api/integrations/index.ts index 1164472b0..6140cd6fc 100644 --- a/frontend/dashboard/src/api/integrations/index.ts +++ b/frontend/dashboard/src/api/integrations/index.ts @@ -3,3 +3,4 @@ export * from './donatello.js'; export * from './donatepay.js'; export * from './donatestream.js'; export * from './discord.js'; +export * from './nightbot.js'; diff --git a/frontend/dashboard/src/api/integrations/nightbot.ts b/frontend/dashboard/src/api/integrations/nightbot.ts new file mode 100644 index 000000000..169a24737 --- /dev/null +++ b/frontend/dashboard/src/api/integrations/nightbot.ts @@ -0,0 +1,22 @@ +import { useMutation } from '@tanstack/vue-query'; + +import { protectedApiClient } from '@/api/twirp'; + +export const useNightbotIntegrationImporter = () => { + return { + useCommandsImporter: () => useMutation({ + mutationKey: ['integrationsNightbotImportCommands'], + mutationFn: async () => { + const call = await protectedApiClient.integrationsNightbotImportCommands({}); + return call.response; + }, + }), + useTimersImporter: () => useMutation({ + mutationKey: ['integrationsNightbotImportTimers'], + mutationFn: async () => { + const call = await protectedApiClient.integrationsNightbotImportTimers({}); + return call.response; + }, + }), + }; +}; diff --git a/frontend/dashboard/src/api/integrations/oauth.ts b/frontend/dashboard/src/api/integrations/oauth.ts index 31feed5bf..047ac7094 100644 --- a/frontend/dashboard/src/api/integrations/oauth.ts +++ b/frontend/dashboard/src/api/integrations/oauth.ts @@ -138,3 +138,11 @@ export const useValorantIntegration = () => createIntegrationOauth({ useLogout: protectedApiClient.integrationsValorantLogout, }); +export const useNightbotIntegration = () => createIntegrationOauth({ + integrationName: 'nightbot', + getData: protectedApiClient.integrationsNightbotGetData, + getAuthLink: protectedApiClient.integrationsNightbotGetAuthLink, + usePostCode: protectedApiClient.integrationsNightbotPostCode, + useLogout: protectedApiClient.integrationsNightbotLogout, +}); + diff --git a/frontend/dashboard/src/assets/integrations/nightbot.png b/frontend/dashboard/src/assets/integrations/nightbot.png new file mode 100644 index 000000000..995f88008 Binary files /dev/null and b/frontend/dashboard/src/assets/integrations/nightbot.png differ diff --git a/frontend/dashboard/src/assets/integrations/nightbot.svg b/frontend/dashboard/src/assets/integrations/nightbot.svg new file mode 100644 index 000000000..b1b4c5902 --- /dev/null +++ b/frontend/dashboard/src/assets/integrations/nightbot.svg @@ -0,0 +1,698 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/dashboard/src/components/commands/importModal.vue b/frontend/dashboard/src/components/commands/importModal.vue new file mode 100644 index 000000000..ae3ef4040 --- /dev/null +++ b/frontend/dashboard/src/components/commands/importModal.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/frontend/dashboard/src/components/commands/list.vue b/frontend/dashboard/src/components/commands/list.vue index 441be3154..9e385bef3 100644 --- a/frontend/dashboard/src/components/commands/list.vue +++ b/frontend/dashboard/src/components/commands/list.vue @@ -16,6 +16,7 @@ import ColumnActions from './list/column-actions.vue'; import { type Group, isCommand, createGroups } from './list/create-groups'; import { useUserAccessFlagChecker } from '@/api/index.js'; +import ImportModal from '@/components/commands/importModal.vue'; import ManageGroups from '@/components/commands/manageGroups.vue'; import Modal from '@/components/commands/modal.vue'; import type { EditableCommand } from '@/components/commands/types.js'; @@ -149,6 +150,9 @@ const table = useVueTable({ } }, }); + +const showImportModal = ref(false); + diff --git a/frontend/dashboard/src/pages/IntegrationsCallback.vue b/frontend/dashboard/src/pages/IntegrationsCallback.vue index 6b56bd198..d6cbf89af 100644 --- a/frontend/dashboard/src/pages/IntegrationsCallback.vue +++ b/frontend/dashboard/src/pages/IntegrationsCallback.vue @@ -10,7 +10,7 @@ import { useStreamlabsIntegration, useDonationAlertsIntegration, useFaceitIntegration, - useDiscordIntegration, useValorantIntegration, + useDiscordIntegration, useValorantIntegration, useNightbotIntegration, } from '@/api/index.js'; const router = useRouter(); @@ -59,6 +59,10 @@ const integrationsHooks: { manager: useValorantIntegration(), closeWindow: true, }, + 'nightbot': { + manager: useNightbotIntegration(), + closeWindow: true, + }, }; onMounted(async () => { diff --git a/frontend/dashboard/src/pages/Timers.vue b/frontend/dashboard/src/pages/Timers.vue index 095f8e752..56aed4104 100644 --- a/frontend/dashboard/src/pages/Timers.vue +++ b/frontend/dashboard/src/pages/Timers.vue @@ -15,6 +15,7 @@ import { computed, h, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { useTimersManager, useUserAccessFlagChecker } from '@/api/index.js'; +import ImportModal from '@/components/timers/importModal.vue'; import Modal from '@/components/timers/modal.vue'; import { type EditableTimer } from '@/components/timers/types.js'; import { renderIcon } from '@/helpers/index.js'; @@ -120,6 +121,8 @@ const showModal = ref(false); const editableTimer = ref(null); +const showImportModal = ref(false); + function openModal(t: EditableTimer | null) { editableTimer.value = t; showModal.value = true; @@ -135,14 +138,22 @@ const timersLength = computed(() => timers.data?.value?.timers?.length ?? 0);