diff --git a/.editorconfig b/.editorconfig index d96e98ea..b4018bc9 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,7 +6,7 @@ charset = utf-8 trim_trailing_whitespace = true indent_style = tab indent_size = 2 -max_line_length = 120 +max_line_length = 100 [*.yaml] indent_style = space diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..1962d478 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,148 @@ +# https://golangci-lint.run/usage/configuration/#config-file +linters: + disable-all: true + enable: + - errcheck + - gosimple + - govet + - ineffassign + - unused + + - errname + - exhaustive + - containedctx + - gocheckcompilerdirectives + - gochecknoinits + - goconst + - gocritic + - ireturn + - perfsprint + - prealloc + - protogetter + - sqlclosecheck + - whitespace + - goerr113 + - goimports + - revive + - staticcheck + - vet + - forbidigo + - tagliatelle + +run: + skip-dirs: + - ^api + - ^proto + - ^.git + - libs/grpc +linters-settings: + govet: + fieldalignment: 0 + forbidigo: + forbid: + - p: ^time\.After$ + msg: "time.After may leak resources. Use time.NewTimer instead." + revive: + severity: error + confidence: 0.8 + enable-all-rules: true + rules: + # Disabled rules + - name: add-constant + disabled: true + - name: argument-limit + disabled: true + - name: bare-return + disabled: true + - name: banned-characters + disabled: true + - name: bool-literal-in-expr + disabled: true + - name: confusing-naming + disabled: true + - name: empty-lines + disabled: true + - name: error-naming + disabled: true + - name: errorf + disabled: true + - name: exported + disabled: true + - name: file-header + disabled: true + - name: function-length + disabled: true + - name: imports-blacklist + disabled: true + - name: increment-decrement + disabled: true + - name: line-length-limit + disabled: true + - name: max-public-structs + disabled: true + - name: nested-structs + disabled: true + - name: package-comments + disabled: true + - name: string-format + disabled: true + - name: unexported-naming + disabled: true + - name: unexported-return + disabled: true + - name: unused-parameter + disabled: true + - name: unused-receiver + disabled: true + - name: use-any + disabled: true + - name: var-naming + disabled: true + - name: empty-block + disabled: true + - name: flag-parameter + disabled: true + + # Rule tuning + - name: cognitive-complexity + arguments: + - 25 + - name: cyclomatic + arguments: + - 25 + - name: function-result-limit + arguments: + - 5 + - name: unhandled-error + arguments: + - "fmt.*" + - "bytes.Buffer.*" + - "strings.Builder.*" + tagliatelle: + # Check the struck tag name case. + case: + # Use the struct field name to check the name of the struct tag. + # Default: false + use-field-name: true + rules: + # Any struct tag type can be used. + # Support string case: `camel`, `pascal`, `kebab`, `snake`, `upperSnake`, `goCamel`, `goPascal`, `goKebab`, `goSnake`, `upper`, `lower`, `header`. + json: snake + yaml: snake + xml: snake + toml: snake + +issues: + # Exclude cyclomatic and cognitive complexity rules for functional tests in the `tests` root directory. + exclude-rules: + - path: ^tests\/.+\.go + text: "(cyclomatic|cognitive)" + linters: + - revive + - path: _test\.go|^common/persistence\/tests\/.+\.go # Ignore things like err = errors.New("test error") in tests + linters: + - goerr113 + - path: ^tools\/.+\.go + linters: + - goerr113 + - revive diff --git a/.mockery.yaml b/.mockery.yaml new file mode 100644 index 00000000..8ccbdc0b --- /dev/null +++ b/.mockery.yaml @@ -0,0 +1,7 @@ +quiet: False +keeptree: True +disable-version-string: True +with-expecter: True +mockname: "{{.InterfaceName}}" +filename: "{{.MockName}}.go" +outpkg: mocks diff --git a/cmd/main.go b/cmd/main.go index 1d2b9030..fd1295e6 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,146 +1,39 @@ package main import ( - "context" - "log" - "os" - "os/signal" - "path/filepath" - "syscall" - "time" - - "github.com/getsentry/sentry-go" - - "entgo.io/ent/dialect/sql" - "github.com/TheZeroSlave/zapsentry" - "github.com/lib/pq" - "github.com/satont/twitch-notifier/ent" - "github.com/satont/twitch-notifier/internal/config" - "github.com/satont/twitch-notifier/internal/db" - "github.com/satont/twitch-notifier/internal/message_sender" - "github.com/satont/twitch-notifier/internal/telegram" - "github.com/satont/twitch-notifier/internal/twitch" - "github.com/satont/twitch-notifier/internal/twitch_streams_cheker" - "github.com/satont/twitch-notifier/internal/types" - "github.com/satont/twitch-notifier/pkg/i18n" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" + announcesender "github.com/satont/twitch-notifier/internal/announcesender/temporal" + i18nstore "github.com/satont/twitch-notifier/internal/i18n/store" + messagesender "github.com/satont/twitch-notifier/internal/messagesender/fx" + "github.com/satont/twitch-notifier/internal/pgx" + repositories "github.com/satont/twitch-notifier/internal/repository/fx" + thumbnailchecker "github.com/satont/twitch-notifier/internal/thumbnailchecker/temporal" + "github.com/satont/twitch-notifier/internal/twitchclient" + "github.com/satont/twitch-notifier/internal/twitchclient/twitchclientimpl" + "github.com/satont/twitch-notifier/pkg/config" + "github.com/satont/twitch-notifier/pkg/logger" + "go.uber.org/fx" ) -func createEnt(cfg *config.Config) (*ent.Client, error) { - pgConnectionUrl, err := pq.ParseURL(cfg.DatabaseUrl) - if err != nil { - log.Fatalln(err) - } - - drv, err := sql.Open("postgres", pgConnectionUrl) - if err != nil { - return nil, err - } - - db := drv.DB() - db.SetMaxIdleConns(2) - db.SetMaxOpenConns(10) - db.SetConnMaxLifetime(time.Hour) - return ent.NewClient(ent.Driver(drv)), nil -} - func main() { - wd, err := os.Getwd() - if err != nil { - log.Fatalln(err) - } - - cfg, err := config.NewConfig(nil) - if err != nil { - log.Fatalln(err) - } - - logger, _ := zap.NewDevelopment() - - if cfg.SentryDsn != "" { - sentryClient, err := sentry.NewClient( - sentry.ClientOptions{ - Dsn: cfg.SentryDsn, - EnableTracing: true, - }, - ) - if err != nil { - log.Fatalln(err) - } - logger = modifyToSentryLogger(logger, sentryClient) - defer sentry.Flush(2 * time.Second) - } - - zap.ReplaceGlobals(logger) - - client, err := createEnt(cfg) - if err != nil { - logger.Sugar().Fatalln("failed opening connection to postgres: %v", err) - } - // Run the auto migration tool. - // if err := client.Schema.Create(context.Background()); err != nil { - // log.Fatalf("failed creating schema resources: %v", err) - // } - - twitchService, err := twitch.NewTwitchService(cfg.TwitchClientId, cfg.TwitchClientSecret) - if err != nil { - logger.Sugar().Fatalln(err) - } - - i18, err := i18n.NewI18n(filepath.Join(wd, "locales")) - if err != nil { - logger.Sugar().Fatalln(err) - } - - services := &types.Services{ - Config: cfg, - Twitch: twitchService, - Chat: db.NewChatEntRepository(client), - Channel: db.NewChannelEntService(client), - Follow: db.NewFollowService(client), - Stream: db.NewStreamEntService(client), - I18N: i18, - } - - ctx, cancel := context.WithCancel(context.Background()) - - tg := telegram.NewTelegram(ctx, cfg.TelegramToken, services) - tg.StartPolling(ctx) - - sender := message_sender.NewMessageSender(tg.Client) - - checker := twitch_streams_cheker.NewTwitchStreamChecker(services, sender, nil) - checker.StartPolling(ctx) - - logger.Sugar().Info("Started") - exitSignal := make(chan os.Signal, 1) - signal.Notify(exitSignal, syscall.SIGINT, syscall.SIGTERM) - <-exitSignal - logger.Sugar().Info("Closing...") - cancel() - _ = client.Close() -} - -func modifyToSentryLogger(log *zap.Logger, client *sentry.Client) *zap.Logger { - cfg := zapsentry.Configuration{ - Level: zapcore.ErrorLevel, // when to send message to sentry - EnableBreadcrumbs: true, // enable sending breadcrumbs to Sentry - BreadcrumbLevel: zapcore.InfoLevel, // at what level should we sent breadcrumbs to sentry - Tags: map[string]string{ - "component": "system", - }, - } - core, err := zapsentry.NewCore(cfg, zapsentry.NewSentryClientFromClient(client)) - - // in case of err it will return noop core. so we can safely attach it - if err != nil { - log.Warn("failed to init zap", zap.Error(err)) - } - - log = zapsentry.AttachCoreToLogger(core, log) - - // to use breadcrumbs feature - create new scope explicitly - // and attach after attaching the core - return log.With(zapsentry.NewScope()) + fx.New( + // fx.NopLogger, + fx.Provide( + config.New, + logger.FxOption, + fx.Annotate( + i18nstore.New, + fx.As(new(i18nstore.I18nStore)), + ), + pgx.New, + fx.Annotate( + twitchclientimpl.New, + fx.As(new(twitchclient.TwitchClient)), + ), + ), + repositories.Module, + thumbnailchecker.Module, + messagesender.Module, + announcesender.Module, + fx.Invoke(pgx.New), + ).Run() } diff --git a/ent/generate.go b/ent/generate.go deleted file mode 100644 index 8d3fdfdc..00000000 --- a/ent/generate.go +++ /dev/null @@ -1,3 +0,0 @@ -package ent - -//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate ./schema diff --git a/ent/migrate/migrations/20230327181912_initial.sql b/ent/migrate/migrations/20230327181912_initial.sql deleted file mode 100644 index 9a284d1b..00000000 --- a/ent/migrate/migrations/20230327181912_initial.sql +++ /dev/null @@ -1,62 +0,0 @@ --- create "chats" table -CREATE TABLE "chats" -( - "id" uuid NOT NULL, - "chat_id" character varying NOT NULL, - "service" character varying NOT NULL, - PRIMARY KEY ("id") -); --- create index "chat_chat_id_service" to table: "chats" -CREATE UNIQUE INDEX "chat_chat_id_service" ON "chats" ("chat_id", "service"); --- create "chat_settings" table -CREATE TABLE "chat_settings" -( - "id" uuid NOT NULL, - "game_change_notification" boolean NOT NULL DEFAULT true, - "offline_notification" boolean NOT NULL DEFAULT true, - "chat_language" character varying NOT NULL DEFAULT 'en', - "chat_id" uuid NOT NULL, - PRIMARY KEY ("id"), - CONSTRAINT "chat_settings_chats_settings" FOREIGN KEY ("chat_id") REFERENCES "chats" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION -); --- create index "chat_settings_chat_id_key" to table: "chat_settings" -CREATE UNIQUE INDEX "chat_settings_chat_id_key" ON "chat_settings" ("chat_id"); --- create "channels" table -CREATE TABLE "channels" -( - "id" uuid NOT NULL, - "channel_id" character varying NOT NULL, - "service" character varying NOT NULL, - "is_live" boolean NOT NULL DEFAULT false, - "title" character varying NULL, - "category" character varying NULL, - "updated_at" timestamptz NULL, - PRIMARY KEY ("id") -); --- create index "channel_channel_id_service" to table: "channels" -CREATE UNIQUE INDEX "channel_channel_id_service" ON "channels" ("channel_id", "service"); --- create "follows" table -CREATE TABLE "follows" -( - "id" uuid NOT NULL, - "channel_id" uuid NOT NULL, - "chat_id" uuid NOT NULL, - PRIMARY KEY ("id"), - CONSTRAINT "follows_channels_follows" FOREIGN KEY ("channel_id") REFERENCES "channels" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION, - CONSTRAINT "follows_chats_follows" FOREIGN KEY ("chat_id") REFERENCES "chats" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION -); --- create index "follow_channel_id_chat_id" to table: "follows" -CREATE UNIQUE INDEX "follow_channel_id_chat_id" ON "follows" ("channel_id", "chat_id"); --- create "streams" table -CREATE TABLE "streams" -( - "id" character varying NOT NULL, - "titles" text[] NULL, - "categories" text[] NULL, - "started_at" timestamptz NULL, - "updated_at" timestamptz NULL, - "ended_at" timestamptz NULL, - "channel_id" uuid NOT NULL, - PRIMARY KEY ("id"), - CONSTRAINT "streams_channels_streams" FOREIGN KEY ("channel_id") REFERENCES "channels" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION -); diff --git a/ent/migrate/migrations/20230401125338_TitleChangeNotification.sql b/ent/migrate/migrations/20230401125338_TitleChangeNotification.sql deleted file mode 100644 index 1310ab25..00000000 --- a/ent/migrate/migrations/20230401125338_TitleChangeNotification.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Modify "chat_settings" table -ALTER TABLE "chat_settings" ADD COLUMN "title_change_notification" boolean NOT NULL DEFAULT false; diff --git a/ent/migrate/migrations/20230506114213_EnableImageInNotification.sql b/ent/migrate/migrations/20230506114213_EnableImageInNotification.sql deleted file mode 100644 index 1347b67a..00000000 --- a/ent/migrate/migrations/20230506114213_EnableImageInNotification.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Modify "chat_settings" table -ALTER TABLE "chat_settings" ADD COLUMN "image_in_notification" boolean NOT NULL DEFAULT true; diff --git a/ent/migrate/migrations/20230521162457_GameAndTitleChangeNotificationSetting.sql b/ent/migrate/migrations/20230521162457_GameAndTitleChangeNotificationSetting.sql deleted file mode 100644 index 4a4a78f1..00000000 --- a/ent/migrate/migrations/20230521162457_GameAndTitleChangeNotificationSetting.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Modify "chat_settings" table -ALTER TABLE "chat_settings" ADD COLUMN "game_and_title_change_notification" boolean NOT NULL DEFAULT false; diff --git a/ent/migrate/migrations/atlas.sum b/ent/migrate/migrations/atlas.sum deleted file mode 100644 index 63de8f2f..00000000 --- a/ent/migrate/migrations/atlas.sum +++ /dev/null @@ -1,5 +0,0 @@ -h1:5dIiqHm4gM6G6fF/AjBxNMcJBJYgK25xeMdiqHT0XMk= -20230327181912_initial.sql h1:L6nniWh3O35p6lwgIG+NrRkkU2n2iG48Be5/zfPAz0I= -20230401125338_TitleChangeNotification.sql h1:9u5qCYBNNL6RHtLdcW691tRhYyli7/pG2kBxYuHs1fA= -20230506114213_EnableImageInNotification.sql h1:cGbFYJRVhaB3swtBPyOmw627aemnTVRd6HByCSJa0OQ= -20230521162457_GameAndTitleChangeNotificationSetting.sql h1:g1bUjbU8y2uGqbiDh89URK3QWv+XBRYC4rwsW6Zu408= diff --git a/ent/schema/channel.go b/ent/schema/channel.go deleted file mode 100644 index 11764532..00000000 --- a/ent/schema/channel.go +++ /dev/null @@ -1,50 +0,0 @@ -package schema - -import ( - "entgo.io/ent" - "entgo.io/ent/schema/edge" - "entgo.io/ent/schema/field" - "entgo.io/ent/schema/index" - "github.com/google/uuid" - "time" -) - -type Channel struct { - ent.Schema -} - -type ChannelService string - -func (c ChannelService) String() string { - return string(c) -} - -const ( - Twitch ChannelService = "twitch" -) - -func (Channel) Fields() []ent.Field { - return []ent.Field{ - field.UUID("id", uuid.UUID{}).Default(uuid.New), - field.String("channel_id"), - field.Enum("service").Values(Twitch.String()), - field.Bool("is_live").Default(false), - field.String("title").Nillable().Optional(), - field.String("category").Nillable().Optional(), - field.Time("updated_at").Nillable().Optional().Default(nil).UpdateDefault(time.Now().UTC), - } -} - -func (Channel) Indexes() []ent.Index { - return []ent.Index{ - index.Fields("channel_id", "service"). - Unique(), - } -} - -func (Channel) Edges() []ent.Edge { - return []ent.Edge{ - edge.To("follows", Follow.Type), - edge.To("streams", Stream.Type), - } -} diff --git a/ent/schema/chat.go b/ent/schema/chat.go deleted file mode 100644 index 3bd9378e..00000000 --- a/ent/schema/chat.go +++ /dev/null @@ -1,50 +0,0 @@ -package schema - -import ( - "entgo.io/ent" - "entgo.io/ent/schema/edge" - "entgo.io/ent/schema/field" - "entgo.io/ent/schema/index" - "github.com/google/uuid" -) - -type Chat struct { - ent.Schema -} - -type ChatService string - -func (c ChatService) String() string { - return string(c) -} - -func (ChatService) Values() []string { - return []string{Telegram.String()} -} - -const ( - Telegram ChatService = "telegram" -) - -func (Chat) Fields() []ent.Field { - return []ent.Field{ - field.UUID("id", uuid.UUID{}).Default(uuid.New), - field.String("chat_id"), - field.Enum("service").Values(Telegram.String()), - } -} - -func (Chat) Indexes() []ent.Index { - return []ent.Index{ - index.Fields("chat_id", "service"). - Unique(), - } -} - -func (Chat) Edges() []ent.Edge { - return []ent.Edge{ - edge.To("settings", ChatSettings.Type).Unique(), - //edge.To("id", ChatSettings.Type).Unique().Required(), - edge.To("follows", Follow.Type), - } -} diff --git a/ent/schema/chat_settings.go b/ent/schema/chat_settings.go deleted file mode 100644 index 808b7bb2..00000000 --- a/ent/schema/chat_settings.go +++ /dev/null @@ -1,49 +0,0 @@ -package schema - -import ( - "entgo.io/ent" - "entgo.io/ent/schema/edge" - "entgo.io/ent/schema/field" - "github.com/google/uuid" -) - -type ChatSettings struct { - ent.Schema -} - -type ChatLanguage string - -const ( - ChatLanguageRu ChatLanguage = "ru" - ChatLanguageEn ChatLanguage = "en" - ChatLanguageUk ChatLanguage = "uk" -) - -func (c ChatLanguage) String() string { - return string(c) -} - -func (ChatSettings) Fields() []ent.Field { - return []ent.Field{ - field.UUID("id", uuid.UUID{}).Default(uuid.New), - field.Bool("game_change_notification").Default(true), - field.Bool("title_change_notification").Default(false), - field.Bool("game_and_title_change_notification").Default(false), - field.Bool("offline_notification").Default(true), - field.Bool("image_in_notification").Default(true), - field.Enum("chat_language"). - Values(ChatLanguageRu.String(), ChatLanguageEn.String(), ChatLanguageUk.String()). - Default(ChatLanguageEn.String()), - field.UUID("chat_id", uuid.UUID{}), - } -} - -func (ChatSettings) Edges() []ent.Edge { - return []ent.Edge{ - edge.From("chat", Chat.Type). - Ref("settings"). - Unique(). - Field("chat_id"). - Required(), - } -} diff --git a/ent/schema/follow.go b/ent/schema/follow.go deleted file mode 100644 index c49e10c2..00000000 --- a/ent/schema/follow.go +++ /dev/null @@ -1,43 +0,0 @@ -package schema - -import ( - "entgo.io/ent" - "entgo.io/ent/schema/edge" - "entgo.io/ent/schema/field" - "entgo.io/ent/schema/index" - "github.com/google/uuid" -) - -type Follow struct { - ent.Schema -} - -func (Follow) Fields() []ent.Field { - return []ent.Field{ - field.UUID("id", uuid.UUID{}).Default(uuid.New), - field.UUID("channel_id", uuid.UUID{}), - field.UUID("chat_id", uuid.UUID{}), - } -} - -func (Follow) Edges() []ent.Edge { - return []ent.Edge{ - edge.From("channel", Channel.Type). - Required(). - Ref("follows"). - Unique(). - Field("channel_id"), - edge.From("chat", Chat.Type). - Required(). - Ref("follows"). - Unique(). - Field("chat_id"), - } -} - -func (Follow) Indexes() []ent.Index { - return []ent.Index{ - index.Fields("channel_id", "chat_id"). - Unique(), - } -} diff --git a/ent/schema/stream.go b/ent/schema/stream.go deleted file mode 100644 index 2d8681b4..00000000 --- a/ent/schema/stream.go +++ /dev/null @@ -1,60 +0,0 @@ -package schema - -import ( - "entgo.io/ent" - "entgo.io/ent/dialect" - "entgo.io/ent/schema/edge" - "entgo.io/ent/schema/field" - "github.com/google/uuid" - "github.com/lib/pq" - "time" -) - -type Stream struct { - ent.Schema -} - -func (Stream) Fields() []ent.Field { - return []ent.Field{ - field.String("id").Unique().Immutable(), - field.UUID("channel_id", uuid.UUID{}), - - field.Other("titles", pq.StringArray{}). - SchemaType(map[string]string{ - dialect.Postgres: "text[]", - dialect.SQLite: "JSON", - }). - Default(pq.StringArray{}). - Optional(), - //SchemaType(map[string]string{ - // "postgres": "text[]", - // "sqlite": "text[]", - //}), - field.Other("categories", pq.StringArray{}). - SchemaType(map[string]string{ - dialect.Postgres: "text[]", - dialect.SQLite: "JSON", - }). - Default(pq.StringArray{}). - Optional(), - - //SchemaType(map[string]string{ - // "postgres": "text[]", - // "sqlite": "text[]", - //}), - - field.Time("started_at").Optional().Default(time.Now().UTC), - field.Time("updated_at").Nillable().Optional().Default(nil).UpdateDefault(time.Now().UTC), - field.Time("ended_at").Nillable().Optional().Default(nil), - } -} - -func (Stream) Edges() []ent.Edge { - return []ent.Edge{ - edge.From("channel", Channel.Type). - Ref("streams"). - Required(). - Unique(). - Field("channel_id"), - } -} diff --git a/go.mod b/go.mod index bda9fbf8..1e9a7dee 100644 --- a/go.mod +++ b/go.mod @@ -3,43 +3,66 @@ module github.com/satont/twitch-notifier go 1.21 require ( - entgo.io/ent v0.12.5 - github.com/TheZeroSlave/zapsentry v1.19.0 - github.com/getsentry/sentry-go v0.25.0 + github.com/Masterminds/squirrel v1.5.4 + github.com/getsentry/sentry-go v0.22.0 github.com/google/uuid v1.4.0 - github.com/hashicorp/go-retryablehttp v0.7.5 - github.com/joho/godotenv v1.5.1 - github.com/kelseyhightower/envconfig v1.4.0 - github.com/lib/pq v1.10.9 - github.com/mattn/go-sqlite3 v1.14.16 - github.com/mr-linch/go-tg v0.11.0 - github.com/nicklaw5/helix/v2 v2.25.2 - github.com/samber/lo v1.38.1 - github.com/sourcegraph/conc v0.3.0 + github.com/jackc/pgx/v5 v5.5.1 + github.com/rs/zerolog v1.31.0 + github.com/samber/slog-multi v1.0.2 + github.com/samber/slog-sentry/v2 v2.2.1 + github.com/samber/slog-zerolog/v2 v2.1.0 github.com/stretchr/testify v1.8.4 - go.uber.org/zap v1.26.0 + go.temporal.io/sdk v1.25.1 + go.uber.org/fx v1.20.1 + go.uber.org/mock v0.4.0 + gopkg.in/guregu/null.v4 v4.0.0 ) require ( - ariga.io/atlas v0.15.0 // indirect - github.com/agext/levenshtein v1.2.3 // indirect - github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/go-openapi/inflect v0.19.0 // indirect + github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect + github.com/gogo/googleapis v1.4.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/gogo/status v1.1.1 // indirect github.com/golang-jwt/jwt/v4 v4.5.0 // indirect - github.com/google/go-cmp v0.6.0 // indirect - github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/hcl/v2 v2.19.1 // indirect - github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/golang/mock v1.6.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect + github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/nicklaw5/helix/v2 v2.25.2 // indirect + github.com/pborman/uuid v1.2.1 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/objx v0.5.1 // indirect - github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce // indirect - github.com/zclconf/go-cty v1.14.1 // indirect + github.com/robfig/cron v1.2.0 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/samber/lo v1.38.1 // indirect + github.com/samber/slog-common v0.11.0 // indirect + github.com/stretchr/objx v0.5.0 // indirect + go.temporal.io/api v1.24.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/dig v1.17.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect - golang.org/x/mod v0.14.0 // indirect + go.uber.org/zap v1.26.0 // indirect + golang.org/x/crypto v0.12.0 // indirect + golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect + golang.org/x/mod v0.11.0 // indirect + golang.org/x/net v0.14.0 // indirect + golang.org/x/sync v0.2.0 // indirect golang.org/x/sys v0.14.0 // indirect golang.org/x/text v0.14.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + golang.org/x/time v0.3.0 // indirect + golang.org/x/tools v0.9.1 // indirect + google.golang.org/genproto v0.0.0-20230815205213-6bfd019c3878 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20230815205213-6bfd019c3878 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20230815205213-6bfd019c3878 // indirect + google.golang.org/grpc v1.57.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 81d31621..48e42ac5 100644 --- a/go.sum +++ b/go.sum @@ -1,44 +1,1005 @@ -ariga.io/atlas v0.15.0 h1:9lwSVcO/D3WgaCzstSGqR1hEDtsGibu6JqUofEI/0sY= -ariga.io/atlas v0.15.0/go.mod h1:isZrlzJ5cpoCoKFoY9knZug7Lq4pP1cm8g3XciLZ0Pw= -entgo.io/ent v0.12.5 h1:KREM5E4CSoej4zeGa88Ou/gfturAnpUv0mzAjch1sj4= -entgo.io/ent v0.12.5/go.mod h1:Y3JVAjtlIk8xVZYSn3t3mf8xlZIn5SAOXZQxD6kKI+Q= -github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= -github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= -github.com/TheZeroSlave/zapsentry v1.19.0 h1:/FVdMrq/w7bYt98m49ImZgmCTybXWbGc8/hOT0nLmyc= -github.com/TheZeroSlave/zapsentry v1.19.0/go.mod h1:D1YMfSuu6xnkhwFXxrronesmsiyDhIqo+86I3Ok+r64= -github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= -github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= -github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= -github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +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/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= +cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= +cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= +cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= +cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY= +cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM= +cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY= +cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ= +cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= +cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= +cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= +cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= +cloud.google.com/go v0.100.1/go.mod h1:fs4QogzfH5n2pBXBP9vRiU+eCny7lD2vmFZy79Iuw1U= +cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A= +cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc= +cloud.google.com/go v0.102.1/go.mod h1:XZ77E9qnTEnrgEOvr4xzfdX5TRo7fB4T2F4O6+34hIU= +cloud.google.com/go v0.104.0/go.mod h1:OO6xxXdJyvuJPcEPBLN9BJPD+jep5G1+2U5B5gkRYtA= +cloud.google.com/go v0.105.0/go.mod h1:PrLgOJNe5nfE9UMxKxgXj4mD3voiP+YQ6gdt6KMFOKM= +cloud.google.com/go v0.107.0/go.mod h1:wpc2eNrD7hXUTy8EKS10jkxpZBjASrORK7goS+3YX2I= +cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY= +cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw= +cloud.google.com/go v0.110.4/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5xsI= +cloud.google.com/go v0.110.6/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5xsI= +cloud.google.com/go v0.110.7/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5xsI= +cloud.google.com/go/accessapproval v1.4.0/go.mod h1:zybIuC3KpDOvotz59lFe5qxRZx6C75OtwbisN56xYB4= +cloud.google.com/go/accessapproval v1.5.0/go.mod h1:HFy3tuiGvMdcd/u+Cu5b9NkO1pEICJ46IR82PoUdplw= +cloud.google.com/go/accessapproval v1.6.0/go.mod h1:R0EiYnwV5fsRFiKZkPHr6mwyk2wxUJ30nL4j2pcFY2E= +cloud.google.com/go/accessapproval v1.7.1/go.mod h1:JYczztsHRMK7NTXb6Xw+dwbs/WnOJxbo/2mTI+Kgg68= +cloud.google.com/go/accesscontextmanager v1.3.0/go.mod h1:TgCBehyr5gNMz7ZaH9xubp+CE8dkrszb4oK9CWyvD4o= +cloud.google.com/go/accesscontextmanager v1.4.0/go.mod h1:/Kjh7BBu/Gh83sv+K60vN9QE5NJcd80sU33vIe2IFPE= +cloud.google.com/go/accesscontextmanager v1.6.0/go.mod h1:8XCvZWfYw3K/ji0iVnp+6pu7huxoQTLmxAbVjbloTtM= +cloud.google.com/go/accesscontextmanager v1.7.0/go.mod h1:CEGLewx8dwa33aDAZQujl7Dx+uYhS0eay198wB/VumQ= +cloud.google.com/go/accesscontextmanager v1.8.0/go.mod h1:uI+AI/r1oyWK99NN8cQ3UK76AMelMzgZCvJfsi2c+ps= +cloud.google.com/go/accesscontextmanager v1.8.1/go.mod h1:JFJHfvuaTC+++1iL1coPiG1eu5D24db2wXCDWDjIrxo= +cloud.google.com/go/aiplatform v1.22.0/go.mod h1:ig5Nct50bZlzV6NvKaTwmplLLddFx0YReh9WfTO5jKw= +cloud.google.com/go/aiplatform v1.24.0/go.mod h1:67UUvRBKG6GTayHKV8DBv2RtR1t93YRu5B1P3x99mYY= +cloud.google.com/go/aiplatform v1.27.0/go.mod h1:Bvxqtl40l0WImSb04d0hXFU7gDOiq9jQmorivIiWcKg= +cloud.google.com/go/aiplatform v1.35.0/go.mod h1:7MFT/vCaOyZT/4IIFfxH4ErVg/4ku6lKv3w0+tFTgXQ= +cloud.google.com/go/aiplatform v1.36.1/go.mod h1:WTm12vJRPARNvJ+v6P52RDHCNe4AhvjcIZ/9/RRHy/k= +cloud.google.com/go/aiplatform v1.37.0/go.mod h1:IU2Cv29Lv9oCn/9LkFiiuKfwrRTq+QQMbW+hPCxJGZw= +cloud.google.com/go/aiplatform v1.45.0/go.mod h1:Iu2Q7sC7QGhXUeOhAj/oCK9a+ULz1O4AotZiqjQ8MYA= +cloud.google.com/go/aiplatform v1.48.0/go.mod h1:Iu2Q7sC7QGhXUeOhAj/oCK9a+ULz1O4AotZiqjQ8MYA= +cloud.google.com/go/analytics v0.11.0/go.mod h1:DjEWCu41bVbYcKyvlws9Er60YE4a//bK6mnhWvQeFNI= +cloud.google.com/go/analytics v0.12.0/go.mod h1:gkfj9h6XRf9+TS4bmuhPEShsh3hH8PAZzm/41OOhQd4= +cloud.google.com/go/analytics v0.17.0/go.mod h1:WXFa3WSym4IZ+JiKmavYdJwGG/CvpqiqczmL59bTD9M= +cloud.google.com/go/analytics v0.18.0/go.mod h1:ZkeHGQlcIPkw0R/GW+boWHhCOR43xz9RN/jn7WcqfIE= +cloud.google.com/go/analytics v0.19.0/go.mod h1:k8liqf5/HCnOUkbawNtrWWc+UAzyDlW89doe8TtoDsE= +cloud.google.com/go/analytics v0.21.2/go.mod h1:U8dcUtmDmjrmUTnnnRnI4m6zKn/yaA5N9RlEkYFHpQo= +cloud.google.com/go/analytics v0.21.3/go.mod h1:U8dcUtmDmjrmUTnnnRnI4m6zKn/yaA5N9RlEkYFHpQo= +cloud.google.com/go/apigateway v1.3.0/go.mod h1:89Z8Bhpmxu6AmUxuVRg/ECRGReEdiP3vQtk4Z1J9rJk= +cloud.google.com/go/apigateway v1.4.0/go.mod h1:pHVY9MKGaH9PQ3pJ4YLzoj6U5FUDeDFBllIz7WmzJoc= +cloud.google.com/go/apigateway v1.5.0/go.mod h1:GpnZR3Q4rR7LVu5951qfXPJCHquZt02jf7xQx7kpqN8= +cloud.google.com/go/apigateway v1.6.1/go.mod h1:ufAS3wpbRjqfZrzpvLC2oh0MFlpRJm2E/ts25yyqmXA= +cloud.google.com/go/apigeeconnect v1.3.0/go.mod h1:G/AwXFAKo0gIXkPTVfZDd2qA1TxBXJ3MgMRBQkIi9jc= +cloud.google.com/go/apigeeconnect v1.4.0/go.mod h1:kV4NwOKqjvt2JYR0AoIWo2QGfoRtn/pkS3QlHp0Ni04= +cloud.google.com/go/apigeeconnect v1.5.0/go.mod h1:KFaCqvBRU6idyhSNyn3vlHXc8VMDJdRmwDF6JyFRqZ8= +cloud.google.com/go/apigeeconnect v1.6.1/go.mod h1:C4awq7x0JpLtrlQCr8AzVIzAaYgngRqWf9S5Uhg+wWs= +cloud.google.com/go/apigeeregistry v0.4.0/go.mod h1:EUG4PGcsZvxOXAdyEghIdXwAEi/4MEaoqLMLDMIwKXY= +cloud.google.com/go/apigeeregistry v0.5.0/go.mod h1:YR5+s0BVNZfVOUkMa5pAR2xGd0A473vA5M7j247o1wM= +cloud.google.com/go/apigeeregistry v0.6.0/go.mod h1:BFNzW7yQVLZ3yj0TKcwzb8n25CFBri51GVGOEUcgQsc= +cloud.google.com/go/apigeeregistry v0.7.1/go.mod h1:1XgyjZye4Mqtw7T9TsY4NW10U7BojBvG4RMD+vRDrIw= +cloud.google.com/go/apikeys v0.4.0/go.mod h1:XATS/yqZbaBK0HOssf+ALHp8jAlNHUgyfprvNcBIszU= +cloud.google.com/go/apikeys v0.5.0/go.mod h1:5aQfwY4D+ewMMWScd3hm2en3hCj+BROlyrt3ytS7KLI= +cloud.google.com/go/apikeys v0.6.0/go.mod h1:kbpXu5upyiAlGkKrJgQl8A0rKNNJ7dQ377pdroRSSi8= +cloud.google.com/go/appengine v1.4.0/go.mod h1:CS2NhuBuDXM9f+qscZ6V86m1MIIqPj3WC/UoEuR1Sno= +cloud.google.com/go/appengine v1.5.0/go.mod h1:TfasSozdkFI0zeoxW3PTBLiNqRmzraodCWatWI9Dmak= +cloud.google.com/go/appengine v1.6.0/go.mod h1:hg6i0J/BD2cKmDJbaFSYHFyZkgBEfQrDg/X0V5fJn84= +cloud.google.com/go/appengine v1.7.0/go.mod h1:eZqpbHFCqRGa2aCdope7eC0SWLV1j0neb/QnMJVWx6A= +cloud.google.com/go/appengine v1.7.1/go.mod h1:IHLToyb/3fKutRysUlFO0BPt5j7RiQ45nrzEJmKTo6E= +cloud.google.com/go/appengine v1.8.1/go.mod h1:6NJXGLVhZCN9aQ/AEDvmfzKEfoYBlfB80/BHiKVputY= +cloud.google.com/go/area120 v0.5.0/go.mod h1:DE/n4mp+iqVyvxHN41Vf1CR602GiHQjFPusMFW6bGR4= +cloud.google.com/go/area120 v0.6.0/go.mod h1:39yFJqWVgm0UZqWTOdqkLhjoC7uFfgXRC8g/ZegeAh0= +cloud.google.com/go/area120 v0.7.0/go.mod h1:a3+8EUD1SX5RUcCs3MY5YasiO1z6yLiNLRiFrykbynY= +cloud.google.com/go/area120 v0.7.1/go.mod h1:j84i4E1RboTWjKtZVWXPqvK5VHQFJRF2c1Nm69pWm9k= +cloud.google.com/go/area120 v0.8.1/go.mod h1:BVfZpGpB7KFVNxPiQBuHkX6Ed0rS51xIgmGyjrAfzsg= +cloud.google.com/go/artifactregistry v1.6.0/go.mod h1:IYt0oBPSAGYj/kprzsBjZ/4LnG/zOcHyFHjWPCi6SAQ= +cloud.google.com/go/artifactregistry v1.7.0/go.mod h1:mqTOFOnGZx8EtSqK/ZWcsm/4U8B77rbcLP6ruDU2Ixk= +cloud.google.com/go/artifactregistry v1.8.0/go.mod h1:w3GQXkJX8hiKN0v+at4b0qotwijQbYUqF2GWkZzAhC0= +cloud.google.com/go/artifactregistry v1.9.0/go.mod h1:2K2RqvA2CYvAeARHRkLDhMDJ3OXy26h3XW+3/Jh2uYc= +cloud.google.com/go/artifactregistry v1.11.1/go.mod h1:lLYghw+Itq9SONbCa1YWBoWs1nOucMH0pwXN1rOBZFI= +cloud.google.com/go/artifactregistry v1.11.2/go.mod h1:nLZns771ZGAwVLzTX/7Al6R9ehma4WUEhZGWV6CeQNQ= +cloud.google.com/go/artifactregistry v1.12.0/go.mod h1:o6P3MIvtzTOnmvGagO9v/rOjjA0HmhJ+/6KAXrmYDCI= +cloud.google.com/go/artifactregistry v1.13.0/go.mod h1:uy/LNfoOIivepGhooAUpL1i30Hgee3Cu0l4VTWHUC08= +cloud.google.com/go/artifactregistry v1.14.1/go.mod h1:nxVdG19jTaSTu7yA7+VbWL346r3rIdkZ142BSQqhn5E= +cloud.google.com/go/asset v1.5.0/go.mod h1:5mfs8UvcM5wHhqtSv8J1CtxxaQq3AdBxxQi2jGW/K4o= +cloud.google.com/go/asset v1.7.0/go.mod h1:YbENsRK4+xTiL+Ofoj5Ckf+O17kJtgp3Y3nn4uzZz5s= +cloud.google.com/go/asset v1.8.0/go.mod h1:mUNGKhiqIdbr8X7KNayoYvyc4HbbFO9URsjbytpUaW0= +cloud.google.com/go/asset v1.9.0/go.mod h1:83MOE6jEJBMqFKadM9NLRcs80Gdw76qGuHn8m3h8oHQ= +cloud.google.com/go/asset v1.10.0/go.mod h1:pLz7uokL80qKhzKr4xXGvBQXnzHn5evJAEAtZiIb0wY= +cloud.google.com/go/asset v1.11.1/go.mod h1:fSwLhbRvC9p9CXQHJ3BgFeQNM4c9x10lqlrdEUYXlJo= +cloud.google.com/go/asset v1.12.0/go.mod h1:h9/sFOa4eDIyKmH6QMpm4eUK3pDojWnUhTgJlk762Hg= +cloud.google.com/go/asset v1.13.0/go.mod h1:WQAMyYek/b7NBpYq/K4KJWcRqzoalEsxz/t/dTk4THw= +cloud.google.com/go/asset v1.14.1/go.mod h1:4bEJ3dnHCqWCDbWJ/6Vn7GVI9LerSi7Rfdi03hd+WTQ= +cloud.google.com/go/assuredworkloads v1.5.0/go.mod h1:n8HOZ6pff6re5KYfBXcFvSViQjDwxFkAkmUFffJRbbY= +cloud.google.com/go/assuredworkloads v1.6.0/go.mod h1:yo2YOk37Yc89Rsd5QMVECvjaMKymF9OP+QXWlKXUkXw= +cloud.google.com/go/assuredworkloads v1.7.0/go.mod h1:z/736/oNmtGAyU47reJgGN+KVoYoxeLBoj4XkKYscNI= +cloud.google.com/go/assuredworkloads v1.8.0/go.mod h1:AsX2cqyNCOvEQC8RMPnoc0yEarXQk6WEKkxYfL6kGIo= +cloud.google.com/go/assuredworkloads v1.9.0/go.mod h1:kFuI1P78bplYtT77Tb1hi0FMxM0vVpRC7VVoJC3ZoT0= +cloud.google.com/go/assuredworkloads v1.10.0/go.mod h1:kwdUQuXcedVdsIaKgKTp9t0UJkE5+PAVNhdQm4ZVq2E= +cloud.google.com/go/assuredworkloads v1.11.1/go.mod h1:+F04I52Pgn5nmPG36CWFtxmav6+7Q+c5QyJoL18Lry0= +cloud.google.com/go/automl v1.5.0/go.mod h1:34EjfoFGMZ5sgJ9EoLsRtdPSNZLcfflJR39VbVNS2M0= +cloud.google.com/go/automl v1.6.0/go.mod h1:ugf8a6Fx+zP0D59WLhqgTDsQI9w07o64uf/Is3Nh5p8= +cloud.google.com/go/automl v1.7.0/go.mod h1:RL9MYCCsJEOmt0Wf3z9uzG0a7adTT1fe+aObgSpkCt8= +cloud.google.com/go/automl v1.8.0/go.mod h1:xWx7G/aPEe/NP+qzYXktoBSDfjO+vnKMGgsApGJJquM= +cloud.google.com/go/automl v1.12.0/go.mod h1:tWDcHDp86aMIuHmyvjuKeeHEGq76lD7ZqfGLN6B0NuU= +cloud.google.com/go/automl v1.13.1/go.mod h1:1aowgAHWYZU27MybSCFiukPO7xnyawv7pt3zK4bheQE= +cloud.google.com/go/baremetalsolution v0.3.0/go.mod h1:XOrocE+pvK1xFfleEnShBlNAXf+j5blPPxrhjKgnIFc= +cloud.google.com/go/baremetalsolution v0.4.0/go.mod h1:BymplhAadOO/eBa7KewQ0Ppg4A4Wplbn+PsFKRLo0uI= +cloud.google.com/go/baremetalsolution v0.5.0/go.mod h1:dXGxEkmR9BMwxhzBhV0AioD0ULBmuLZI8CdwalUxuss= +cloud.google.com/go/baremetalsolution v1.1.1/go.mod h1:D1AV6xwOksJMV4OSlWHtWuFNZZYujJknMAP4Qa27QIA= +cloud.google.com/go/batch v0.3.0/go.mod h1:TR18ZoAekj1GuirsUsR1ZTKN3FC/4UDnScjT8NXImFE= +cloud.google.com/go/batch v0.4.0/go.mod h1:WZkHnP43R/QCGQsZ+0JyG4i79ranE2u8xvjq/9+STPE= +cloud.google.com/go/batch v0.7.0/go.mod h1:vLZN95s6teRUqRQ4s3RLDsH8PvboqBK+rn1oevL159g= +cloud.google.com/go/batch v1.3.1/go.mod h1:VguXeQKXIYaeeIYbuozUmBR13AfL4SJP7IltNPS+A4A= +cloud.google.com/go/beyondcorp v0.2.0/go.mod h1:TB7Bd+EEtcw9PCPQhCJtJGjk/7TC6ckmnSFS+xwTfm4= +cloud.google.com/go/beyondcorp v0.3.0/go.mod h1:E5U5lcrcXMsCuoDNyGrpyTm/hn7ne941Jz2vmksAxW8= +cloud.google.com/go/beyondcorp v0.4.0/go.mod h1:3ApA0mbhHx6YImmuubf5pyW8srKnCEPON32/5hj+RmM= +cloud.google.com/go/beyondcorp v0.5.0/go.mod h1:uFqj9X+dSfrheVp7ssLTaRHd2EHqSL4QZmH4e8WXGGU= +cloud.google.com/go/beyondcorp v0.6.1/go.mod h1:YhxDWw946SCbmcWo3fAhw3V4XZMSpQ/VYfcKGAEU8/4= +cloud.google.com/go/beyondcorp v1.0.0/go.mod h1:YhxDWw946SCbmcWo3fAhw3V4XZMSpQ/VYfcKGAEU8/4= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/bigquery v1.42.0/go.mod h1:8dRTJxhtG+vwBKzE5OseQn/hiydoQN3EedCaOdYmxRA= +cloud.google.com/go/bigquery v1.43.0/go.mod h1:ZMQcXHsl+xmU1z36G2jNGZmKp9zNY5BUua5wDgmNCfw= +cloud.google.com/go/bigquery v1.44.0/go.mod h1:0Y33VqXTEsbamHJvJHdFmtqHvMIY28aK1+dFsvaChGc= +cloud.google.com/go/bigquery v1.47.0/go.mod h1:sA9XOgy0A8vQK9+MWhEQTY6Tix87M/ZurWFIxmF9I/E= +cloud.google.com/go/bigquery v1.48.0/go.mod h1:QAwSz+ipNgfL5jxiaK7weyOhzdoAy1zFm0Nf1fysJac= +cloud.google.com/go/bigquery v1.49.0/go.mod h1:Sv8hMmTFFYBlt/ftw2uN6dFdQPzBlREY9yBh7Oy7/4Q= +cloud.google.com/go/bigquery v1.50.0/go.mod h1:YrleYEh2pSEbgTBZYMJ5SuSr0ML3ypjRB1zgf7pvQLU= +cloud.google.com/go/bigquery v1.52.0/go.mod h1:3b/iXjRQGU4nKa87cXeg6/gogLjO8C6PmuM8i5Bi/u4= +cloud.google.com/go/bigquery v1.53.0/go.mod h1:3b/iXjRQGU4nKa87cXeg6/gogLjO8C6PmuM8i5Bi/u4= +cloud.google.com/go/billing v1.4.0/go.mod h1:g9IdKBEFlItS8bTtlrZdVLWSSdSyFUZKXNS02zKMOZY= +cloud.google.com/go/billing v1.5.0/go.mod h1:mztb1tBc3QekhjSgmpf/CV4LzWXLzCArwpLmP2Gm88s= +cloud.google.com/go/billing v1.6.0/go.mod h1:WoXzguj+BeHXPbKfNWkqVtDdzORazmCjraY+vrxcyvI= +cloud.google.com/go/billing v1.7.0/go.mod h1:q457N3Hbj9lYwwRbnlD7vUpyjq6u5U1RAOArInEiD5Y= +cloud.google.com/go/billing v1.12.0/go.mod h1:yKrZio/eu+okO/2McZEbch17O5CB5NpZhhXG6Z766ss= +cloud.google.com/go/billing v1.13.0/go.mod h1:7kB2W9Xf98hP9Sr12KfECgfGclsH3CQR0R08tnRlRbc= +cloud.google.com/go/billing v1.16.0/go.mod h1:y8vx09JSSJG02k5QxbycNRrN7FGZB6F3CAcgum7jvGA= +cloud.google.com/go/binaryauthorization v1.1.0/go.mod h1:xwnoWu3Y84jbuHa0zd526MJYmtnVXn0syOjaJgy4+dM= +cloud.google.com/go/binaryauthorization v1.2.0/go.mod h1:86WKkJHtRcv5ViNABtYMhhNWRrD1Vpi//uKEy7aYEfI= +cloud.google.com/go/binaryauthorization v1.3.0/go.mod h1:lRZbKgjDIIQvzYQS1p99A7/U1JqvqeZg0wiI5tp6tg0= +cloud.google.com/go/binaryauthorization v1.4.0/go.mod h1:tsSPQrBd77VLplV70GUhBf/Zm3FsKmgSqgm4UmiDItk= +cloud.google.com/go/binaryauthorization v1.5.0/go.mod h1:OSe4OU1nN/VswXKRBmciKpo9LulY41gch5c68htf3/Q= +cloud.google.com/go/binaryauthorization v1.6.1/go.mod h1:TKt4pa8xhowwffiBmbrbcxijJRZED4zrqnwZ1lKH51U= +cloud.google.com/go/certificatemanager v1.3.0/go.mod h1:n6twGDvcUBFu9uBgt4eYvvf3sQ6My8jADcOVwHmzadg= +cloud.google.com/go/certificatemanager v1.4.0/go.mod h1:vowpercVFyqs8ABSmrdV+GiFf2H/ch3KyudYQEMM590= +cloud.google.com/go/certificatemanager v1.6.0/go.mod h1:3Hh64rCKjRAX8dXgRAyOcY5vQ/fE1sh8o+Mdd6KPgY8= +cloud.google.com/go/certificatemanager v1.7.1/go.mod h1:iW8J3nG6SaRYImIa+wXQ0g8IgoofDFRp5UMzaNk1UqI= +cloud.google.com/go/channel v1.8.0/go.mod h1:W5SwCXDJsq/rg3tn3oG0LOxpAo6IMxNa09ngphpSlnk= +cloud.google.com/go/channel v1.9.0/go.mod h1:jcu05W0my9Vx4mt3/rEHpfxc9eKi9XwsdDL8yBMbKUk= +cloud.google.com/go/channel v1.11.0/go.mod h1:IdtI0uWGqhEeatSB62VOoJ8FSUhJ9/+iGkJVqp74CGE= +cloud.google.com/go/channel v1.12.0/go.mod h1:VkxCGKASi4Cq7TbXxlaBezonAYpp1GCnKMY6tnMQnLU= +cloud.google.com/go/channel v1.16.0/go.mod h1:eN/q1PFSl5gyu0dYdmxNXscY/4Fi7ABmeHCJNf/oHmc= +cloud.google.com/go/cloudbuild v1.3.0/go.mod h1:WequR4ULxlqvMsjDEEEFnOG5ZSRSgWOywXYDb1vPE6U= +cloud.google.com/go/cloudbuild v1.4.0/go.mod h1:5Qwa40LHiOXmz3386FrjrYM93rM/hdRr7b53sySrTqA= +cloud.google.com/go/cloudbuild v1.6.0/go.mod h1:UIbc/w9QCbH12xX+ezUsgblrWv+Cv4Tw83GiSMHOn9M= +cloud.google.com/go/cloudbuild v1.7.0/go.mod h1:zb5tWh2XI6lR9zQmsm1VRA+7OCuve5d8S+zJUul8KTg= +cloud.google.com/go/cloudbuild v1.9.0/go.mod h1:qK1d7s4QlO0VwfYn5YuClDGg2hfmLZEb4wQGAbIgL1s= +cloud.google.com/go/cloudbuild v1.10.1/go.mod h1:lyJg7v97SUIPq4RC2sGsz/9tNczhyv2AjML/ci4ulzU= +cloud.google.com/go/cloudbuild v1.13.0/go.mod h1:lyJg7v97SUIPq4RC2sGsz/9tNczhyv2AjML/ci4ulzU= +cloud.google.com/go/clouddms v1.3.0/go.mod h1:oK6XsCDdW4Ib3jCCBugx+gVjevp2TMXFtgxvPSee3OM= +cloud.google.com/go/clouddms v1.4.0/go.mod h1:Eh7sUGCC+aKry14O1NRljhjyrr0NFC0G2cjwX0cByRk= +cloud.google.com/go/clouddms v1.5.0/go.mod h1:QSxQnhikCLUw13iAbffF2CZxAER3xDGNHjsTAkQJcQA= +cloud.google.com/go/clouddms v1.6.1/go.mod h1:Ygo1vL52Ov4TBZQquhz5fiw2CQ58gvu+PlS6PVXCpZI= +cloud.google.com/go/cloudtasks v1.5.0/go.mod h1:fD92REy1x5woxkKEkLdvavGnPJGEn8Uic9nWuLzqCpY= +cloud.google.com/go/cloudtasks v1.6.0/go.mod h1:C6Io+sxuke9/KNRkbQpihnW93SWDU3uXt92nu85HkYI= +cloud.google.com/go/cloudtasks v1.7.0/go.mod h1:ImsfdYWwlWNJbdgPIIGJWC+gemEGTBK/SunNQQNCAb4= +cloud.google.com/go/cloudtasks v1.8.0/go.mod h1:gQXUIwCSOI4yPVK7DgTVFiiP0ZW/eQkydWzwVMdHxrI= +cloud.google.com/go/cloudtasks v1.9.0/go.mod h1:w+EyLsVkLWHcOaqNEyvcKAsWp9p29dL6uL9Nst1cI7Y= +cloud.google.com/go/cloudtasks v1.10.0/go.mod h1:NDSoTLkZ3+vExFEWu2UJV1arUyzVDAiZtdWcsUyNwBs= +cloud.google.com/go/cloudtasks v1.11.1/go.mod h1:a9udmnou9KO2iulGscKR0qBYjreuX8oHwpmFsKspEvM= +cloud.google.com/go/cloudtasks v1.12.1/go.mod h1:a9udmnou9KO2iulGscKR0qBYjreuX8oHwpmFsKspEvM= +cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow= +cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM= +cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M= +cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s= +cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU= +cloud.google.com/go/compute v1.7.0/go.mod h1:435lt8av5oL9P3fv1OEzSbSUe+ybHXGMPQHHZWZxy9U= +cloud.google.com/go/compute v1.10.0/go.mod h1:ER5CLbMxl90o2jtNbGSbtfOpQKR0t15FOtRsugnLrlU= +cloud.google.com/go/compute v1.12.0/go.mod h1:e8yNOBcBONZU1vJKCvCoDw/4JQsA0dpM4x/6PIIOocU= +cloud.google.com/go/compute v1.12.1/go.mod h1:e8yNOBcBONZU1vJKCvCoDw/4JQsA0dpM4x/6PIIOocU= +cloud.google.com/go/compute v1.13.0/go.mod h1:5aPTS0cUNMIc1CE546K+Th6weJUNQErARyZtRXDJ8GE= +cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvjxega5vAdo= +cloud.google.com/go/compute v1.15.1/go.mod h1:bjjoF/NtFUrkD/urWfdHaKuOPDR5nWIs63rR+SXhcpA= +cloud.google.com/go/compute v1.18.0/go.mod h1:1X7yHxec2Ga+Ss6jPyjxRxpu2uu7PLgsOVXvgU0yacs= +cloud.google.com/go/compute v1.19.0/go.mod h1:rikpw2y+UMidAe9tISo04EHNOIf42RLYF/q8Bs93scU= +cloud.google.com/go/compute v1.19.1/go.mod h1:6ylj3a05WF8leseCdIf77NK0g1ey+nj5IKd5/kvShxE= +cloud.google.com/go/compute v1.19.3/go.mod h1:qxvISKp/gYnXkSAD1ppcSOveRAmzxicEv/JlizULFrI= +cloud.google.com/go/compute v1.20.1/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= +cloud.google.com/go/compute v1.23.0/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= +cloud.google.com/go/compute/metadata v0.1.0/go.mod h1:Z1VN+bulIf6bt4P/C37K4DyZYZEXYonfTBHHFPO/4UU= +cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +cloud.google.com/go/compute/metadata v0.2.1/go.mod h1:jgHgmJd2RKBGzXqF5LR2EZMGxBkeanZ9wwa75XHJgOM= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +cloud.google.com/go/contactcenterinsights v1.3.0/go.mod h1:Eu2oemoePuEFc/xKFPjbTuPSj0fYJcPls9TFlPNnHHY= +cloud.google.com/go/contactcenterinsights v1.4.0/go.mod h1:L2YzkGbPsv+vMQMCADxJoT9YiTTnSEd6fEvCeHTYVck= +cloud.google.com/go/contactcenterinsights v1.6.0/go.mod h1:IIDlT6CLcDoyv79kDv8iWxMSTZhLxSCofVV5W6YFM/w= +cloud.google.com/go/contactcenterinsights v1.9.1/go.mod h1:bsg/R7zGLYMVxFFzfh9ooLTruLRCG9fnzhH9KznHhbM= +cloud.google.com/go/contactcenterinsights v1.10.0/go.mod h1:bsg/R7zGLYMVxFFzfh9ooLTruLRCG9fnzhH9KznHhbM= +cloud.google.com/go/container v1.6.0/go.mod h1:Xazp7GjJSeUYo688S+6J5V+n/t+G5sKBTFkKNudGRxg= +cloud.google.com/go/container v1.7.0/go.mod h1:Dp5AHtmothHGX3DwwIHPgq45Y8KmNsgN3amoYfxVkLo= +cloud.google.com/go/container v1.13.1/go.mod h1:6wgbMPeQRw9rSnKBCAJXnds3Pzj03C4JHamr8asWKy4= +cloud.google.com/go/container v1.14.0/go.mod h1:3AoJMPhHfLDxLvrlVWaK57IXzaPnLaZq63WX59aQBfM= +cloud.google.com/go/container v1.15.0/go.mod h1:ft+9S0WGjAyjDggg5S06DXj+fHJICWg8L7isCQe9pQA= +cloud.google.com/go/container v1.22.1/go.mod h1:lTNExE2R7f+DLbAN+rJiKTisauFCaoDq6NURZ83eVH4= +cloud.google.com/go/container v1.24.0/go.mod h1:lTNExE2R7f+DLbAN+rJiKTisauFCaoDq6NURZ83eVH4= +cloud.google.com/go/containeranalysis v0.5.1/go.mod h1:1D92jd8gRR/c0fGMlymRgxWD3Qw9C1ff6/T7mLgVL8I= +cloud.google.com/go/containeranalysis v0.6.0/go.mod h1:HEJoiEIu+lEXM+k7+qLCci0h33lX3ZqoYFdmPcoO7s4= +cloud.google.com/go/containeranalysis v0.7.0/go.mod h1:9aUL+/vZ55P2CXfuZjS4UjQ9AgXoSw8Ts6lemfmxBxI= +cloud.google.com/go/containeranalysis v0.9.0/go.mod h1:orbOANbwk5Ejoom+s+DUCTTJ7IBdBQJDcSylAx/on9s= +cloud.google.com/go/containeranalysis v0.10.1/go.mod h1:Ya2jiILITMY68ZLPaogjmOMNkwsDrWBSTyBubGXO7j0= +cloud.google.com/go/datacatalog v1.3.0/go.mod h1:g9svFY6tuR+j+hrTw3J2dNcmI0dzmSiyOzm8kpLq0a0= +cloud.google.com/go/datacatalog v1.5.0/go.mod h1:M7GPLNQeLfWqeIm3iuiruhPzkt65+Bx8dAKvScX8jvs= +cloud.google.com/go/datacatalog v1.6.0/go.mod h1:+aEyF8JKg+uXcIdAmmaMUmZ3q1b/lKLtXCmXdnc0lbc= +cloud.google.com/go/datacatalog v1.7.0/go.mod h1:9mEl4AuDYWw81UGc41HonIHH7/sn52H0/tc8f8ZbZIE= +cloud.google.com/go/datacatalog v1.8.0/go.mod h1:KYuoVOv9BM8EYz/4eMFxrr4DUKhGIOXxZoKYF5wdISM= +cloud.google.com/go/datacatalog v1.8.1/go.mod h1:RJ58z4rMp3gvETA465Vg+ag8BGgBdnRPEMMSTr5Uv+M= +cloud.google.com/go/datacatalog v1.12.0/go.mod h1:CWae8rFkfp6LzLumKOnmVh4+Zle4A3NXLzVJ1d1mRm0= +cloud.google.com/go/datacatalog v1.13.0/go.mod h1:E4Rj9a5ZtAxcQJlEBTLgMTphfP11/lNaAshpoBgemX8= +cloud.google.com/go/datacatalog v1.14.0/go.mod h1:h0PrGtlihoutNMp/uvwhawLQ9+c63Kz65UFqh49Yo+E= +cloud.google.com/go/datacatalog v1.14.1/go.mod h1:d2CevwTG4yedZilwe+v3E3ZBDRMobQfSG/a6cCCN5R4= +cloud.google.com/go/datacatalog v1.16.0/go.mod h1:d2CevwTG4yedZilwe+v3E3ZBDRMobQfSG/a6cCCN5R4= +cloud.google.com/go/dataflow v0.6.0/go.mod h1:9QwV89cGoxjjSR9/r7eFDqqjtvbKxAK2BaYU6PVk9UM= +cloud.google.com/go/dataflow v0.7.0/go.mod h1:PX526vb4ijFMesO1o202EaUmouZKBpjHsTlCtB4parQ= +cloud.google.com/go/dataflow v0.8.0/go.mod h1:Rcf5YgTKPtQyYz8bLYhFoIV/vP39eL7fWNcSOyFfLJE= +cloud.google.com/go/dataflow v0.9.1/go.mod h1:Wp7s32QjYuQDWqJPFFlnBKhkAtiFpMTdg00qGbnIHVw= +cloud.google.com/go/dataform v0.3.0/go.mod h1:cj8uNliRlHpa6L3yVhDOBrUXH+BPAO1+KFMQQNSThKo= +cloud.google.com/go/dataform v0.4.0/go.mod h1:fwV6Y4Ty2yIFL89huYlEkwUPtS7YZinZbzzj5S9FzCE= +cloud.google.com/go/dataform v0.5.0/go.mod h1:GFUYRe8IBa2hcomWplodVmUx/iTL0FrsauObOM3Ipr0= +cloud.google.com/go/dataform v0.6.0/go.mod h1:QPflImQy33e29VuapFdf19oPbE4aYTJxr31OAPV+ulA= +cloud.google.com/go/dataform v0.7.0/go.mod h1:7NulqnVozfHvWUBpMDfKMUESr+85aJsC/2O0o3jWPDE= +cloud.google.com/go/dataform v0.8.1/go.mod h1:3BhPSiw8xmppbgzeBbmDvmSWlwouuJkXsXsb8UBih9M= +cloud.google.com/go/datafusion v1.4.0/go.mod h1:1Zb6VN+W6ALo85cXnM1IKiPw+yQMKMhB9TsTSRDo/38= +cloud.google.com/go/datafusion v1.5.0/go.mod h1:Kz+l1FGHB0J+4XF2fud96WMmRiq/wj8N9u007vyXZ2w= +cloud.google.com/go/datafusion v1.6.0/go.mod h1:WBsMF8F1RhSXvVM8rCV3AeyWVxcC2xY6vith3iw3S+8= +cloud.google.com/go/datafusion v1.7.1/go.mod h1:KpoTBbFmoToDExJUso/fcCiguGDk7MEzOWXUsJo0wsI= +cloud.google.com/go/datalabeling v0.5.0/go.mod h1:TGcJ0G2NzcsXSE/97yWjIZO0bXj0KbVlINXMG9ud42I= +cloud.google.com/go/datalabeling v0.6.0/go.mod h1:WqdISuk/+WIGeMkpw/1q7bK/tFEZxsrFJOJdY2bXvTQ= +cloud.google.com/go/datalabeling v0.7.0/go.mod h1:WPQb1y08RJbmpM3ww0CSUAGweL0SxByuW2E+FU+wXcM= +cloud.google.com/go/datalabeling v0.8.1/go.mod h1:XS62LBSVPbYR54GfYQsPXZjTW8UxCK2fkDciSrpRFdY= +cloud.google.com/go/dataplex v1.3.0/go.mod h1:hQuRtDg+fCiFgC8j0zV222HvzFQdRd+SVX8gdmFcZzA= +cloud.google.com/go/dataplex v1.4.0/go.mod h1:X51GfLXEMVJ6UN47ESVqvlsRplbLhcsAt0kZCCKsU0A= +cloud.google.com/go/dataplex v1.5.2/go.mod h1:cVMgQHsmfRoI5KFYq4JtIBEUbYwc3c7tXmIDhRmNNVQ= +cloud.google.com/go/dataplex v1.6.0/go.mod h1:bMsomC/aEJOSpHXdFKFGQ1b0TDPIeL28nJObeO1ppRs= +cloud.google.com/go/dataplex v1.8.1/go.mod h1:7TyrDT6BCdI8/38Uvp0/ZxBslOslP2X2MPDucliyvSE= +cloud.google.com/go/dataplex v1.9.0/go.mod h1:7TyrDT6BCdI8/38Uvp0/ZxBslOslP2X2MPDucliyvSE= +cloud.google.com/go/dataproc v1.7.0/go.mod h1:CKAlMjII9H90RXaMpSxQ8EU6dQx6iAYNPcYPOkSbi8s= +cloud.google.com/go/dataproc v1.8.0/go.mod h1:5OW+zNAH0pMpw14JVrPONsxMQYMBqJuzORhIBfBn9uI= +cloud.google.com/go/dataproc v1.12.0/go.mod h1:zrF3aX0uV3ikkMz6z4uBbIKyhRITnxvr4i3IjKsKrw4= +cloud.google.com/go/dataproc/v2 v2.0.1/go.mod h1:7Ez3KRHdFGcfY7GcevBbvozX+zyWGcwLJvvAMwCaoZ4= +cloud.google.com/go/dataqna v0.5.0/go.mod h1:90Hyk596ft3zUQ8NkFfvICSIfHFh1Bc7C4cK3vbhkeo= +cloud.google.com/go/dataqna v0.6.0/go.mod h1:1lqNpM7rqNLVgWBJyk5NF6Uen2PHym0jtVJonplVsDA= +cloud.google.com/go/dataqna v0.7.0/go.mod h1:Lx9OcIIeqCrw1a6KdO3/5KMP1wAmTc0slZWwP12Qq3c= +cloud.google.com/go/dataqna v0.8.1/go.mod h1:zxZM0Bl6liMePWsHA8RMGAfmTG34vJMapbHAxQ5+WA8= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/datastore v1.10.0/go.mod h1:PC5UzAmDEkAmkfaknstTYbNpgE49HAgW2J1gcgUfmdM= +cloud.google.com/go/datastore v1.11.0/go.mod h1:TvGxBIHCS50u8jzG+AW/ppf87v1of8nwzFNgEZU1D3c= +cloud.google.com/go/datastore v1.12.0/go.mod h1:KjdB88W897MRITkvWWJrg2OUtrR5XVj1EoLgSp6/N70= +cloud.google.com/go/datastore v1.12.1/go.mod h1:KjdB88W897MRITkvWWJrg2OUtrR5XVj1EoLgSp6/N70= +cloud.google.com/go/datastore v1.13.0/go.mod h1:KjdB88W897MRITkvWWJrg2OUtrR5XVj1EoLgSp6/N70= +cloud.google.com/go/datastream v1.2.0/go.mod h1:i/uTP8/fZwgATHS/XFu0TcNUhuA0twZxxQ3EyCUQMwo= +cloud.google.com/go/datastream v1.3.0/go.mod h1:cqlOX8xlyYF/uxhiKn6Hbv6WjwPPuI9W2M9SAXwaLLQ= +cloud.google.com/go/datastream v1.4.0/go.mod h1:h9dpzScPhDTs5noEMQVWP8Wx8AFBRyS0s8KWPx/9r0g= +cloud.google.com/go/datastream v1.5.0/go.mod h1:6TZMMNPwjUqZHBKPQ1wwXpb0d5VDVPl2/XoS5yi88q4= +cloud.google.com/go/datastream v1.6.0/go.mod h1:6LQSuswqLa7S4rPAOZFVjHIG3wJIjZcZrw8JDEDJuIs= +cloud.google.com/go/datastream v1.7.0/go.mod h1:uxVRMm2elUSPuh65IbZpzJNMbuzkcvu5CjMqVIUHrww= +cloud.google.com/go/datastream v1.9.1/go.mod h1:hqnmr8kdUBmrnk65k5wNRoHSCYksvpdZIcZIEl8h43Q= +cloud.google.com/go/datastream v1.10.0/go.mod h1:hqnmr8kdUBmrnk65k5wNRoHSCYksvpdZIcZIEl8h43Q= +cloud.google.com/go/deploy v1.4.0/go.mod h1:5Xghikd4VrmMLNaF6FiRFDlHb59VM59YoDQnOUdsH/c= +cloud.google.com/go/deploy v1.5.0/go.mod h1:ffgdD0B89tToyW/U/D2eL0jN2+IEV/3EMuXHA0l4r+s= +cloud.google.com/go/deploy v1.6.0/go.mod h1:f9PTHehG/DjCom3QH0cntOVRm93uGBDt2vKzAPwpXQI= +cloud.google.com/go/deploy v1.8.0/go.mod h1:z3myEJnA/2wnB4sgjqdMfgxCA0EqC3RBTNcVPs93mtQ= +cloud.google.com/go/deploy v1.11.0/go.mod h1:tKuSUV5pXbn67KiubiUNUejqLs4f5cxxiCNCeyl0F2g= +cloud.google.com/go/deploy v1.13.0/go.mod h1:tKuSUV5pXbn67KiubiUNUejqLs4f5cxxiCNCeyl0F2g= +cloud.google.com/go/dialogflow v1.15.0/go.mod h1:HbHDWs33WOGJgn6rfzBW1Kv807BE3O1+xGbn59zZWI4= +cloud.google.com/go/dialogflow v1.16.1/go.mod h1:po6LlzGfK+smoSmTBnbkIZY2w8ffjz/RcGSS+sh1el0= +cloud.google.com/go/dialogflow v1.17.0/go.mod h1:YNP09C/kXA1aZdBgC/VtXX74G/TKn7XVCcVumTflA+8= +cloud.google.com/go/dialogflow v1.18.0/go.mod h1:trO7Zu5YdyEuR+BhSNOqJezyFQ3aUzz0njv7sMx/iek= +cloud.google.com/go/dialogflow v1.19.0/go.mod h1:JVmlG1TwykZDtxtTXujec4tQ+D8SBFMoosgy+6Gn0s0= +cloud.google.com/go/dialogflow v1.29.0/go.mod h1:b+2bzMe+k1s9V+F2jbJwpHPzrnIyHihAdRFMtn2WXuM= +cloud.google.com/go/dialogflow v1.31.0/go.mod h1:cuoUccuL1Z+HADhyIA7dci3N5zUssgpBJmCzI6fNRB4= +cloud.google.com/go/dialogflow v1.32.0/go.mod h1:jG9TRJl8CKrDhMEcvfcfFkkpp8ZhgPz3sBGmAUYJ2qE= +cloud.google.com/go/dialogflow v1.38.0/go.mod h1:L7jnH+JL2mtmdChzAIcXQHXMvQkE3U4hTaNltEuxXn4= +cloud.google.com/go/dialogflow v1.40.0/go.mod h1:L7jnH+JL2mtmdChzAIcXQHXMvQkE3U4hTaNltEuxXn4= +cloud.google.com/go/dlp v1.6.0/go.mod h1:9eyB2xIhpU0sVwUixfBubDoRwP+GjeUoxxeueZmqvmM= +cloud.google.com/go/dlp v1.7.0/go.mod h1:68ak9vCiMBjbasxeVD17hVPxDEck+ExiHavX8kiHG+Q= +cloud.google.com/go/dlp v1.9.0/go.mod h1:qdgmqgTyReTz5/YNSSuueR8pl7hO0o9bQ39ZhtgkWp4= +cloud.google.com/go/dlp v1.10.1/go.mod h1:IM8BWz1iJd8njcNcG0+Kyd9OPnqnRNkDV8j42VT5KOI= +cloud.google.com/go/documentai v1.7.0/go.mod h1:lJvftZB5NRiFSX4moiye1SMxHx0Bc3x1+p9e/RfXYiU= +cloud.google.com/go/documentai v1.8.0/go.mod h1:xGHNEB7CtsnySCNrCFdCyyMz44RhFEEX2Q7UD0c5IhU= +cloud.google.com/go/documentai v1.9.0/go.mod h1:FS5485S8R00U10GhgBC0aNGrJxBP8ZVpEeJ7PQDZd6k= +cloud.google.com/go/documentai v1.10.0/go.mod h1:vod47hKQIPeCfN2QS/jULIvQTugbmdc0ZvxxfQY1bg4= +cloud.google.com/go/documentai v1.16.0/go.mod h1:o0o0DLTEZ+YnJZ+J4wNfTxmDVyrkzFvttBXXtYRMHkM= +cloud.google.com/go/documentai v1.18.0/go.mod h1:F6CK6iUH8J81FehpskRmhLq/3VlwQvb7TvwOceQ2tbs= +cloud.google.com/go/documentai v1.20.0/go.mod h1:yJkInoMcK0qNAEdRnqY/D5asy73tnPe88I1YTZT+a8E= +cloud.google.com/go/documentai v1.22.0/go.mod h1:yJkInoMcK0qNAEdRnqY/D5asy73tnPe88I1YTZT+a8E= +cloud.google.com/go/domains v0.6.0/go.mod h1:T9Rz3GasrpYk6mEGHh4rymIhjlnIuB4ofT1wTxDeT4Y= +cloud.google.com/go/domains v0.7.0/go.mod h1:PtZeqS1xjnXuRPKE/88Iru/LdfoRyEHYA9nFQf4UKpg= +cloud.google.com/go/domains v0.8.0/go.mod h1:M9i3MMDzGFXsydri9/vW+EWz9sWb4I6WyHqdlAk0idE= +cloud.google.com/go/domains v0.9.1/go.mod h1:aOp1c0MbejQQ2Pjf1iJvnVyT+z6R6s8pX66KaCSDYfE= +cloud.google.com/go/edgecontainer v0.1.0/go.mod h1:WgkZ9tp10bFxqO8BLPqv2LlfmQF1X8lZqwW4r1BTajk= +cloud.google.com/go/edgecontainer v0.2.0/go.mod h1:RTmLijy+lGpQ7BXuTDa4C4ssxyXT34NIuHIgKuP4s5w= +cloud.google.com/go/edgecontainer v0.3.0/go.mod h1:FLDpP4nykgwwIfcLt6zInhprzw0lEi2P1fjO6Ie0qbc= +cloud.google.com/go/edgecontainer v1.0.0/go.mod h1:cttArqZpBB2q58W/upSG++ooo6EsblxDIolxa3jSjbY= +cloud.google.com/go/edgecontainer v1.1.1/go.mod h1:O5bYcS//7MELQZs3+7mabRqoWQhXCzenBu0R8bz2rwk= +cloud.google.com/go/errorreporting v0.3.0/go.mod h1:xsP2yaAp+OAW4OIm60An2bbLpqIhKXdWR/tawvl7QzU= +cloud.google.com/go/essentialcontacts v1.3.0/go.mod h1:r+OnHa5jfj90qIfZDO/VztSFqbQan7HV75p8sA+mdGI= +cloud.google.com/go/essentialcontacts v1.4.0/go.mod h1:8tRldvHYsmnBCHdFpvU+GL75oWiBKl80BiqlFh9tp+8= +cloud.google.com/go/essentialcontacts v1.5.0/go.mod h1:ay29Z4zODTuwliK7SnX8E86aUF2CTzdNtvv42niCX0M= +cloud.google.com/go/essentialcontacts v1.6.2/go.mod h1:T2tB6tX+TRak7i88Fb2N9Ok3PvY3UNbUsMag9/BARh4= +cloud.google.com/go/eventarc v1.7.0/go.mod h1:6ctpF3zTnaQCxUjHUdcfgcA1A2T309+omHZth7gDfmc= +cloud.google.com/go/eventarc v1.8.0/go.mod h1:imbzxkyAU4ubfsaKYdQg04WS1NvncblHEup4kvF+4gw= +cloud.google.com/go/eventarc v1.10.0/go.mod h1:u3R35tmZ9HvswGRBnF48IlYgYeBcPUCjkr4BTdem2Kw= +cloud.google.com/go/eventarc v1.11.0/go.mod h1:PyUjsUKPWoRBCHeOxZd/lbOOjahV41icXyUY5kSTvVY= +cloud.google.com/go/eventarc v1.12.1/go.mod h1:mAFCW6lukH5+IZjkvrEss+jmt2kOdYlN8aMx3sRJiAI= +cloud.google.com/go/eventarc v1.13.0/go.mod h1:mAFCW6lukH5+IZjkvrEss+jmt2kOdYlN8aMx3sRJiAI= +cloud.google.com/go/filestore v1.3.0/go.mod h1:+qbvHGvXU1HaKX2nD0WEPo92TP/8AQuCVEBXNY9z0+w= +cloud.google.com/go/filestore v1.4.0/go.mod h1:PaG5oDfo9r224f8OYXURtAsY+Fbyq/bLYoINEK8XQAI= +cloud.google.com/go/filestore v1.5.0/go.mod h1:FqBXDWBp4YLHqRnVGveOkHDf8svj9r5+mUDLupOWEDs= +cloud.google.com/go/filestore v1.6.0/go.mod h1:di5unNuss/qfZTw2U9nhFqo8/ZDSc466dre85Kydllg= +cloud.google.com/go/filestore v1.7.1/go.mod h1:y10jsorq40JJnjR/lQ8AfFbbcGlw3g+Dp8oN7i7FjV4= +cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE= +cloud.google.com/go/firestore v1.11.0/go.mod h1:b38dKhgzlmNNGTNZZwe7ZRFEuRab1Hay3/DBsIGKKy4= +cloud.google.com/go/firestore v1.12.0/go.mod h1:b38dKhgzlmNNGTNZZwe7ZRFEuRab1Hay3/DBsIGKKy4= +cloud.google.com/go/functions v1.6.0/go.mod h1:3H1UA3qiIPRWD7PeZKLvHZ9SaQhR26XIJcC0A5GbvAk= +cloud.google.com/go/functions v1.7.0/go.mod h1:+d+QBcWM+RsrgZfV9xo6KfA1GlzJfxcfZcRPEhDDfzg= +cloud.google.com/go/functions v1.8.0/go.mod h1:RTZ4/HsQjIqIYP9a9YPbU+QFoQsAlYgrwOXJWHn1POY= +cloud.google.com/go/functions v1.9.0/go.mod h1:Y+Dz8yGguzO3PpIjhLTbnqV1CWmgQ5UwtlpzoyquQ08= +cloud.google.com/go/functions v1.10.0/go.mod h1:0D3hEOe3DbEvCXtYOZHQZmD+SzYsi1YbI7dGvHfldXw= +cloud.google.com/go/functions v1.12.0/go.mod h1:AXWGrF3e2C/5ehvwYo/GH6O5s09tOPksiKhz+hH8WkA= +cloud.google.com/go/functions v1.13.0/go.mod h1:EU4O007sQm6Ef/PwRsI8N2umygGqPBS/IZQKBQBcJ3c= +cloud.google.com/go/functions v1.15.1/go.mod h1:P5yNWUTkyU+LvW/S9O6V+V423VZooALQlqoXdoPz5AE= +cloud.google.com/go/gaming v1.5.0/go.mod h1:ol7rGcxP/qHTRQE/RO4bxkXq+Fix0j6D4LFPzYTIrDM= +cloud.google.com/go/gaming v1.6.0/go.mod h1:YMU1GEvA39Qt3zWGyAVA9bpYz/yAhTvaQ1t2sK4KPUA= +cloud.google.com/go/gaming v1.7.0/go.mod h1:LrB8U7MHdGgFG851iHAfqUdLcKBdQ55hzXy9xBJz0+w= +cloud.google.com/go/gaming v1.8.0/go.mod h1:xAqjS8b7jAVW0KFYeRUxngo9My3f33kFmua++Pi+ggM= +cloud.google.com/go/gaming v1.9.0/go.mod h1:Fc7kEmCObylSWLO334NcO+O9QMDyz+TKC4v1D7X+Bc0= +cloud.google.com/go/gaming v1.10.1/go.mod h1:XQQvtfP8Rb9Rxnxm5wFVpAp9zCQkJi2bLIb7iHGwB3s= +cloud.google.com/go/gkebackup v0.2.0/go.mod h1:XKvv/4LfG829/B8B7xRkk8zRrOEbKtEam6yNfuQNH60= +cloud.google.com/go/gkebackup v0.3.0/go.mod h1:n/E671i1aOQvUxT541aTkCwExO/bTer2HDlj4TsBRAo= +cloud.google.com/go/gkebackup v0.4.0/go.mod h1:byAyBGUwYGEEww7xsbnUTBHIYcOPy/PgUWUtOeRm9Vg= +cloud.google.com/go/gkebackup v1.3.0/go.mod h1:vUDOu++N0U5qs4IhG1pcOnD1Mac79xWy6GoBFlWCWBU= +cloud.google.com/go/gkeconnect v0.5.0/go.mod h1:c5lsNAg5EwAy7fkqX/+goqFsU1Da/jQFqArp+wGNr/o= +cloud.google.com/go/gkeconnect v0.6.0/go.mod h1:Mln67KyU/sHJEBY8kFZ0xTeyPtzbq9StAVvEULYK16A= +cloud.google.com/go/gkeconnect v0.7.0/go.mod h1:SNfmVqPkaEi3bF/B3CNZOAYPYdg7sU+obZ+QTky2Myw= +cloud.google.com/go/gkeconnect v0.8.1/go.mod h1:KWiK1g9sDLZqhxB2xEuPV8V9NYzrqTUmQR9shJHpOZw= +cloud.google.com/go/gkehub v0.9.0/go.mod h1:WYHN6WG8w9bXU0hqNxt8rm5uxnk8IH+lPY9J2TV7BK0= +cloud.google.com/go/gkehub v0.10.0/go.mod h1:UIPwxI0DsrpsVoWpLB0stwKCP+WFVG9+y977wO+hBH0= +cloud.google.com/go/gkehub v0.11.0/go.mod h1:JOWHlmN+GHyIbuWQPl47/C2RFhnFKH38jH9Ascu3n0E= +cloud.google.com/go/gkehub v0.12.0/go.mod h1:djiIwwzTTBrF5NaXCGv3mf7klpEMcST17VBTVVDcuaw= +cloud.google.com/go/gkehub v0.14.1/go.mod h1:VEXKIJZ2avzrbd7u+zeMtW00Y8ddk/4V9511C9CQGTY= +cloud.google.com/go/gkemulticloud v0.3.0/go.mod h1:7orzy7O0S+5kq95e4Hpn7RysVA7dPs8W/GgfUtsPbrA= +cloud.google.com/go/gkemulticloud v0.4.0/go.mod h1:E9gxVBnseLWCk24ch+P9+B2CoDFJZTyIgLKSalC7tuI= +cloud.google.com/go/gkemulticloud v0.5.0/go.mod h1:W0JDkiyi3Tqh0TJr//y19wyb1yf8llHVto2Htf2Ja3Y= +cloud.google.com/go/gkemulticloud v0.6.1/go.mod h1:kbZ3HKyTsiwqKX7Yw56+wUGwwNZViRnxWK2DVknXWfw= +cloud.google.com/go/gkemulticloud v1.0.0/go.mod h1:kbZ3HKyTsiwqKX7Yw56+wUGwwNZViRnxWK2DVknXWfw= +cloud.google.com/go/grafeas v0.2.0/go.mod h1:KhxgtF2hb0P191HlY5besjYm6MqTSTj3LSI+M+ByZHc= +cloud.google.com/go/grafeas v0.3.0/go.mod h1:P7hgN24EyONOTMyeJH6DxG4zD7fwiYa5Q6GUgyFSOU8= +cloud.google.com/go/gsuiteaddons v1.3.0/go.mod h1:EUNK/J1lZEZO8yPtykKxLXI6JSVN2rg9bN8SXOa0bgM= +cloud.google.com/go/gsuiteaddons v1.4.0/go.mod h1:rZK5I8hht7u7HxFQcFei0+AtfS9uSushomRlg+3ua1o= +cloud.google.com/go/gsuiteaddons v1.5.0/go.mod h1:TFCClYLd64Eaa12sFVmUyG62tk4mdIsI7pAnSXRkcFo= +cloud.google.com/go/gsuiteaddons v1.6.1/go.mod h1:CodrdOqRZcLp5WOwejHWYBjZvfY0kOphkAKpF/3qdZY= +cloud.google.com/go/iam v0.1.0/go.mod h1:vcUNEa0pEm0qRVpmWepWaFMIAI8/hjB9mO8rNCJtF6c= +cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY= +cloud.google.com/go/iam v0.5.0/go.mod h1:wPU9Vt0P4UmCux7mqtRu6jcpPAb74cP1fh50J3QpkUc= +cloud.google.com/go/iam v0.6.0/go.mod h1:+1AH33ueBne5MzYccyMHtEKqLE4/kJOibtffMHDMFMc= +cloud.google.com/go/iam v0.7.0/go.mod h1:H5Br8wRaDGNc8XP3keLc4unfUUZeyH3Sfl9XpQEYOeg= +cloud.google.com/go/iam v0.8.0/go.mod h1:lga0/y3iH6CX7sYqypWJ33hf7kkfXJag67naqGESjkE= +cloud.google.com/go/iam v0.11.0/go.mod h1:9PiLDanza5D+oWFZiH1uG+RnRCfEGKoyl6yo4cgWZGY= +cloud.google.com/go/iam v0.12.0/go.mod h1:knyHGviacl11zrtZUoDuYpDgLjvr28sLQaG0YB2GYAY= +cloud.google.com/go/iam v0.13.0/go.mod h1:ljOg+rcNfzZ5d6f1nAUJ8ZIxOaZUVoS14bKCtaLZ/D0= +cloud.google.com/go/iam v1.0.1/go.mod h1:yR3tmSL8BcZB4bxByRv2jkSIahVmCtfKZwLYGBalRE8= +cloud.google.com/go/iam v1.1.0/go.mod h1:nxdHjaKfCr7fNYx/HJMM8LgiMugmveWlkatear5gVyk= +cloud.google.com/go/iam v1.1.1/go.mod h1:A5avdyVL2tCppe4unb0951eI9jreack+RJ0/d+KUZOU= +cloud.google.com/go/iap v1.4.0/go.mod h1:RGFwRJdihTINIe4wZ2iCP0zF/qu18ZwyKxrhMhygBEc= +cloud.google.com/go/iap v1.5.0/go.mod h1:UH/CGgKd4KyohZL5Pt0jSKE4m3FR51qg6FKQ/z/Ix9A= +cloud.google.com/go/iap v1.6.0/go.mod h1:NSuvI9C/j7UdjGjIde7t7HBz+QTwBcapPE07+sSRcLk= +cloud.google.com/go/iap v1.7.0/go.mod h1:beqQx56T9O1G1yNPph+spKpNibDlYIiIixiqsQXxLIo= +cloud.google.com/go/iap v1.7.1/go.mod h1:WapEwPc7ZxGt2jFGB/C/bm+hP0Y6NXzOYGjpPnmMS74= +cloud.google.com/go/iap v1.8.1/go.mod h1:sJCbeqg3mvWLqjZNsI6dfAtbbV1DL2Rl7e1mTyXYREQ= +cloud.google.com/go/ids v1.1.0/go.mod h1:WIuwCaYVOzHIj2OhN9HAwvW+DBdmUAdcWlFxRl+KubM= +cloud.google.com/go/ids v1.2.0/go.mod h1:5WXvp4n25S0rA/mQWAg1YEEBBq6/s+7ml1RDCW1IrcY= +cloud.google.com/go/ids v1.3.0/go.mod h1:JBdTYwANikFKaDP6LtW5JAi4gubs57SVNQjemdt6xV4= +cloud.google.com/go/ids v1.4.1/go.mod h1:np41ed8YMU8zOgv53MMMoCntLTn2lF+SUzlM+O3u/jw= +cloud.google.com/go/iot v1.3.0/go.mod h1:r7RGh2B61+B8oz0AGE+J72AhA0G7tdXItODWsaA2oLs= +cloud.google.com/go/iot v1.4.0/go.mod h1:dIDxPOn0UvNDUMD8Ger7FIaTuvMkj+aGk94RPP0iV+g= +cloud.google.com/go/iot v1.5.0/go.mod h1:mpz5259PDl3XJthEmh9+ap0affn/MqNSP4My77Qql9o= +cloud.google.com/go/iot v1.6.0/go.mod h1:IqdAsmE2cTYYNO1Fvjfzo9po179rAtJeVGUvkLN3rLE= +cloud.google.com/go/iot v1.7.1/go.mod h1:46Mgw7ev1k9KqK1ao0ayW9h0lI+3hxeanz+L1zmbbbk= +cloud.google.com/go/kms v1.4.0/go.mod h1:fajBHndQ+6ubNw6Ss2sSd+SWvjL26RNo/dr7uxsnnOA= +cloud.google.com/go/kms v1.5.0/go.mod h1:QJS2YY0eJGBg3mnDfuaCyLauWwBJiHRboYxJ++1xJNg= +cloud.google.com/go/kms v1.6.0/go.mod h1:Jjy850yySiasBUDi6KFUwUv2n1+o7QZFyuUJg6OgjA0= +cloud.google.com/go/kms v1.8.0/go.mod h1:4xFEhYFqvW+4VMELtZyxomGSYtSQKzM178ylFW4jMAg= +cloud.google.com/go/kms v1.9.0/go.mod h1:qb1tPTgfF9RQP8e1wq4cLFErVuTJv7UsSC915J8dh3w= +cloud.google.com/go/kms v1.10.0/go.mod h1:ng3KTUtQQU9bPX3+QGLsflZIHlkbn8amFAMY63m8d24= +cloud.google.com/go/kms v1.10.1/go.mod h1:rIWk/TryCkR59GMC3YtHtXeLzd634lBbKenvyySAyYI= +cloud.google.com/go/kms v1.11.0/go.mod h1:hwdiYC0xjnWsKQQCQQmIQnS9asjYVSK6jtXm+zFqXLM= +cloud.google.com/go/kms v1.12.1/go.mod h1:c9J991h5DTl+kg7gi3MYomh12YEENGrf48ee/N/2CDM= +cloud.google.com/go/kms v1.15.0/go.mod h1:c9J991h5DTl+kg7gi3MYomh12YEENGrf48ee/N/2CDM= +cloud.google.com/go/language v1.4.0/go.mod h1:F9dRpNFQmJbkaop6g0JhSBXCNlO90e1KWx5iDdxbWic= +cloud.google.com/go/language v1.6.0/go.mod h1:6dJ8t3B+lUYfStgls25GusK04NLh3eDLQnWM3mdEbhI= +cloud.google.com/go/language v1.7.0/go.mod h1:DJ6dYN/W+SQOjF8e1hLQXMF21AkH2w9wiPzPCJa2MIE= +cloud.google.com/go/language v1.8.0/go.mod h1:qYPVHf7SPoNNiCL2Dr0FfEFNil1qi3pQEyygwpgVKB8= +cloud.google.com/go/language v1.9.0/go.mod h1:Ns15WooPM5Ad/5no/0n81yUetis74g3zrbeJBE+ptUY= +cloud.google.com/go/language v1.10.1/go.mod h1:CPp94nsdVNiQEt1CNjF5WkTcisLiHPyIbMhvR8H2AW0= +cloud.google.com/go/lifesciences v0.5.0/go.mod h1:3oIKy8ycWGPUyZDR/8RNnTOYevhaMLqh5vLUXs9zvT8= +cloud.google.com/go/lifesciences v0.6.0/go.mod h1:ddj6tSX/7BOnhxCSd3ZcETvtNr8NZ6t/iPhY2Tyfu08= +cloud.google.com/go/lifesciences v0.8.0/go.mod h1:lFxiEOMqII6XggGbOnKiyZ7IBwoIqA84ClvoezaA/bo= +cloud.google.com/go/lifesciences v0.9.1/go.mod h1:hACAOd1fFbCGLr/+weUKRAJas82Y4vrL3O5326N//Wc= +cloud.google.com/go/logging v1.6.1/go.mod h1:5ZO0mHHbvm8gEmeEUHrmDlTDSu5imF6MUP9OfilNXBw= +cloud.google.com/go/logging v1.7.0/go.mod h1:3xjP2CjkM3ZkO73aj4ASA5wRPGGCRrPIAeNqVNkzY8M= +cloud.google.com/go/longrunning v0.1.1/go.mod h1:UUFxuDWkv22EuY93jjmDMFT5GPQKeFVJBIF6QlTqdsE= +cloud.google.com/go/longrunning v0.3.0/go.mod h1:qth9Y41RRSUE69rDcOn6DdK3HfQfsUI0YSmW3iIlLJc= +cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo= +cloud.google.com/go/longrunning v0.4.2/go.mod h1:OHrnaYyLUV6oqwh0xiS7e5sLQhP1m0QU9R+WhGDMgIQ= +cloud.google.com/go/longrunning v0.5.0/go.mod h1:0JNuqRShmscVAhIACGtskSAWtqtOoPkwP0YF1oVEchc= +cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc= +cloud.google.com/go/managedidentities v1.3.0/go.mod h1:UzlW3cBOiPrzucO5qWkNkh0w33KFtBJU281hacNvsdE= +cloud.google.com/go/managedidentities v1.4.0/go.mod h1:NWSBYbEMgqmbZsLIyKvxrYbtqOsxY1ZrGM+9RgDqInM= +cloud.google.com/go/managedidentities v1.5.0/go.mod h1:+dWcZ0JlUmpuxpIDfyP5pP5y0bLdRwOS4Lp7gMni/LA= +cloud.google.com/go/managedidentities v1.6.1/go.mod h1:h/irGhTN2SkZ64F43tfGPMbHnypMbu4RB3yl8YcuEak= +cloud.google.com/go/maps v0.1.0/go.mod h1:BQM97WGyfw9FWEmQMpZ5T6cpovXXSd1cGmFma94eubI= +cloud.google.com/go/maps v0.6.0/go.mod h1:o6DAMMfb+aINHz/p/jbcY+mYeXBoZoxTfdSQ8VAJaCw= +cloud.google.com/go/maps v0.7.0/go.mod h1:3GnvVl3cqeSvgMcpRlQidXsPYuDGQ8naBis7MVzpXsY= +cloud.google.com/go/maps v1.3.0/go.mod h1:6mWTUv+WhnOwAgjVsSW2QPPECmW+s3PcRyOa9vgG/5s= +cloud.google.com/go/maps v1.4.0/go.mod h1:6mWTUv+WhnOwAgjVsSW2QPPECmW+s3PcRyOa9vgG/5s= +cloud.google.com/go/mediatranslation v0.5.0/go.mod h1:jGPUhGTybqsPQn91pNXw0xVHfuJ3leR1wj37oU3y1f4= +cloud.google.com/go/mediatranslation v0.6.0/go.mod h1:hHdBCTYNigsBxshbznuIMFNe5QXEowAuNmmC7h8pu5w= +cloud.google.com/go/mediatranslation v0.7.0/go.mod h1:LCnB/gZr90ONOIQLgSXagp8XUW1ODs2UmUMvcgMfI2I= +cloud.google.com/go/mediatranslation v0.8.1/go.mod h1:L/7hBdEYbYHQJhX2sldtTO5SZZ1C1vkapubj0T2aGig= +cloud.google.com/go/memcache v1.4.0/go.mod h1:rTOfiGZtJX1AaFUrOgsMHX5kAzaTQ8azHiuDoTPzNsE= +cloud.google.com/go/memcache v1.5.0/go.mod h1:dk3fCK7dVo0cUU2c36jKb4VqKPS22BTkf81Xq617aWM= +cloud.google.com/go/memcache v1.6.0/go.mod h1:XS5xB0eQZdHtTuTF9Hf8eJkKtR3pVRCcvJwtm68T3rA= +cloud.google.com/go/memcache v1.7.0/go.mod h1:ywMKfjWhNtkQTxrWxCkCFkoPjLHPW6A7WOTVI8xy3LY= +cloud.google.com/go/memcache v1.9.0/go.mod h1:8oEyzXCu+zo9RzlEaEjHl4KkgjlNDaXbCQeQWlzNFJM= +cloud.google.com/go/memcache v1.10.1/go.mod h1:47YRQIarv4I3QS5+hoETgKO40InqzLP6kpNLvyXuyaA= +cloud.google.com/go/metastore v1.5.0/go.mod h1:2ZNrDcQwghfdtCwJ33nM0+GrBGlVuh8rakL3vdPY3XY= +cloud.google.com/go/metastore v1.6.0/go.mod h1:6cyQTls8CWXzk45G55x57DVQ9gWg7RiH65+YgPsNh9s= +cloud.google.com/go/metastore v1.7.0/go.mod h1:s45D0B4IlsINu87/AsWiEVYbLaIMeUSoxlKKDqBGFS8= +cloud.google.com/go/metastore v1.8.0/go.mod h1:zHiMc4ZUpBiM7twCIFQmJ9JMEkDSyZS9U12uf7wHqSI= +cloud.google.com/go/metastore v1.10.0/go.mod h1:fPEnH3g4JJAk+gMRnrAnoqyv2lpUCqJPWOodSaf45Eo= +cloud.google.com/go/metastore v1.11.1/go.mod h1:uZuSo80U3Wd4zi6C22ZZliOUJ3XeM/MlYi/z5OAOWRA= +cloud.google.com/go/metastore v1.12.0/go.mod h1:uZuSo80U3Wd4zi6C22ZZliOUJ3XeM/MlYi/z5OAOWRA= +cloud.google.com/go/monitoring v1.7.0/go.mod h1:HpYse6kkGo//7p6sT0wsIC6IBDET0RhIsnmlA53dvEk= +cloud.google.com/go/monitoring v1.8.0/go.mod h1:E7PtoMJ1kQXWxPjB6mv2fhC5/15jInuulFdYYtlcvT4= +cloud.google.com/go/monitoring v1.12.0/go.mod h1:yx8Jj2fZNEkL/GYZyTLS4ZtZEZN8WtDEiEqG4kLK50w= +cloud.google.com/go/monitoring v1.13.0/go.mod h1:k2yMBAB1H9JT/QETjNkgdCGD9bPF712XiLTVr+cBrpw= +cloud.google.com/go/monitoring v1.15.1/go.mod h1:lADlSAlFdbqQuwwpaImhsJXu1QSdd3ojypXrFSMr2rM= +cloud.google.com/go/networkconnectivity v1.4.0/go.mod h1:nOl7YL8odKyAOtzNX73/M5/mGZgqqMeryi6UPZTk/rA= +cloud.google.com/go/networkconnectivity v1.5.0/go.mod h1:3GzqJx7uhtlM3kln0+x5wyFvuVH1pIBJjhCpjzSt75o= +cloud.google.com/go/networkconnectivity v1.6.0/go.mod h1:OJOoEXW+0LAxHh89nXd64uGG+FbQoeH8DtxCHVOMlaM= +cloud.google.com/go/networkconnectivity v1.7.0/go.mod h1:RMuSbkdbPwNMQjB5HBWD5MpTBnNm39iAVpC3TmsExt8= +cloud.google.com/go/networkconnectivity v1.10.0/go.mod h1:UP4O4sWXJG13AqrTdQCD9TnLGEbtNRqjuaaA7bNjF5E= +cloud.google.com/go/networkconnectivity v1.11.0/go.mod h1:iWmDD4QF16VCDLXUqvyspJjIEtBR/4zq5hwnY2X3scM= +cloud.google.com/go/networkconnectivity v1.12.1/go.mod h1:PelxSWYM7Sh9/guf8CFhi6vIqf19Ir/sbfZRUwXh92E= +cloud.google.com/go/networkmanagement v1.4.0/go.mod h1:Q9mdLLRn60AsOrPc8rs8iNV6OHXaGcDdsIQe1ohekq8= +cloud.google.com/go/networkmanagement v1.5.0/go.mod h1:ZnOeZ/evzUdUsnvRt792H0uYEnHQEMaz+REhhzJRcf4= +cloud.google.com/go/networkmanagement v1.6.0/go.mod h1:5pKPqyXjB/sgtvB5xqOemumoQNB7y95Q7S+4rjSOPYY= +cloud.google.com/go/networkmanagement v1.8.0/go.mod h1:Ho/BUGmtyEqrttTgWEe7m+8vDdK74ibQc+Be0q7Fof0= +cloud.google.com/go/networksecurity v0.5.0/go.mod h1:xS6fOCoqpVC5zx15Z/MqkfDwH4+m/61A3ODiDV1xmiQ= +cloud.google.com/go/networksecurity v0.6.0/go.mod h1:Q5fjhTr9WMI5mbpRYEbiexTzROf7ZbDzvzCrNl14nyU= +cloud.google.com/go/networksecurity v0.7.0/go.mod h1:mAnzoxx/8TBSyXEeESMy9OOYwo1v+gZ5eMRnsT5bC8k= +cloud.google.com/go/networksecurity v0.8.0/go.mod h1:B78DkqsxFG5zRSVuwYFRZ9Xz8IcQ5iECsNrPn74hKHU= +cloud.google.com/go/networksecurity v0.9.1/go.mod h1:MCMdxOKQ30wsBI1eI659f9kEp4wuuAueoC9AJKSPWZQ= +cloud.google.com/go/notebooks v1.2.0/go.mod h1:9+wtppMfVPUeJ8fIWPOq1UnATHISkGXGqTkxeieQ6UY= +cloud.google.com/go/notebooks v1.3.0/go.mod h1:bFR5lj07DtCPC7YAAJ//vHskFBxA5JzYlH68kXVdk34= +cloud.google.com/go/notebooks v1.4.0/go.mod h1:4QPMngcwmgb6uw7Po99B2xv5ufVoIQ7nOGDyL4P8AgA= +cloud.google.com/go/notebooks v1.5.0/go.mod h1:q8mwhnP9aR8Hpfnrc5iN5IBhrXUy8S2vuYs+kBJ/gu0= +cloud.google.com/go/notebooks v1.7.0/go.mod h1:PVlaDGfJgj1fl1S3dUwhFMXFgfYGhYQt2164xOMONmE= +cloud.google.com/go/notebooks v1.8.0/go.mod h1:Lq6dYKOYOWUCTvw5t2q1gp1lAp0zxAxRycayS0iJcqQ= +cloud.google.com/go/notebooks v1.9.1/go.mod h1:zqG9/gk05JrzgBt4ghLzEepPHNwE5jgPcHZRKhlC1A8= +cloud.google.com/go/optimization v1.1.0/go.mod h1:5po+wfvX5AQlPznyVEZjGJTMr4+CAkJf2XSTQOOl9l4= +cloud.google.com/go/optimization v1.2.0/go.mod h1:Lr7SOHdRDENsh+WXVmQhQTrzdu9ybg0NecjHidBq6xs= +cloud.google.com/go/optimization v1.3.1/go.mod h1:IvUSefKiwd1a5p0RgHDbWCIbDFgKuEdB+fPPuP0IDLI= +cloud.google.com/go/optimization v1.4.1/go.mod h1:j64vZQP7h9bO49m2rVaTVoNM0vEBEN5eKPUPbZyXOrk= +cloud.google.com/go/orchestration v1.3.0/go.mod h1:Sj5tq/JpWiB//X/q3Ngwdl5K7B7Y0KZ7bfv0wL6fqVA= +cloud.google.com/go/orchestration v1.4.0/go.mod h1:6W5NLFWs2TlniBphAViZEVhrXRSMgUGDfW7vrWKvsBk= +cloud.google.com/go/orchestration v1.6.0/go.mod h1:M62Bevp7pkxStDfFfTuCOaXgaaqRAga1yKyoMtEoWPQ= +cloud.google.com/go/orchestration v1.8.1/go.mod h1:4sluRF3wgbYVRqz7zJ1/EUNc90TTprliq9477fGobD8= +cloud.google.com/go/orgpolicy v1.4.0/go.mod h1:xrSLIV4RePWmP9P3tBl8S93lTmlAxjm06NSm2UTmKvE= +cloud.google.com/go/orgpolicy v1.5.0/go.mod h1:hZEc5q3wzwXJaKrsx5+Ewg0u1LxJ51nNFlext7Tanwc= +cloud.google.com/go/orgpolicy v1.10.0/go.mod h1:w1fo8b7rRqlXlIJbVhOMPrwVljyuW5mqssvBtU18ONc= +cloud.google.com/go/orgpolicy v1.11.0/go.mod h1:2RK748+FtVvnfuynxBzdnyu7sygtoZa1za/0ZfpOs1M= +cloud.google.com/go/orgpolicy v1.11.1/go.mod h1:8+E3jQcpZJQliP+zaFfayC2Pg5bmhuLK755wKhIIUCE= +cloud.google.com/go/osconfig v1.7.0/go.mod h1:oVHeCeZELfJP7XLxcBGTMBvRO+1nQ5tFG9VQTmYS2Fs= +cloud.google.com/go/osconfig v1.8.0/go.mod h1:EQqZLu5w5XA7eKizepumcvWx+m8mJUhEwiPqWiZeEdg= +cloud.google.com/go/osconfig v1.9.0/go.mod h1:Yx+IeIZJ3bdWmzbQU4fxNl8xsZ4amB+dygAwFPlvnNo= +cloud.google.com/go/osconfig v1.10.0/go.mod h1:uMhCzqC5I8zfD9zDEAfvgVhDS8oIjySWh+l4WK6GnWw= +cloud.google.com/go/osconfig v1.11.0/go.mod h1:aDICxrur2ogRd9zY5ytBLV89KEgT2MKB2L/n6x1ooPw= +cloud.google.com/go/osconfig v1.12.0/go.mod h1:8f/PaYzoS3JMVfdfTubkowZYGmAhUCjjwnjqWI7NVBc= +cloud.google.com/go/osconfig v1.12.1/go.mod h1:4CjBxND0gswz2gfYRCUoUzCm9zCABp91EeTtWXyz0tE= +cloud.google.com/go/oslogin v1.4.0/go.mod h1:YdgMXWRaElXz/lDk1Na6Fh5orF7gvmJ0FGLIs9LId4E= +cloud.google.com/go/oslogin v1.5.0/go.mod h1:D260Qj11W2qx/HVF29zBg+0fd6YCSjSqLUkY/qEenQU= +cloud.google.com/go/oslogin v1.6.0/go.mod h1:zOJ1O3+dTU8WPlGEkFSh7qeHPPSoxrcMbbK1Nm2iX70= +cloud.google.com/go/oslogin v1.7.0/go.mod h1:e04SN0xO1UNJ1M5GP0vzVBFicIe4O53FOfcixIqTyXo= +cloud.google.com/go/oslogin v1.9.0/go.mod h1:HNavntnH8nzrn8JCTT5fj18FuJLFJc4NaZJtBnQtKFs= +cloud.google.com/go/oslogin v1.10.1/go.mod h1:x692z7yAue5nE7CsSnoG0aaMbNoRJRXO4sn73R+ZqAs= +cloud.google.com/go/phishingprotection v0.5.0/go.mod h1:Y3HZknsK9bc9dMi+oE8Bim0lczMU6hrX0UpADuMefr0= +cloud.google.com/go/phishingprotection v0.6.0/go.mod h1:9Y3LBLgy0kDTcYET8ZH3bq/7qni15yVUoAxiFxnlSUA= +cloud.google.com/go/phishingprotection v0.7.0/go.mod h1:8qJI4QKHoda/sb/7/YmMQ2omRLSLYSu9bU0EKCNI+Lk= +cloud.google.com/go/phishingprotection v0.8.1/go.mod h1:AxonW7GovcA8qdEk13NfHq9hNx5KPtfxXNeUxTDxB6I= +cloud.google.com/go/policytroubleshooter v1.3.0/go.mod h1:qy0+VwANja+kKrjlQuOzmlvscn4RNsAc0e15GGqfMxg= +cloud.google.com/go/policytroubleshooter v1.4.0/go.mod h1:DZT4BcRw3QoO8ota9xw/LKtPa8lKeCByYeKTIf/vxdE= +cloud.google.com/go/policytroubleshooter v1.5.0/go.mod h1:Rz1WfV+1oIpPdN2VvvuboLVRsB1Hclg3CKQ53j9l8vw= +cloud.google.com/go/policytroubleshooter v1.6.0/go.mod h1:zYqaPTsmfvpjm5ULxAyD/lINQxJ0DDsnWOP/GZ7xzBc= +cloud.google.com/go/policytroubleshooter v1.7.1/go.mod h1:0NaT5v3Ag1M7U5r0GfDCpUFkWd9YqpubBWsQlhanRv0= +cloud.google.com/go/policytroubleshooter v1.8.0/go.mod h1:tmn5Ir5EToWe384EuboTcVQT7nTag2+DuH3uHmKd1HU= +cloud.google.com/go/privatecatalog v0.5.0/go.mod h1:XgosMUvvPyxDjAVNDYxJ7wBW8//hLDDYmnsNcMGq1K0= +cloud.google.com/go/privatecatalog v0.6.0/go.mod h1:i/fbkZR0hLN29eEWiiwue8Pb+GforiEIBnV9yrRUOKI= +cloud.google.com/go/privatecatalog v0.7.0/go.mod h1:2s5ssIFO69F5csTXcwBP7NPFTZvps26xGzvQ2PQaBYg= +cloud.google.com/go/privatecatalog v0.8.0/go.mod h1:nQ6pfaegeDAq/Q5lrfCQzQLhubPiZhSaNhIgfJlnIXs= +cloud.google.com/go/privatecatalog v0.9.1/go.mod h1:0XlDXW2unJXdf9zFz968Hp35gl/bhF4twwpXZAW50JA= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/pubsub v1.26.0/go.mod h1:QgBH3U/jdJy/ftjPhTkyXNj543Tin1pRYcdcPRnFIRI= +cloud.google.com/go/pubsub v1.27.1/go.mod h1:hQN39ymbV9geqBnfQq6Xf63yNhUAhv9CZhzp5O6qsW0= +cloud.google.com/go/pubsub v1.28.0/go.mod h1:vuXFpwaVoIPQMGXqRyUQigu/AX1S3IWugR9xznmcXX8= +cloud.google.com/go/pubsub v1.30.0/go.mod h1:qWi1OPS0B+b5L+Sg6Gmc9zD1Y+HaM0MdUr7LsupY1P4= +cloud.google.com/go/pubsub v1.32.0/go.mod h1:f+w71I33OMyxf9VpMVcZbnG5KSUkCOUHYpFd5U1GdRc= +cloud.google.com/go/pubsub v1.33.0/go.mod h1:f+w71I33OMyxf9VpMVcZbnG5KSUkCOUHYpFd5U1GdRc= +cloud.google.com/go/pubsublite v1.5.0/go.mod h1:xapqNQ1CuLfGi23Yda/9l4bBCKz/wC3KIJ5gKcxveZg= +cloud.google.com/go/pubsublite v1.6.0/go.mod h1:1eFCS0U11xlOuMFV/0iBqw3zP12kddMeCbj/F3FSj9k= +cloud.google.com/go/pubsublite v1.7.0/go.mod h1:8hVMwRXfDfvGm3fahVbtDbiLePT3gpoiJYJY+vxWxVM= +cloud.google.com/go/pubsublite v1.8.1/go.mod h1:fOLdU4f5xldK4RGJrBMm+J7zMWNj/k4PxwEZXy39QS0= +cloud.google.com/go/recaptchaenterprise v1.3.1/go.mod h1:OdD+q+y4XGeAlxRaMn1Y7/GveP6zmq76byL6tjPE7d4= +cloud.google.com/go/recaptchaenterprise/v2 v2.1.0/go.mod h1:w9yVqajwroDNTfGuhmOjPDN//rZGySaf6PtFVcSCa7o= +cloud.google.com/go/recaptchaenterprise/v2 v2.2.0/go.mod h1:/Zu5jisWGeERrd5HnlS3EUGb/D335f9k51B/FVil0jk= +cloud.google.com/go/recaptchaenterprise/v2 v2.3.0/go.mod h1:O9LwGCjrhGHBQET5CA7dd5NwwNQUErSgEDit1DLNTdo= +cloud.google.com/go/recaptchaenterprise/v2 v2.4.0/go.mod h1:Am3LHfOuBstrLrNCBrlI5sbwx9LBg3te2N6hGvHn2mE= +cloud.google.com/go/recaptchaenterprise/v2 v2.5.0/go.mod h1:O8LzcHXN3rz0j+LBC91jrwI3R+1ZSZEWrfL7XHgNo9U= +cloud.google.com/go/recaptchaenterprise/v2 v2.6.0/go.mod h1:RPauz9jeLtB3JVzg6nCbe12qNoaa8pXc4d/YukAmcnA= +cloud.google.com/go/recaptchaenterprise/v2 v2.7.0/go.mod h1:19wVj/fs5RtYtynAPJdDTb69oW0vNHYDBTbB4NvMD9c= +cloud.google.com/go/recaptchaenterprise/v2 v2.7.2/go.mod h1:kR0KjsJS7Jt1YSyWFkseQ756D45kaYNTlDPPaRAvDBU= +cloud.google.com/go/recommendationengine v0.5.0/go.mod h1:E5756pJcVFeVgaQv3WNpImkFP8a+RptV6dDLGPILjvg= +cloud.google.com/go/recommendationengine v0.6.0/go.mod h1:08mq2umu9oIqc7tDy8sx+MNJdLG0fUi3vaSVbztHgJ4= +cloud.google.com/go/recommendationengine v0.7.0/go.mod h1:1reUcE3GIu6MeBz/h5xZJqNLuuVjNg1lmWMPyjatzac= +cloud.google.com/go/recommendationengine v0.8.1/go.mod h1:MrZihWwtFYWDzE6Hz5nKcNz3gLizXVIDI/o3G1DLcrE= +cloud.google.com/go/recommender v1.5.0/go.mod h1:jdoeiBIVrJe9gQjwd759ecLJbxCDED4A6p+mqoqDvTg= +cloud.google.com/go/recommender v1.6.0/go.mod h1:+yETpm25mcoiECKh9DEScGzIRyDKpZ0cEhWGo+8bo+c= +cloud.google.com/go/recommender v1.7.0/go.mod h1:XLHs/W+T8olwlGOgfQenXBTbIseGclClff6lhFVe9Bs= +cloud.google.com/go/recommender v1.8.0/go.mod h1:PkjXrTT05BFKwxaUxQmtIlrtj0kph108r02ZZQ5FE70= +cloud.google.com/go/recommender v1.9.0/go.mod h1:PnSsnZY7q+VL1uax2JWkt/UegHssxjUVVCrX52CuEmQ= +cloud.google.com/go/recommender v1.10.1/go.mod h1:XFvrE4Suqn5Cq0Lf+mCP6oBHD/yRMA8XxP5sb7Q7gpA= +cloud.google.com/go/redis v1.7.0/go.mod h1:V3x5Jq1jzUcg+UNsRvdmsfuFnit1cfe3Z/PGyq/lm4Y= +cloud.google.com/go/redis v1.8.0/go.mod h1:Fm2szCDavWzBk2cDKxrkmWBqoCiL1+Ctwq7EyqBCA/A= +cloud.google.com/go/redis v1.9.0/go.mod h1:HMYQuajvb2D0LvMgZmLDZW8V5aOC/WxstZHiy4g8OiA= +cloud.google.com/go/redis v1.10.0/go.mod h1:ThJf3mMBQtW18JzGgh41/Wld6vnDDc/F/F35UolRZPM= +cloud.google.com/go/redis v1.11.0/go.mod h1:/X6eicana+BWcUda5PpwZC48o37SiFVTFSs0fWAJ7uQ= +cloud.google.com/go/redis v1.13.1/go.mod h1:VP7DGLpE91M6bcsDdMuyCm2hIpB6Vp2hI090Mfd1tcg= +cloud.google.com/go/resourcemanager v1.3.0/go.mod h1:bAtrTjZQFJkiWTPDb1WBjzvc6/kifjj4QBYuKCCoqKA= +cloud.google.com/go/resourcemanager v1.4.0/go.mod h1:MwxuzkumyTX7/a3n37gmsT3py7LIXwrShilPh3P1tR0= +cloud.google.com/go/resourcemanager v1.5.0/go.mod h1:eQoXNAiAvCf5PXxWxXjhKQoTMaUSNrEfg+6qdf/wots= +cloud.google.com/go/resourcemanager v1.6.0/go.mod h1:YcpXGRs8fDzcUl1Xw8uOVmI8JEadvhRIkoXXUNVYcVo= +cloud.google.com/go/resourcemanager v1.7.0/go.mod h1:HlD3m6+bwhzj9XCouqmeiGuni95NTrExfhoSrkC/3EI= +cloud.google.com/go/resourcemanager v1.9.1/go.mod h1:dVCuosgrh1tINZ/RwBufr8lULmWGOkPS8gL5gqyjdT8= +cloud.google.com/go/resourcesettings v1.3.0/go.mod h1:lzew8VfESA5DQ8gdlHwMrqZs1S9V87v3oCnKCWoOuQU= +cloud.google.com/go/resourcesettings v1.4.0/go.mod h1:ldiH9IJpcrlC3VSuCGvjR5of/ezRrOxFtpJoJo5SmXg= +cloud.google.com/go/resourcesettings v1.5.0/go.mod h1:+xJF7QSG6undsQDfsCJyqWXyBwUoJLhetkRMDRnIoXA= +cloud.google.com/go/resourcesettings v1.6.1/go.mod h1:M7mk9PIZrC5Fgsu1kZJci6mpgN8o0IUzVx3eJU3y4Jw= +cloud.google.com/go/retail v1.8.0/go.mod h1:QblKS8waDmNUhghY2TI9O3JLlFk8jybHeV4BF19FrE4= +cloud.google.com/go/retail v1.9.0/go.mod h1:g6jb6mKuCS1QKnH/dpu7isX253absFl6iE92nHwlBUY= +cloud.google.com/go/retail v1.10.0/go.mod h1:2gDk9HsL4HMS4oZwz6daui2/jmKvqShXKQuB2RZ+cCc= +cloud.google.com/go/retail v1.11.0/go.mod h1:MBLk1NaWPmh6iVFSz9MeKG/Psyd7TAgm6y/9L2B4x9Y= +cloud.google.com/go/retail v1.12.0/go.mod h1:UMkelN/0Z8XvKymXFbD4EhFJlYKRx1FGhQkVPU5kF14= +cloud.google.com/go/retail v1.14.1/go.mod h1:y3Wv3Vr2k54dLNIrCzenyKG8g8dhvhncT2NcNjb/6gE= +cloud.google.com/go/run v0.2.0/go.mod h1:CNtKsTA1sDcnqqIFR3Pb5Tq0usWxJJvsWOCPldRU3Do= +cloud.google.com/go/run v0.3.0/go.mod h1:TuyY1+taHxTjrD0ZFk2iAR+xyOXEA0ztb7U3UNA0zBo= +cloud.google.com/go/run v0.8.0/go.mod h1:VniEnuBwqjigv0A7ONfQUaEItaiCRVujlMqerPPiktM= +cloud.google.com/go/run v0.9.0/go.mod h1:Wwu+/vvg8Y+JUApMwEDfVfhetv30hCG4ZwDR/IXl2Qg= +cloud.google.com/go/run v1.2.0/go.mod h1:36V1IlDzQ0XxbQjUx6IYbw8H3TJnWvhii963WW3B/bo= +cloud.google.com/go/scheduler v1.4.0/go.mod h1:drcJBmxF3aqZJRhmkHQ9b3uSSpQoltBPGPxGAWROx6s= +cloud.google.com/go/scheduler v1.5.0/go.mod h1:ri073ym49NW3AfT6DZi21vLZrG07GXr5p3H1KxN5QlI= +cloud.google.com/go/scheduler v1.6.0/go.mod h1:SgeKVM7MIwPn3BqtcBntpLyrIJftQISRrYB5ZtT+KOk= +cloud.google.com/go/scheduler v1.7.0/go.mod h1:jyCiBqWW956uBjjPMMuX09n3x37mtyPJegEWKxRsn44= +cloud.google.com/go/scheduler v1.8.0/go.mod h1:TCET+Y5Gp1YgHT8py4nlg2Sew8nUHMqcpousDgXJVQc= +cloud.google.com/go/scheduler v1.9.0/go.mod h1:yexg5t+KSmqu+njTIh3b7oYPheFtBWGcbVUYF1GGMIc= +cloud.google.com/go/scheduler v1.10.1/go.mod h1:R63Ldltd47Bs4gnhQkmNDse5w8gBRrhObZ54PxgR2Oo= +cloud.google.com/go/secretmanager v1.6.0/go.mod h1:awVa/OXF6IiyaU1wQ34inzQNc4ISIDIrId8qE5QGgKA= +cloud.google.com/go/secretmanager v1.8.0/go.mod h1:hnVgi/bN5MYHd3Gt0SPuTPPp5ENina1/LxM+2W9U9J4= +cloud.google.com/go/secretmanager v1.9.0/go.mod h1:b71qH2l1yHmWQHt9LC80akm86mX8AL6X1MA01dW8ht4= +cloud.google.com/go/secretmanager v1.10.0/go.mod h1:MfnrdvKMPNra9aZtQFvBcvRU54hbPD8/HayQdlUgJpU= +cloud.google.com/go/secretmanager v1.11.1/go.mod h1:znq9JlXgTNdBeQk9TBW/FnR/W4uChEKGeqQWAJ8SXFw= +cloud.google.com/go/security v1.5.0/go.mod h1:lgxGdyOKKjHL4YG3/YwIL2zLqMFCKs0UbQwgyZmfJl4= +cloud.google.com/go/security v1.7.0/go.mod h1:mZklORHl6Bg7CNnnjLH//0UlAlaXqiG7Lb9PsPXLfD0= +cloud.google.com/go/security v1.8.0/go.mod h1:hAQOwgmaHhztFhiQ41CjDODdWP0+AE1B3sX4OFlq+GU= +cloud.google.com/go/security v1.9.0/go.mod h1:6Ta1bO8LXI89nZnmnsZGp9lVoVWXqsVbIq/t9dzI+2Q= +cloud.google.com/go/security v1.10.0/go.mod h1:QtOMZByJVlibUT2h9afNDWRZ1G96gVywH8T5GUSb9IA= +cloud.google.com/go/security v1.12.0/go.mod h1:rV6EhrpbNHrrxqlvW0BWAIawFWq3X90SduMJdFwtLB8= +cloud.google.com/go/security v1.13.0/go.mod h1:Q1Nvxl1PAgmeW0y3HTt54JYIvUdtcpYKVfIB8AOMZ+0= +cloud.google.com/go/security v1.15.1/go.mod h1:MvTnnbsWnehoizHi09zoiZob0iCHVcL4AUBj76h9fXA= +cloud.google.com/go/securitycenter v1.13.0/go.mod h1:cv5qNAqjY84FCN6Y9z28WlkKXyWsgLO832YiWwkCWcU= +cloud.google.com/go/securitycenter v1.14.0/go.mod h1:gZLAhtyKv85n52XYWt6RmeBdydyxfPeTrpToDPw4Auc= +cloud.google.com/go/securitycenter v1.15.0/go.mod h1:PeKJ0t8MoFmmXLXWm41JidyzI3PJjd8sXWaVqg43WWk= +cloud.google.com/go/securitycenter v1.16.0/go.mod h1:Q9GMaLQFUD+5ZTabrbujNWLtSLZIZF7SAR0wWECrjdk= +cloud.google.com/go/securitycenter v1.18.1/go.mod h1:0/25gAzCM/9OL9vVx4ChPeM/+DlfGQJDwBy/UC8AKK0= +cloud.google.com/go/securitycenter v1.19.0/go.mod h1:LVLmSg8ZkkyaNy4u7HCIshAngSQ8EcIRREP3xBnyfag= +cloud.google.com/go/securitycenter v1.23.0/go.mod h1:8pwQ4n+Y9WCWM278R8W3nF65QtY172h4S8aXyI9/hsQ= +cloud.google.com/go/servicecontrol v1.4.0/go.mod h1:o0hUSJ1TXJAmi/7fLJAedOovnujSEvjKCAFNXPQ1RaU= +cloud.google.com/go/servicecontrol v1.5.0/go.mod h1:qM0CnXHhyqKVuiZnGKrIurvVImCs8gmqWsDoqe9sU1s= +cloud.google.com/go/servicecontrol v1.10.0/go.mod h1:pQvyvSRh7YzUF2efw7H87V92mxU8FnFDawMClGCNuAA= +cloud.google.com/go/servicecontrol v1.11.0/go.mod h1:kFmTzYzTUIuZs0ycVqRHNaNhgR+UMUpw9n02l/pY+mc= +cloud.google.com/go/servicecontrol v1.11.1/go.mod h1:aSnNNlwEFBY+PWGQ2DoM0JJ/QUXqV5/ZD9DOLB7SnUk= +cloud.google.com/go/servicedirectory v1.4.0/go.mod h1:gH1MUaZCgtP7qQiI+F+A+OpeKF/HQWgtAddhTbhL2bs= +cloud.google.com/go/servicedirectory v1.5.0/go.mod h1:QMKFL0NUySbpZJ1UZs3oFAmdvVxhhxB6eJ/Vlp73dfg= +cloud.google.com/go/servicedirectory v1.6.0/go.mod h1:pUlbnWsLH9c13yGkxCmfumWEPjsRs1RlmJ4pqiNjVL4= +cloud.google.com/go/servicedirectory v1.7.0/go.mod h1:5p/U5oyvgYGYejufvxhgwjL8UVXjkuw7q5XcG10wx1U= +cloud.google.com/go/servicedirectory v1.8.0/go.mod h1:srXodfhY1GFIPvltunswqXpVxFPpZjf8nkKQT7XcXaY= +cloud.google.com/go/servicedirectory v1.9.0/go.mod h1:29je5JjiygNYlmsGz8k6o+OZ8vd4f//bQLtvzkPPT/s= +cloud.google.com/go/servicedirectory v1.10.1/go.mod h1:Xv0YVH8s4pVOwfM/1eMTl0XJ6bzIOSLDt8f8eLaGOxQ= +cloud.google.com/go/servicedirectory v1.11.0/go.mod h1:Xv0YVH8s4pVOwfM/1eMTl0XJ6bzIOSLDt8f8eLaGOxQ= +cloud.google.com/go/servicemanagement v1.4.0/go.mod h1:d8t8MDbezI7Z2R1O/wu8oTggo3BI2GKYbdG4y/SJTco= +cloud.google.com/go/servicemanagement v1.5.0/go.mod h1:XGaCRe57kfqu4+lRxaFEAuqmjzF0r+gWHjWqKqBvKFo= +cloud.google.com/go/servicemanagement v1.6.0/go.mod h1:aWns7EeeCOtGEX4OvZUWCCJONRZeFKiptqKf1D0l/Jc= +cloud.google.com/go/servicemanagement v1.8.0/go.mod h1:MSS2TDlIEQD/fzsSGfCdJItQveu9NXnUniTrq/L8LK4= +cloud.google.com/go/serviceusage v1.3.0/go.mod h1:Hya1cozXM4SeSKTAgGXgj97GlqUvF5JaoXacR1JTP/E= +cloud.google.com/go/serviceusage v1.4.0/go.mod h1:SB4yxXSaYVuUBYUml6qklyONXNLt83U0Rb+CXyhjEeU= +cloud.google.com/go/serviceusage v1.5.0/go.mod h1:w8U1JvqUqwJNPEOTQjrMHkw3IaIFLoLsPLvsE3xueec= +cloud.google.com/go/serviceusage v1.6.0/go.mod h1:R5wwQcbOWsyuOfbP9tGdAnCAc6B9DRwPG1xtWMDeuPA= +cloud.google.com/go/shell v1.3.0/go.mod h1:VZ9HmRjZBsjLGXusm7K5Q5lzzByZmJHf1d0IWHEN5X4= +cloud.google.com/go/shell v1.4.0/go.mod h1:HDxPzZf3GkDdhExzD/gs8Grqk+dmYcEjGShZgYa9URw= +cloud.google.com/go/shell v1.6.0/go.mod h1:oHO8QACS90luWgxP3N9iZVuEiSF84zNyLytb+qE2f9A= +cloud.google.com/go/shell v1.7.1/go.mod h1:u1RaM+huXFaTojTbW4g9P5emOrrmLE69KrxqQahKn4g= +cloud.google.com/go/spanner v1.41.0/go.mod h1:MLYDBJR/dY4Wt7ZaMIQ7rXOTLjYrmxLE/5ve9vFfWos= +cloud.google.com/go/spanner v1.44.0/go.mod h1:G8XIgYdOK+Fbcpbs7p2fiprDw4CaZX63whnSMLVBxjk= +cloud.google.com/go/spanner v1.45.0/go.mod h1:FIws5LowYz8YAE1J8fOS7DJup8ff7xJeetWEo5REA2M= +cloud.google.com/go/spanner v1.47.0/go.mod h1:IXsJwVW2j4UKs0eYDqodab6HgGuA1bViSqW4uH9lfUI= +cloud.google.com/go/speech v1.6.0/go.mod h1:79tcr4FHCimOp56lwC01xnt/WPJZc4v3gzyT7FoBkCM= +cloud.google.com/go/speech v1.7.0/go.mod h1:KptqL+BAQIhMsj1kOP2la5DSEEerPDuOP/2mmkhHhZQ= +cloud.google.com/go/speech v1.8.0/go.mod h1:9bYIl1/tjsAnMgKGHKmBZzXKEkGgtU+MpdDPTE9f7y0= +cloud.google.com/go/speech v1.9.0/go.mod h1:xQ0jTcmnRFFM2RfX/U+rk6FQNUF6DQlydUSyoooSpco= +cloud.google.com/go/speech v1.14.1/go.mod h1:gEosVRPJ9waG7zqqnsHpYTOoAS4KouMRLDFMekpJ0J0= +cloud.google.com/go/speech v1.15.0/go.mod h1:y6oH7GhqCaZANH7+Oe0BhgIogsNInLlz542tg3VqeYI= +cloud.google.com/go/speech v1.17.1/go.mod h1:8rVNzU43tQvxDaGvqOhpDqgkJTFowBpDvCJ14kGlJYo= +cloud.google.com/go/speech v1.19.0/go.mod h1:8rVNzU43tQvxDaGvqOhpDqgkJTFowBpDvCJ14kGlJYo= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= +cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y= +cloud.google.com/go/storage v1.23.0/go.mod h1:vOEEDNFnciUMhBeT6hsJIn3ieU5cFRmzeLgDvXzfIXc= +cloud.google.com/go/storage v1.27.0/go.mod h1:x9DOL8TK/ygDUMieqwfhdpQryTeEkhGKMi80i/iqR2s= +cloud.google.com/go/storage v1.28.1/go.mod h1:Qnisd4CqDdo6BGs2AD5LLnEsmSQ80wQ5ogcBBKhU86Y= +cloud.google.com/go/storage v1.29.0/go.mod h1:4puEjyTKnku6gfKoTfNOU/W+a9JyuVNxjpS5GBrB8h4= +cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E= +cloud.google.com/go/storagetransfer v1.5.0/go.mod h1:dxNzUopWy7RQevYFHewchb29POFv3/AaBgnhqzqiK0w= +cloud.google.com/go/storagetransfer v1.6.0/go.mod h1:y77xm4CQV/ZhFZH75PLEXY0ROiS7Gh6pSKrM8dJyg6I= +cloud.google.com/go/storagetransfer v1.7.0/go.mod h1:8Giuj1QNb1kfLAiWM1bN6dHzfdlDAVC9rv9abHot2W4= +cloud.google.com/go/storagetransfer v1.8.0/go.mod h1:JpegsHHU1eXg7lMHkvf+KE5XDJ7EQu0GwNJbbVGanEw= +cloud.google.com/go/storagetransfer v1.10.0/go.mod h1:DM4sTlSmGiNczmV6iZyceIh2dbs+7z2Ayg6YAiQlYfA= +cloud.google.com/go/talent v1.1.0/go.mod h1:Vl4pt9jiHKvOgF9KoZo6Kob9oV4lwd/ZD5Cto54zDRw= +cloud.google.com/go/talent v1.2.0/go.mod h1:MoNF9bhFQbiJ6eFD3uSsg0uBALw4n4gaCaEjBw9zo8g= +cloud.google.com/go/talent v1.3.0/go.mod h1:CmcxwJ/PKfRgd1pBjQgU6W3YBwiewmUzQYH5HHmSCmM= +cloud.google.com/go/talent v1.4.0/go.mod h1:ezFtAgVuRf8jRsvyE6EwmbTK5LKciD4KVnHuDEFmOOA= +cloud.google.com/go/talent v1.5.0/go.mod h1:G+ODMj9bsasAEJkQSzO2uHQWXHHXUomArjWQQYkqK6c= +cloud.google.com/go/talent v1.6.2/go.mod h1:CbGvmKCG61mkdjcqTcLOkb2ZN1SrQI8MDyma2l7VD24= +cloud.google.com/go/texttospeech v1.4.0/go.mod h1:FX8HQHA6sEpJ7rCMSfXuzBcysDAuWusNNNvN9FELDd8= +cloud.google.com/go/texttospeech v1.5.0/go.mod h1:oKPLhR4n4ZdQqWKURdwxMy0uiTS1xU161C8W57Wkea4= +cloud.google.com/go/texttospeech v1.6.0/go.mod h1:YmwmFT8pj1aBblQOI3TfKmwibnsfvhIBzPXcW4EBovc= +cloud.google.com/go/texttospeech v1.7.1/go.mod h1:m7QfG5IXxeneGqTapXNxv2ItxP/FS0hCZBwXYqucgSk= +cloud.google.com/go/tpu v1.3.0/go.mod h1:aJIManG0o20tfDQlRIej44FcwGGl/cD0oiRyMKG19IQ= +cloud.google.com/go/tpu v1.4.0/go.mod h1:mjZaX8p0VBgllCzF6wcU2ovUXN9TONFLd7iz227X2Xg= +cloud.google.com/go/tpu v1.5.0/go.mod h1:8zVo1rYDFuW2l4yZVY0R0fb/v44xLh3llq7RuV61fPM= +cloud.google.com/go/tpu v1.6.1/go.mod h1:sOdcHVIgDEEOKuqUoi6Fq53MKHJAtOwtz0GuKsWSH3E= +cloud.google.com/go/trace v1.3.0/go.mod h1:FFUE83d9Ca57C+K8rDl/Ih8LwOzWIV1krKgxg6N0G28= +cloud.google.com/go/trace v1.4.0/go.mod h1:UG0v8UBqzusp+z63o7FK74SdFE+AXpCLdFb1rshXG+Y= +cloud.google.com/go/trace v1.8.0/go.mod h1:zH7vcsbAhklH8hWFig58HvxcxyQbaIqMarMg9hn5ECA= +cloud.google.com/go/trace v1.9.0/go.mod h1:lOQqpE5IaWY0Ixg7/r2SjixMuc6lfTFeO4QGM4dQWOk= +cloud.google.com/go/trace v1.10.1/go.mod h1:gbtL94KE5AJLH3y+WVpfWILmqgc6dXcqgNXdOPAQTYk= +cloud.google.com/go/translate v1.3.0/go.mod h1:gzMUwRjvOqj5i69y/LYLd8RrNQk+hOmIXTi9+nb3Djs= +cloud.google.com/go/translate v1.4.0/go.mod h1:06Dn/ppvLD6WvA5Rhdp029IX2Mi3Mn7fpMRLPvXT5Wg= +cloud.google.com/go/translate v1.5.0/go.mod h1:29YDSYveqqpA1CQFD7NQuP49xymq17RXNaUDdc0mNu0= +cloud.google.com/go/translate v1.6.0/go.mod h1:lMGRudH1pu7I3n3PETiOB2507gf3HnfLV8qlkHZEyos= +cloud.google.com/go/translate v1.7.0/go.mod h1:lMGRudH1pu7I3n3PETiOB2507gf3HnfLV8qlkHZEyos= +cloud.google.com/go/translate v1.8.1/go.mod h1:d1ZH5aaOA0CNhWeXeC8ujd4tdCFw8XoNWRljklu5RHs= +cloud.google.com/go/translate v1.8.2/go.mod h1:d1ZH5aaOA0CNhWeXeC8ujd4tdCFw8XoNWRljklu5RHs= +cloud.google.com/go/video v1.8.0/go.mod h1:sTzKFc0bUSByE8Yoh8X0mn8bMymItVGPfTuUBUyRgxk= +cloud.google.com/go/video v1.9.0/go.mod h1:0RhNKFRF5v92f8dQt0yhaHrEuH95m068JYOvLZYnJSw= +cloud.google.com/go/video v1.12.0/go.mod h1:MLQew95eTuaNDEGriQdcYn0dTwf9oWiA4uYebxM5kdg= +cloud.google.com/go/video v1.13.0/go.mod h1:ulzkYlYgCp15N2AokzKjy7MQ9ejuynOJdf1tR5lGthk= +cloud.google.com/go/video v1.14.0/go.mod h1:SkgaXwT+lIIAKqWAJfktHT/RbgjSuY6DobxEp0C5yTQ= +cloud.google.com/go/video v1.15.0/go.mod h1:SkgaXwT+lIIAKqWAJfktHT/RbgjSuY6DobxEp0C5yTQ= +cloud.google.com/go/video v1.17.1/go.mod h1:9qmqPqw/Ib2tLqaeHgtakU+l5TcJxCJbhFXM7UJjVzU= +cloud.google.com/go/video v1.19.0/go.mod h1:9qmqPqw/Ib2tLqaeHgtakU+l5TcJxCJbhFXM7UJjVzU= +cloud.google.com/go/videointelligence v1.6.0/go.mod h1:w0DIDlVRKtwPCn/C4iwZIJdvC69yInhW0cfi+p546uU= +cloud.google.com/go/videointelligence v1.7.0/go.mod h1:k8pI/1wAhjznARtVT9U1llUaFNPh7muw8QyOUpavru4= +cloud.google.com/go/videointelligence v1.8.0/go.mod h1:dIcCn4gVDdS7yte/w+koiXn5dWVplOZkE+xwG9FgK+M= +cloud.google.com/go/videointelligence v1.9.0/go.mod h1:29lVRMPDYHikk3v8EdPSaL8Ku+eMzDljjuvRs105XoU= +cloud.google.com/go/videointelligence v1.10.0/go.mod h1:LHZngX1liVtUhZvi2uNS0VQuOzNi2TkY1OakiuoUOjU= +cloud.google.com/go/videointelligence v1.11.1/go.mod h1:76xn/8InyQHarjTWsBR058SmlPCwQjgcvoW0aZykOvo= +cloud.google.com/go/vision v1.2.0/go.mod h1:SmNwgObm5DpFBme2xpyOyasvBc1aPdjvMk2bBk0tKD0= +cloud.google.com/go/vision/v2 v2.2.0/go.mod h1:uCdV4PpN1S0jyCyq8sIM42v2Y6zOLkZs+4R9LrGYwFo= +cloud.google.com/go/vision/v2 v2.3.0/go.mod h1:UO61abBx9QRMFkNBbf1D8B1LXdS2cGiiCRx0vSpZoUo= +cloud.google.com/go/vision/v2 v2.4.0/go.mod h1:VtI579ll9RpVTrdKdkMzckdnwMyX2JILb+MhPqRbPsY= +cloud.google.com/go/vision/v2 v2.5.0/go.mod h1:MmaezXOOE+IWa+cS7OhRRLK2cNv1ZL98zhqFFZaaH2E= +cloud.google.com/go/vision/v2 v2.6.0/go.mod h1:158Hes0MvOS9Z/bDMSFpjwsUrZ5fPrdwuyyvKSGAGMY= +cloud.google.com/go/vision/v2 v2.7.0/go.mod h1:H89VysHy21avemp6xcf9b9JvZHVehWbET0uT/bcuY/0= +cloud.google.com/go/vision/v2 v2.7.2/go.mod h1:jKa8oSYBWhYiXarHPvP4USxYANYUEdEsQrloLjrSwJU= +cloud.google.com/go/vmmigration v1.2.0/go.mod h1:IRf0o7myyWFSmVR1ItrBSFLFD/rJkfDCUTO4vLlJvsE= +cloud.google.com/go/vmmigration v1.3.0/go.mod h1:oGJ6ZgGPQOFdjHuocGcLqX4lc98YQ7Ygq8YQwHh9A7g= +cloud.google.com/go/vmmigration v1.5.0/go.mod h1:E4YQ8q7/4W9gobHjQg4JJSgXXSgY21nA5r8swQV+Xxc= +cloud.google.com/go/vmmigration v1.6.0/go.mod h1:bopQ/g4z+8qXzichC7GW1w2MjbErL54rk3/C843CjfY= +cloud.google.com/go/vmmigration v1.7.1/go.mod h1:WD+5z7a/IpZ5bKK//YmT9E047AD+rjycCAvyMxGJbro= +cloud.google.com/go/vmwareengine v0.1.0/go.mod h1:RsdNEf/8UDvKllXhMz5J40XxDrNJNN4sagiox+OI208= +cloud.google.com/go/vmwareengine v0.2.2/go.mod h1:sKdctNJxb3KLZkE/6Oui94iw/xs9PRNC2wnNLXsHvH8= +cloud.google.com/go/vmwareengine v0.3.0/go.mod h1:wvoyMvNWdIzxMYSpH/R7y2h5h3WFkx6d+1TIsP39WGY= +cloud.google.com/go/vmwareengine v0.4.1/go.mod h1:Px64x+BvjPZwWuc4HdmVhoygcXqEkGHXoa7uyfTgSI0= +cloud.google.com/go/vmwareengine v1.0.0/go.mod h1:Px64x+BvjPZwWuc4HdmVhoygcXqEkGHXoa7uyfTgSI0= +cloud.google.com/go/vpcaccess v1.4.0/go.mod h1:aQHVbTWDYUR1EbTApSVvMq1EnT57ppDmQzZ3imqIk4w= +cloud.google.com/go/vpcaccess v1.5.0/go.mod h1:drmg4HLk9NkZpGfCmZ3Tz0Bwnm2+DKqViEpeEpOq0m8= +cloud.google.com/go/vpcaccess v1.6.0/go.mod h1:wX2ILaNhe7TlVa4vC5xce1bCnqE3AeH27RV31lnmZes= +cloud.google.com/go/vpcaccess v1.7.1/go.mod h1:FogoD46/ZU+JUBX9D606X21EnxiszYi2tArQwLY4SXs= +cloud.google.com/go/webrisk v1.4.0/go.mod h1:Hn8X6Zr+ziE2aNd8SliSDWpEnSS1u4R9+xXZmFiHmGE= +cloud.google.com/go/webrisk v1.5.0/go.mod h1:iPG6fr52Tv7sGk0H6qUFzmL3HHZev1htXuWDEEsqMTg= +cloud.google.com/go/webrisk v1.6.0/go.mod h1:65sW9V9rOosnc9ZY7A7jsy1zoHS5W9IAXv6dGqhMQMc= +cloud.google.com/go/webrisk v1.7.0/go.mod h1:mVMHgEYH0r337nmt1JyLthzMr6YxwN1aAIEc2fTcq7A= +cloud.google.com/go/webrisk v1.8.0/go.mod h1:oJPDuamzHXgUc+b8SiHRcVInZQuybnvEW72PqTc7sSg= +cloud.google.com/go/webrisk v1.9.1/go.mod h1:4GCmXKcOa2BZcZPn6DCEvE7HypmEJcJkr4mtM+sqYPc= +cloud.google.com/go/websecurityscanner v1.3.0/go.mod h1:uImdKm2wyeXQevQJXeh8Uun/Ym1VqworNDlBXQevGMo= +cloud.google.com/go/websecurityscanner v1.4.0/go.mod h1:ebit/Fp0a+FWu5j4JOmJEV8S8CzdTkAS77oDsiSqYWQ= +cloud.google.com/go/websecurityscanner v1.5.0/go.mod h1:Y6xdCPy81yi0SQnDY1xdNTNpfY1oAgXUlcfN3B3eSng= +cloud.google.com/go/websecurityscanner v1.6.1/go.mod h1:Njgaw3rttgRHXzwCB8kgCYqv5/rGpFCsBOvPbYgszpg= +cloud.google.com/go/workflows v1.6.0/go.mod h1:6t9F5h/unJz41YqfBmqSASJSXccBLtD1Vwf+KmJENM0= +cloud.google.com/go/workflows v1.7.0/go.mod h1:JhSrZuVZWuiDfKEFxU0/F1PQjmpnpcoISEXH2bcHC3M= +cloud.google.com/go/workflows v1.8.0/go.mod h1:ysGhmEajwZxGn1OhGOGKsTXc5PyxOc0vfKf5Af+to4M= +cloud.google.com/go/workflows v1.9.0/go.mod h1:ZGkj1aFIOd9c8Gerkjjq7OW7I5+l6cSvT3ujaO/WwSA= +cloud.google.com/go/workflows v1.10.0/go.mod h1:fZ8LmRmZQWacon9UCX1r/g/DfAXx5VcPALq2CxzdePw= +cloud.google.com/go/workflows v1.11.1/go.mod h1:Z+t10G1wF7h8LgdY/EmRcQY8ptBD/nvofaL6FqlET6g= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8= +git.sr.ht/~sbinet/gg v0.3.1/go.mod h1:KGYtlADtqsqANL9ueOFkWymvzUvLMQllU5Ixo+8v3pc= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= +github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= +github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= +github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= +github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= +github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0= +github.com/apache/arrow/go/v11 v11.0.0/go.mod h1:Eg5OsL5H+e299f7u5ssuXsuHQVEGC4xei5aX110hRiI= +github.com/apache/arrow/go/v12 v12.0.0/go.mod h1:d+tV/eHZZ7Dz7RPrFKtPK02tpr+c9/PEd/zm8mDS9Vg= +github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU= +github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= +github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20220314180256-7f1daf1720fc/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20230105202645-06c439db220b/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20230310173818-32f1caf87195/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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/getsentry/sentry-go v0.25.0 h1:q6Eo+hS+yoJlTO3uu/azhQadsD8V+jQn2D8VvX1eOyI= -github.com/getsentry/sentry-go v0.25.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= +github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= +github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= +github.com/envoyproxy/go-control-plane v0.10.3/go.mod h1:fJJn/j26vwOu972OllsvAgJJM//w9BV6Fxbg2LuVd34= +github.com/envoyproxy/go-control-plane v0.11.0/go.mod h1:VnHyVMpzcLvCFt9yUz1UnCwHLhwx1WguiVDV7pTG/tI= +github.com/envoyproxy/go-control-plane v0.11.1-0.20230524094728-9239064ad72f/go.mod h1:sfYdkwUW4BA3PbKjySwjJy+O4Pu0h62rlqCMHNk+K+Q= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v0.6.7/go.mod h1:dyJXwwfPK2VSqiB9Klm1J6romD608Ba7Hij42vrOBCo= +github.com/envoyproxy/protoc-gen-validate v0.9.1/go.mod h1:OKNgG7TCp5pF4d6XftA0++PMirau2/yoOwVac3AbF2w= +github.com/envoyproxy/protoc-gen-validate v0.10.0/go.mod h1:DRjgyB0I43LtJapqN6NiRwroiAU2PaFuvk/vjgh61ss= +github.com/envoyproxy/protoc-gen-validate v0.10.1/go.mod h1:DRjgyB0I43LtJapqN6NiRwroiAU2PaFuvk/vjgh61ss= +github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yih2JAcL6Ls/kVkSE8GFACTGVnMPruw= +github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a/go.mod h1:7Ga40egUymuWXxAe151lTNnCv97MddSOVsjpPPkityA= +github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/getsentry/sentry-go v0.22.0 h1:XNX9zKbv7baSEI65l+H1GEJgSeIC1c7EN5kluWaP6dM= +github.com/getsentry/sentry-go v0.22.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= -github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4= -github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4= -github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= -github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g= +github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3T0ecnM9pNujks= +github.com/go-fonts/liberation v0.1.1/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY= +github.com/go-fonts/liberation v0.2.0/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY= +github.com/go-fonts/stix v0.1.0/go.mod h1:w/c1f0ldAUlJmLBvlbkvVXLAD+tAMqobIIQpmnUIzUY= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U= +github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpxhq9o2S/CELCSUxEWWAuoCUcVCQWv7G2OCk= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-pdf/fpdf v0.5.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= +github.com/go-pdf/fpdf v0.6.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/googleapis v0.0.0-20180223154316-0cd9801be74a/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= +github.com/gogo/googleapis v1.4.1 h1:1Yx4Myt7BxzvUr5ldGSbwYiZG6t9wGBZ+8/fX3Wvtq0= +github.com/gogo/googleapis v1.4.1/go.mod h1:2lpHqI5OcWCtVElxXnPt+s8oJvMpySlOyM6xDCrzib4= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/gogo/status v1.1.1 h1:DuHXlSFHNKqTQ+/ACf5Vs6r4X/dH2EgIzR9Vr+H65kg= +github.com/gogo/status v1.1.1/go.mod h1:jpG3dM5QPcqu19Hg8lkUhBFBa3TcLs1DG7+2Jqci7oU= 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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= +github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +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/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +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.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/flatbuffers v2.0.8+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +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/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= +github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/s2a-go v0.1.0/go.mod h1:OJpEgntRZo8ugHpF9hkoLJbS5dSI20XZeXJ9JVywLlM= +github.com/google/s2a-go v0.1.3/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A= +github.com/google/s2a-go v0.1.4/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= -github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= -github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= -github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= -github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= -github.com/hashicorp/hcl/v2 v2.19.1 h1://i05Jqznmb2EXqa39Nsvyan2o5XyMowW5fnCKW5RPI= -github.com/hashicorp/hcl/v2 v2.19.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= -github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= +github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= +github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg= +github.com/googleapis/enterprise-certificate-proxy v0.2.1/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= +github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= +github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= +github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM= +github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM= +github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c= +github.com/googleapis/gax-go/v2 v2.5.1/go.mod h1:h6B0KMMFNtI2ddbGJn3T3ZbwkeT6yqEF02fYlzkUCyo= +github.com/googleapis/gax-go/v2 v2.6.0/go.mod h1:1mjbznJAPHFpesgE5ucqfYEscaz5kMdcIDwU/6+DDoY= +github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8= +github.com/googleapis/gax-go/v2 v2.7.1/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI= +github.com/googleapis/gax-go/v2 v2.8.0/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI= +github.com/googleapis/gax-go/v2 v2.10.0/go.mod h1:4UOEnMCrxsSqQ940WnTiD6qJ63le2ev3xfyagutxiPw= +github.com/googleapis/gax-go/v2 v2.11.0/go.mod h1:DxmR61SGKkGLa2xigwuZIQpkCI2S5iydzRfb3peWZJI= +github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4= +github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw= +github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= +github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.1 h1:5I9etrGkLrN+2XPCsi6XLlV5DITbSL/xBZdmAxFcXPI= +github.com/jackc/pgx/v5 v5.5.1/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= +github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= @@ -46,64 +1007,896 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4= -github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= -github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= -github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= -github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= -github.com/mr-linch/go-tg v0.11.0 h1:vgL9TaKGHxA5Iid5bNyHUQMeTExwthVNJTkG2wHh2tA= -github.com/mr-linch/go-tg v0.11.0/go.mod h1:ySHBZ68Wl5Vgd6RAjNf9wbnwEN1P8deawEB/qr3GbDM= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= +github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= +github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= github.com/nicklaw5/helix/v2 v2.25.2 h1:diGnmRnUpNk8vYVM1vOjUo5PGZnr8atbkUYT78T6evc= github.com/nicklaw5/helix/v2 v2.25.2/go.mod h1:zZcKsyyBWDli34x3QleYsVMiiNGMXPAEU5NjsiZDtvY= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= +github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY= +github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= +github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= 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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= +github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A= +github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= +github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk= github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= -github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= -github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/samber/slog-common v0.11.0 h1:JdESCaXcEwdtoTCYHKQFfHGbWN2vZJq0DDGEE/lwTUQ= +github.com/samber/slog-common v0.11.0/go.mod h1:Qjrfhwk79XiCIhBj8+jTq1Cr0u9rlWbjawh3dWXzaHk= +github.com/samber/slog-multi v1.0.2 h1:6BVH9uHGAsiGkbbtQgAOQJMpKgV8unMrHhhJaw+X1EQ= +github.com/samber/slog-multi v1.0.2/go.mod h1:uLAvHpGqbYgX4FSL0p1ZwoLuveIAJvBECtE07XmYvFo= +github.com/samber/slog-sentry/v2 v2.2.1 h1:W1ztzl5t1QYZkdQt9uZywgKpQFThyUoU+habjlTk9b0= +github.com/samber/slog-sentry/v2 v2.2.1/go.mod h1:Bo0hgH6/fqXoF2ZwtHLHFh4uyA/YbXykQR5aaI+IkYc= +github.com/samber/slog-zerolog/v2 v2.1.0 h1:QdS3X5gLqrP3TVS7fAu3gk1e92LsZAdk5AnRHFc8X0E= +github.com/samber/slog-zerolog/v2 v2.1.0/go.mod h1:9WQLMvggH2Tt0yt0D9hJ6rpnOfAKLpdvF8bqRqF4YY4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= +github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= +github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.1 h1:4VhoImhV/Bm0ToFkXFi8hXNXwpDRZ/ynw3amt82mzq0= -github.com/stretchr/objx v0.5.1/go.mod h1:/iHQpkQwBD6DLUmQ4pE+s1TXdob1mORJ4/UFdrifcy0= 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.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +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= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc= -github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4= -github.com/zclconf/go-cty v1.14.1 h1:t9fyA35fwjjUMcmL5hLER+e/rEPqrbCK1/OSE4SI9KA= -github.com/zclconf/go-cty v1.14.1/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= +go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= +go.temporal.io/api v1.24.0 h1:WWjMYSXNh4+T4Y4jq1e/d9yCNnWoHhq4bIwflHY6fic= +go.temporal.io/api v1.24.0/go.mod h1:4ackgCMjQHMpJYr1UQ6Tr/nknIqFkJ6dZ/SZsGv+St0= +go.temporal.io/sdk v1.25.1 h1:jC9l9vHHz5OJ7PR6OjrpYSN4+uEG0bLe5rdF9nlMSGk= +go.temporal.io/sdk v1.25.1/go.mod h1:X7iFKZpsj90BfszfpFCzLX8lwEJXbnRrl351/HyEgmU= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/dig v1.17.0 h1:5Chju+tUvcC+N7N6EV08BJz41UZuO3BmHcN4A287ZLI= +go.uber.org/dig v1.17.0/go.mod h1:rTxpf7l5I0eBTlE6/9RL+lDybC7WFwY2QH55ZSjy1mU= +go.uber.org/fx v1.20.1 h1:zVwVQGS8zYvhh9Xxcu4w1M6ESyeMzebzj2NbSayZ4Mk= +go.uber.org/fx v1.20.1/go.mod h1:iSYNbHf2y55acNCwCXKx7LbWb5WG1Bnue5RDXz1OREg= go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= 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.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= -golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= -golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= -golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= -golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= +golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3/go.mod h1:NOZ3BPKG0ec/BKJQgnvsSFpcKLM5xXVWnvZS97DWHgE= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20220827204233-334a2380cb91/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc= +golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= +golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20200430140353-33d19683fad8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20210216034530-4410531fe030/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20210607152325-775e3b0c77b9/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= +golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= +golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= +golang.org/x/image v0.0.0-20220302094943-723b81ca9867/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= +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/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= +golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU= +golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +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-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220617184016-355a448f1bc9/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.0.0-20221012135044-0b7e1fb9d458/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +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/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= +golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= +golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/oauth2 v0.0.0-20221006150949-b44042a4b9c1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec= +golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I= +golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= +golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= +golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= +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/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= +golang.org/x/sync v0.2.0/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-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210304124612-50617c2ba197/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +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/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 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/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20220922220347-f3bd1da661af/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +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-20190206041539-40960b6deb8e/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-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= +golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo= +golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= +gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= +gonum.org/v1/gonum v0.9.3/go.mod h1:TZumC3NeyVQskjXqmyWt4S3bINhy7B4eYwW69EbyX+0= +gonum.org/v1/gonum v0.11.0/go.mod h1:fSG4YDCxxUZQJ7rKsQrj0gMOg00Il0Z96/qMA4bVQhA= +gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= +gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= +gonum.org/v1/plot v0.9.0/go.mod h1:3Pcqqmp6RHvJI72kgb8fThyUnav364FOsdDo2aGW5lY= +gonum.org/v1/plot v0.10.1/go.mod h1:VZW5OlhkL1mysU9vaqNHnsy86inf6Ot+jB3r+BczCEo= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= +google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= +google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo= +google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4= +google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw= +google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU= +google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k= +google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= +google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= +google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI= +google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= +google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo= +google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g= +google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA= +google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8= +google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs= +google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= +google.golang.org/api v0.77.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= +google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw= +google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg= +google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o= +google.golang.org/api v0.85.0/go.mod h1:AqZf8Ep9uZ2pyTvgL+x0D3Zt0eoT9b5E8fmzfu6FO2g= +google.golang.org/api v0.90.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw= +google.golang.org/api v0.93.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw= +google.golang.org/api v0.95.0/go.mod h1:eADj+UBuxkh5zlrSntJghuNeg8HwQ1w5lTKkuqaETEI= +google.golang.org/api v0.96.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= +google.golang.org/api v0.97.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= +google.golang.org/api v0.98.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= +google.golang.org/api v0.99.0/go.mod h1:1YOf74vkVndF7pG6hIHuINsM7eWwpVTAfNMNiL91A08= +google.golang.org/api v0.100.0/go.mod h1:ZE3Z2+ZOr87Rx7dqFsdRQkRBk36kDtp/h+QpHbB7a70= +google.golang.org/api v0.102.0/go.mod h1:3VFl6/fzoA+qNuS1N1/VfXY4LjoXN/wzeIp7TweWwGo= +google.golang.org/api v0.103.0/go.mod h1:hGtW6nK1AC+d9si/UBhw8Xli+QMOf6xyNAyJw4qU9w0= +google.golang.org/api v0.106.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY= +google.golang.org/api v0.107.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY= +google.golang.org/api v0.108.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY= +google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI= +google.golang.org/api v0.111.0/go.mod h1:qtFHvU9mhgTJegR31csQ+rwxyUTHOKFqCKWp1J0fdw0= +google.golang.org/api v0.114.0/go.mod h1:ifYI2ZsFK6/uGddGfAD5BMxlnkBqCmqHSDUVi45N5Yg= +google.golang.org/api v0.118.0/go.mod h1:76TtD3vkgmZ66zZzp72bUUklpmQmKlhh6sYtIjYK+5E= +google.golang.org/api v0.122.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms= +google.golang.org/api v0.124.0/go.mod h1:xu2HQurE5gi/3t1aFCvhPD781p0a3p11sdunTJ2BlP4= +google.golang.org/api v0.125.0/go.mod h1:mBwVAtz+87bEN6CbA1GtZPDOqY2R5ONPqJeIlvyo4Aw= +google.golang.org/api v0.126.0/go.mod h1:mBwVAtz+87bEN6CbA1GtZPDOqY2R5ONPqJeIlvyo4Aw= +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/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180518175338-11a468237815/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +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-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210329143202-679c6ae281ee/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= +google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= +google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= +google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= +google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= +google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= +google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= +google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= +google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w= +google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E= +google.golang.org/genproto v0.0.0-20220329172620-7be39ac1afc7/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220502173005-c8bf987b8c21/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= +google.golang.org/genproto v0.0.0-20220722212130-b98a9ff5e252/go.mod h1:GkXuJDJ6aQ7lnJcRF+SJVgFdQhypqgl3LB1C9vabdRE= +google.golang.org/genproto v0.0.0-20220801145646-83ce21fca29f/go.mod h1:iHe1svFLAZg9VWz891+QbRMwUv9O/1Ww+/mngYeThbc= +google.golang.org/genproto v0.0.0-20220815135757-37a418bb8959/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= +google.golang.org/genproto v0.0.0-20220817144833-d7fd3f11b9b1/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= +google.golang.org/genproto v0.0.0-20220822174746-9e6da59bd2fc/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= +google.golang.org/genproto v0.0.0-20220829144015-23454907ede3/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= +google.golang.org/genproto v0.0.0-20220829175752-36a9c930ecbf/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk= +google.golang.org/genproto v0.0.0-20220913154956-18f8339a66a5/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= +google.golang.org/genproto v0.0.0-20220914142337-ca0e39ece12f/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= +google.golang.org/genproto v0.0.0-20220915135415-7fd63a7952de/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= +google.golang.org/genproto v0.0.0-20220916172020-2692e8806bfa/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= +google.golang.org/genproto v0.0.0-20220919141832-68c03719ef51/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= +google.golang.org/genproto v0.0.0-20220920201722-2b89144ce006/go.mod h1:ht8XFiar2npT/g4vkk7O0WYS1sHOHbdujxbEp7CJWbw= +google.golang.org/genproto v0.0.0-20220926165614-551eb538f295/go.mod h1:woMGP53BroOrRY3xTxlbr8Y3eB/nzAvvFM83q7kG2OI= +google.golang.org/genproto v0.0.0-20220926220553-6981cbe3cfce/go.mod h1:woMGP53BroOrRY3xTxlbr8Y3eB/nzAvvFM83q7kG2OI= +google.golang.org/genproto v0.0.0-20221010155953-15ba04fc1c0e/go.mod h1:3526vdqwhZAwq4wsRUaVG555sVgsNmIjRtO7t/JH29U= +google.golang.org/genproto v0.0.0-20221014173430-6e2ab493f96b/go.mod h1:1vXfmgAz9N9Jx0QA82PqRVauvCz1SGSz739p0f183jM= +google.golang.org/genproto v0.0.0-20221014213838-99cd37c6964a/go.mod h1:1vXfmgAz9N9Jx0QA82PqRVauvCz1SGSz739p0f183jM= +google.golang.org/genproto v0.0.0-20221024153911-1573dae28c9c/go.mod h1:9qHF0xnpdSfF6knlcsnpzUu5y+rpwgbvsyGAZPBMg4s= +google.golang.org/genproto v0.0.0-20221024183307-1bc688fe9f3e/go.mod h1:9qHF0xnpdSfF6knlcsnpzUu5y+rpwgbvsyGAZPBMg4s= +google.golang.org/genproto v0.0.0-20221027153422-115e99e71e1c/go.mod h1:CGI5F/G+E5bKwmfYo09AXuVN4dD894kIKUFmVbP2/Fo= +google.golang.org/genproto v0.0.0-20221109142239-94d6d90a7d66/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= +google.golang.org/genproto v0.0.0-20221114212237-e4508ebdbee1/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= +google.golang.org/genproto v0.0.0-20221117204609-8f9c96812029/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= +google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= +google.golang.org/genproto v0.0.0-20221201164419-0e50fba7f41c/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= +google.golang.org/genproto v0.0.0-20221201204527-e3fa12d562f3/go.mod h1:rZS5c/ZVYMaOGBfO68GWtjOw/eLaZM1X6iVtgjZ+EWg= +google.golang.org/genproto v0.0.0-20221202195650-67e5cbc046fd/go.mod h1:cTsE614GARnxrLsqKREzmNYJACSWWpAWdNMwnD7c2BE= +google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= +google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= +google.golang.org/genproto v0.0.0-20230112194545-e10362b5ecf9/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= +google.golang.org/genproto v0.0.0-20230113154510-dbe35b8444a5/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= +google.golang.org/genproto v0.0.0-20230123190316-2c411cf9d197/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= +google.golang.org/genproto v0.0.0-20230124163310-31e0e69b6fc2/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= +google.golang.org/genproto v0.0.0-20230125152338-dcaf20b6aeaa/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= +google.golang.org/genproto v0.0.0-20230127162408-596548ed4efa/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= +google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= +google.golang.org/genproto v0.0.0-20230216225411-c8e22ba71e44/go.mod h1:8B0gmkoRebU8ukX6HP+4wrVQUY1+6PkQ44BSyIlflHA= +google.golang.org/genproto v0.0.0-20230222225845-10f96fb3dbec/go.mod h1:3Dl5ZL0q0isWJt+FVcfpQyirqemEuLAK/iFvg1UP1Hw= +google.golang.org/genproto v0.0.0-20230223222841-637eb2293923/go.mod h1:3Dl5ZL0q0isWJt+FVcfpQyirqemEuLAK/iFvg1UP1Hw= +google.golang.org/genproto v0.0.0-20230303212802-e74f57abe488/go.mod h1:TvhZT5f700eVlTNwND1xoEZQeWTB2RY/65kplwl/bFA= +google.golang.org/genproto v0.0.0-20230306155012-7f2fa6fef1f4/go.mod h1:NWraEVixdDnqcqQ30jipen1STv2r/n24Wb7twVTGR4s= +google.golang.org/genproto v0.0.0-20230320184635-7606e756e683/go.mod h1:NWraEVixdDnqcqQ30jipen1STv2r/n24Wb7twVTGR4s= +google.golang.org/genproto v0.0.0-20230323212658-478b75c54725/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= +google.golang.org/genproto v0.0.0-20230330154414-c0448cd141ea/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= +google.golang.org/genproto v0.0.0-20230331144136-dcfb400f0633/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= +google.golang.org/genproto v0.0.0-20230403163135-c38d8f061ccd/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak= +google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= +google.golang.org/genproto v0.0.0-20230525234025-438c736192d0/go.mod h1:9ExIQyXL5hZrHzQceCwuSYwZZ5QZBazOcprJ5rgs3lY= +google.golang.org/genproto v0.0.0-20230526161137-0005af68ea54/go.mod h1:zqTuNwFlFRsw5zIts5VnzLQxSRqh+CGOTVMlYbY0Eyk= +google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:xZnkP7mREFX5MORlOPEzLMr+90PPZQ2QWzrVTWfAq64= +google.golang.org/genproto v0.0.0-20230629202037-9506855d4529/go.mod h1:xZnkP7mREFX5MORlOPEzLMr+90PPZQ2QWzrVTWfAq64= +google.golang.org/genproto v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:O9kGHb51iE/nOGvQaDUuadVYqovW56s5emA88lQnj6Y= +google.golang.org/genproto v0.0.0-20230726155614-23370e0ffb3e/go.mod h1:0ggbjUrZYpy1q+ANUS30SEoGZ53cdfwtbuG7Ptgy108= +google.golang.org/genproto v0.0.0-20230803162519-f966b187b2e5/go.mod h1:oH/ZOT02u4kWEp7oYBGYFFkCdKS/uYR9Z7+0/xuuFp8= +google.golang.org/genproto v0.0.0-20230815205213-6bfd019c3878 h1:Iveh6tGCJkHAjJgEqUQYGDGgbwmhjoAOz8kO/ajxefY= +google.golang.org/genproto v0.0.0-20230815205213-6bfd019c3878/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4= +google.golang.org/genproto/googleapis/api v0.0.0-20230525234020-1aefcd67740a/go.mod h1:ts19tUU+Z0ZShN1y3aPyq2+O3d5FUNNgT6FtOzmrNn8= +google.golang.org/genproto/googleapis/api v0.0.0-20230525234035-dd9d682886f9/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig= +google.golang.org/genproto/googleapis/api v0.0.0-20230526203410-71b5a4ffd15e/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig= +google.golang.org/genproto/googleapis/api v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig= +google.golang.org/genproto/googleapis/api v0.0.0-20230629202037-9506855d4529/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig= +google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:mPBs5jNgx2GuQGvFwUvVKqtn6HsUw9nP64BedgvqEsQ= +google.golang.org/genproto/googleapis/api v0.0.0-20230726155614-23370e0ffb3e/go.mod h1:rsr7RhLuwsDKL7RmgDDCUc6yaGr1iqceVb5Wv6f6YvQ= +google.golang.org/genproto/googleapis/api v0.0.0-20230803162519-f966b187b2e5/go.mod h1:5DZzOUPCLYL3mNkQ0ms0F3EuUNZ7py1Bqeq6sxzI7/Q= +google.golang.org/genproto/googleapis/api v0.0.0-20230815205213-6bfd019c3878 h1:WGq4lvB/mlicysM/dUT3SBvijH4D3sm/Ny1A4wmt2CI= +google.golang.org/genproto/googleapis/api v0.0.0-20230815205213-6bfd019c3878/go.mod h1:KjSP20unUpOx5kyQUFa7k4OJg0qeJ7DEZflGDu2p6Bk= +google.golang.org/genproto/googleapis/bytestream v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:ylj+BE99M198VPbBh6A8d9n3w8fChvyLK3wwBOjXBFA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234015-3fc162c6f38a/go.mod h1:xURIpW9ES5+/GZhnV6beoEtxQrnkRGIfP5VQG2tCBLc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230526203410-71b5a4ffd15e/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230629202037-9506855d4529/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:8mL13HKkDa+IuJ8yruA3ci0q+0vsUz4m//+ottjwS5o= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230731190214-cbb8c96f2d6d/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230803162519-f966b187b2e5/go.mod h1:zBEcrKX2ZOcEkHWxBPAIvYUWOKKMIhYcmNiUIu2ji3I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230815205213-6bfd019c3878 h1:lv6/DhyiFFGsmzxbsUUTOkN29II+zeWHxvT8Lpdxsv0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230815205213-6bfd019c3878/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M= +google.golang.org/grpc v1.12.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= +google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= +google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= +google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= +google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= +google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= +google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= +google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.48.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= +google.golang.org/grpc v1.50.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= +google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= +google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsAIPww= +google.golang.org/grpc v1.52.0/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5vorUY= +google.golang.org/grpc v1.52.3/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5vorUY= +google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw= +google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g= +google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8= +google.golang.org/grpc v1.56.2/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s= +google.golang.org/grpc v1.57.0 h1:kfzNeI/klCGD2YPMUlaGNT3pxvYfga7smW3Vth8Zsiw= +google.golang.org/grpc v1.57.0/go.mod h1:Sd+9RMTACXwmub0zcNY2c4arhtrbBYD1AUHI/dt16Mo= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +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.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.29.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +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/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/guregu/null.v4 v4.0.0 h1:1Wm3S1WEA2I26Kq+6vcW+w0gcDo44YKYD7YIEJNHDjg= +gopkg.in/guregu/null.v4 v4.0.0/go.mod h1:YoQhUrADuG3i9WqesrCmpNRwm1ypAgSHYqoOcTu/JrI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/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= +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= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= +lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.36.0/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= +modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= +modernc.org/cc/v3 v3.36.3/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= +modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20= +modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= +modernc.org/ccgo/v3 v3.0.0-20220428102840-41399a37e894/go.mod h1:eI31LL8EwEBKPpNpA4bU1/i+sKOwOrQy8D87zWUcRZc= +modernc.org/ccgo/v3 v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9YdWGjxWsdkPcZyRPHqrOvJxaKAKGw= +modernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI= +modernc.org/ccgo/v3 v3.16.4/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ= +modernc.org/ccgo/v3 v3.16.6/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ= +modernc.org/ccgo/v3 v3.16.8/go.mod h1:zNjwkizS+fIFDrDjIAgBSCLkWbJuHF+ar3QRn+Z9aws= +modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo= +modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8/go.mod h1:fUB3Vn0nVPReA+7IG7yZDfjv1TMWjhQP8gCxrFAtL5g= +modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= +modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= +modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= +modernc.org/libc v0.0.0-20220428101251-2d5f3daf273b/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA= +modernc.org/libc v1.16.0/go.mod h1:N4LD6DBE9cf+Dzf9buBlzVJndKr/iJHG97vGLHYnb5A= +modernc.org/libc v1.16.1/go.mod h1:JjJE0eu4yeK7tab2n4S1w8tlWd9MxXLRzheaRnAKymU= +modernc.org/libc v1.16.17/go.mod h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU= +modernc.org/libc v1.16.19/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA= +modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0= +modernc.org/libc v1.17.1/go.mod h1:FZ23b+8LjxZs7XtFMbSzL/EhPxNbfZbErxEHc7cbD9s= +modernc.org/libc v1.17.4/go.mod h1:WNg2ZH56rDEwdropAJeZPQkXmDwh+JCA1s/htl6r2fA= +modernc.org/libc v1.18.0/go.mod h1:vj6zehR5bfc98ipowQOM2nIDUZnVew/wNC/2tOGS+q0= +modernc.org/libc v1.20.3/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0= +modernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI= +modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug= +modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.1.1/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw= +modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw= +modernc.org/memory v1.2.1/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4= +modernc.org/sqlite v1.18.2/go.mod h1:kvrTLEWgxUcHa2GfHBQtanR1H9ht3hTJNtKpzH9k1u0= +modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw= +modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= +modernc.org/tcl v1.13.1/go.mod h1:XOLfOwzhkljL4itZkK6T72ckMgvj0BDsnKNdZVUOecw= +modernc.org/tcl v1.13.2/go.mod h1:7CLiGIPo1M8Rv1Mitpv5akc2+8fxUd2y2UzC/MfMzy0= +modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/internal/announcesender/announce_sender.go b/internal/announcesender/announce_sender.go new file mode 100644 index 00000000..80ab5041 --- /dev/null +++ b/internal/announcesender/announce_sender.go @@ -0,0 +1,48 @@ +package announcesender + +import ( + "context" + + "github.com/google/uuid" +) + +//go:generate go run go.uber.org/mock/mockgen -source=announce_sender.go -destination=mocks/mock.go + +type AnnounceSender interface { + SendOnline(ctx context.Context, opts ChannelOnlineOpts) error + SendOffline(ctx context.Context, opts ChannelOfflineOpts) error + SendTitleChange(ctx context.Context, opts ChannelTitleChangeOpts) error + SendCategoryChange(ctx context.Context, opts ChannelCategoryChangeOpts) error + SendTitleAndCategoryChange(ctx context.Context, opts ChannelTitleAndCategoryChangeOpts) error +} + +type ChannelOnlineOpts struct { + ChannelID uuid.UUID + Category string + Title string + ThumbnailURL string +} + +type ChannelOfflineOpts struct { + ChannelID uuid.UUID +} + +type ChannelTitleChangeOpts struct { + ChannelID uuid.UUID + OldTitle string + NewTitle string +} + +type ChannelCategoryChangeOpts struct { + ChannelID uuid.UUID + OldCategory string + NewCategory string +} + +type ChannelTitleAndCategoryChangeOpts struct { + ChannelID uuid.UUID + OldCategory string + NewCategory string + OldTitle string + NewTitle string +} diff --git a/internal/announcesender/mocks/mock.go b/internal/announcesender/mocks/mock.go new file mode 100644 index 00000000..fc6f88eb --- /dev/null +++ b/internal/announcesender/mocks/mock.go @@ -0,0 +1,111 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: announce_sender.go +// +// Generated by this command: +// +// mockgen -source=announce_sender.go -destination=mocks/mock.go +// + +// Package mock_announcesender is a generated GoMock package. +package mock_announcesender + +import ( + context "context" + reflect "reflect" + + announcesender "github.com/satont/twitch-notifier/internal/announcesender" + gomock "go.uber.org/mock/gomock" +) + +// MockAnnounceSender is a mock of AnnounceSender interface. +type MockAnnounceSender struct { + ctrl *gomock.Controller + recorder *MockAnnounceSenderMockRecorder +} + +// MockAnnounceSenderMockRecorder is the mock recorder for MockAnnounceSender. +type MockAnnounceSenderMockRecorder struct { + mock *MockAnnounceSender +} + +// NewMockAnnounceSender creates a new mock instance. +func NewMockAnnounceSender(ctrl *gomock.Controller) *MockAnnounceSender { + mock := &MockAnnounceSender{ctrl: ctrl} + mock.recorder = &MockAnnounceSenderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAnnounceSender) EXPECT() *MockAnnounceSenderMockRecorder { + return m.recorder +} + +// SendCategoryChange mocks base method. +func (m *MockAnnounceSender) SendCategoryChange(ctx context.Context, opts announcesender.ChannelCategoryChangeOpts) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SendCategoryChange", ctx, opts) + ret0, _ := ret[0].(error) + return ret0 +} + +// SendCategoryChange indicates an expected call of SendCategoryChange. +func (mr *MockAnnounceSenderMockRecorder) SendCategoryChange(ctx, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendCategoryChange", reflect.TypeOf((*MockAnnounceSender)(nil).SendCategoryChange), ctx, opts) +} + +// SendOffline mocks base method. +func (m *MockAnnounceSender) SendOffline(ctx context.Context, opts announcesender.ChannelOfflineOpts) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SendOffline", ctx, opts) + ret0, _ := ret[0].(error) + return ret0 +} + +// SendOffline indicates an expected call of SendOffline. +func (mr *MockAnnounceSenderMockRecorder) SendOffline(ctx, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendOffline", reflect.TypeOf((*MockAnnounceSender)(nil).SendOffline), ctx, opts) +} + +// SendOnline mocks base method. +func (m *MockAnnounceSender) SendOnline(ctx context.Context, opts announcesender.ChannelOnlineOpts) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SendOnline", ctx, opts) + ret0, _ := ret[0].(error) + return ret0 +} + +// SendOnline indicates an expected call of SendOnline. +func (mr *MockAnnounceSenderMockRecorder) SendOnline(ctx, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendOnline", reflect.TypeOf((*MockAnnounceSender)(nil).SendOnline), ctx, opts) +} + +// SendTitleAndCategoryChange mocks base method. +func (m *MockAnnounceSender) SendTitleAndCategoryChange(ctx context.Context, opts announcesender.ChannelTitleAndCategoryChangeOpts) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SendTitleAndCategoryChange", ctx, opts) + ret0, _ := ret[0].(error) + return ret0 +} + +// SendTitleAndCategoryChange indicates an expected call of SendTitleAndCategoryChange. +func (mr *MockAnnounceSenderMockRecorder) SendTitleAndCategoryChange(ctx, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendTitleAndCategoryChange", reflect.TypeOf((*MockAnnounceSender)(nil).SendTitleAndCategoryChange), ctx, opts) +} + +// SendTitleChange mocks base method. +func (m *MockAnnounceSender) SendTitleChange(ctx context.Context, opts announcesender.ChannelTitleChangeOpts) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SendTitleChange", ctx, opts) + ret0, _ := ret[0].(error) + return ret0 +} + +// SendTitleChange indicates an expected call of SendTitleChange. +func (mr *MockAnnounceSenderMockRecorder) SendTitleChange(ctx, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendTitleChange", reflect.TypeOf((*MockAnnounceSender)(nil).SendTitleChange), ctx, opts) +} diff --git a/internal/announcesender/temporal/activities.go b/internal/announcesender/temporal/activities.go new file mode 100644 index 00000000..93cd4c37 --- /dev/null +++ b/internal/announcesender/temporal/activities.go @@ -0,0 +1,54 @@ +package temporal + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/satont/twitch-notifier/internal/domain" + "github.com/satont/twitch-notifier/internal/repository/channel" + "github.com/satont/twitch-notifier/internal/twitchclient" + "go.uber.org/fx" +) + +type ActivitiesOpts struct { + fx.In + + ChannelRepository channel.Repository + TwitchClient twitchclient.TwitchClient +} + +func NewActivities(opts ActivitiesOpts) *Activities { + return &Activities{ + channelRepository: opts.ChannelRepository, + twitchClient: opts.TwitchClient, + } +} + +type Activities struct { + channelRepository channel.Repository + twitchClient twitchclient.TwitchClient +} + +var ErrUnknownService = fmt.Errorf("unknown service") + +func (c *Activities) GetChannelInformation(ctx context.Context, channelID uuid.UUID) ( + *domain.PlatformChannelInformation, + error, +) { + channelEntity, err := c.channelRepository.GetById(ctx, channelID) + if err != nil { + return nil, err + } + + if channelEntity.Service == domain.StreamingServiceTwitch { + twitchChannel, err := c.twitchClient.GetChannelInformation(channelEntity.ChannelID) + if err != nil { + return nil, fmt.Errorf("failed to get twitch channel information: %w", err) + } + + return twitchChannel, nil + } + + return nil, fmt.Errorf("%w: %s", ErrUnknownService, channelEntity.Service) +} diff --git a/internal/announcesender/temporal/fx.go b/internal/announcesender/temporal/fx.go new file mode 100644 index 00000000..2f28fa4a --- /dev/null +++ b/internal/announcesender/temporal/fx.go @@ -0,0 +1,15 @@ +package temporal + +import ( + "github.com/satont/twitch-notifier/internal/announcesender" + "go.uber.org/fx" +) + +var Module = fx.Options( + fx.Provide( + NewActivities, + NewWorkflow, + fx.Annotate(NewImpl, fx.As(new(announcesender.AnnounceSender))), + ), + fx.Invoke(NewWorker), +) diff --git a/internal/announcesender/temporal/impl.go b/internal/announcesender/temporal/impl.go new file mode 100644 index 00000000..44ed5e8a --- /dev/null +++ b/internal/announcesender/temporal/impl.go @@ -0,0 +1,247 @@ +package temporal + +import ( + "context" + + "github.com/satont/twitch-notifier/internal/announcesender" + "github.com/satont/twitch-notifier/pkg/logger" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/log" + "go.uber.org/fx" +) + +type Opts struct { + fx.In + + Logger logger.Logger +} + +func NewImpl(opts Opts) (*AnnounceSenderTemporal, error) { + temporalClient, err := client.Dial( + client.Options{ + Logger: log.NewStructuredLogger(opts.Logger.GetSlog()), + }, + ) + if err != nil { + return nil, err + } + + return &AnnounceSenderTemporal{ + client: temporalClient, + }, nil +} + +var _ announcesender.AnnounceSender = (*AnnounceSenderTemporal)(nil) + +type AnnounceSenderTemporal struct { + client client.Client +} + +const queueName = "announcesender" + +func (c *AnnounceSenderTemporal) SendOnline( + ctx context.Context, + opts announcesender.ChannelOnlineOpts, +) error { + return nil +} + +func (c *AnnounceSenderTemporal) SendOffline( + ctx context.Context, + opts announcesender.ChannelOfflineOpts, +) error { + // followers, err := c.followRepository.GetByChannelID(ctx, opts.ChannelID) + // if err != nil { + // return fmt.Errorf("failed to get followers: %w", err) + // } + // + // for _, follower := range followers { + // followerChat, err := c.chatRepository.GetByID(ctx, follower.ChatID) + // if err != nil { + // return fmt.Errorf("failed to get followerChat: %w", err) + // } + // + // chatSettings, err := c.chatSettingsRepository.GetByChatID(ctx, followerChat.ID) + // if err != nil { + // return fmt.Errorf("failed to get followerChat settings: %w", err) + // } + // + // if !chatSettings.OfflineNotifications { + // continue + // } + // + // localizedString := c.localizer.MustLocalize( + // localizer.WithKey("offline"), + // localizer.WithLanguage(chatSettings.Language), + // localizer.WithAttribute("channelName", opts.ChannelID), + // localizer.WithAttribute("follower", follower), + // ) + // + // if followerChat.Service == domain.ChatServiceTelegram { + // err = c.messageSender.SendMessageTelegram( + // ctx, + // messagesender.TelegramOpts{ + // ServiceChatID: messagesender.MessageTarget{ + // ServiceChatID: followerChat.ChatID, + // }, + // Text: localizedString, + // }, + // ) + // if err != nil { + // return fmt.Errorf("failed to send message: %w", err) + // } + // } + // } + + return nil +} + +func (c *AnnounceSenderTemporal) SendTitleChange( + ctx context.Context, + opts announcesender.ChannelTitleChangeOpts, +) error { + // followers, err := c.followRepository.GetByChannelID(ctx, opts.ChannelID) + // if err != nil { + // return fmt.Errorf("failed to get followers: %w", err) + // } + // + // for _, follower := range followers { + // followerChat, err := c.chatRepository.GetByID(ctx, follower.ChatID) + // if err != nil { + // return fmt.Errorf("failed to get followerChat: %w", err) + // } + // + // chatSettings, err := c.chatSettingsRepository.GetByChatID(ctx, follower.ChatID) + // if err != nil { + // return fmt.Errorf("failed to get followerChat settings: %w", err) + // } + // + // if !chatSettings.TitleChangeNotifications { + // continue + // } + // + // localizedString := c.localizer.MustLocalize( + // localizer.WithKey("offline"), + // localizer.WithLanguage(chatSettings.Language), + // localizer.WithAttribute("channelName", opts.ChannelID), + // localizer.WithAttribute("follower", follower), + // ) + // + // if followerChat.Service == domain.ChatServiceTelegram { + // err = c.messageSender.SendMessageTelegram( + // ctx, + // messagesender.TelegramOpts{ + // ServiceChatID: messagesender.MessageTarget{ + // ServiceChatID: followerChat.ChatID, + // }, + // Text: localizedString, + // }, + // ) + // if err != nil { + // return fmt.Errorf("failed to send message: %w", err) + // } + // } + // } + + return nil +} + +func (c *AnnounceSenderTemporal) SendCategoryChange( + ctx context.Context, + opts announcesender.ChannelCategoryChangeOpts, +) error { + // followers, err := c.followRepository.GetByChannelID(ctx, opts.ChannelID) + // if err != nil { + // return fmt.Errorf("failed to get followers: %w", err) + // } + // + // for _, follower := range followers { + // followerChat, err := c.chatRepository.GetByID(ctx, follower.ChatID) + // if err != nil { + // return fmt.Errorf("failed to get followerChat: %w", err) + // } + // + // chatSettings, err := c.chatSettingsRepository.GetByChatID(ctx, follower.ChatID) + // if err != nil { + // return fmt.Errorf("failed to get followerChat settings: %w", err) + // } + // + // if !chatSettings.CategoryChangeNotifications { + // continue + // } + // + // localizedString := c.localizer.MustLocalize( + // localizer.WithKey("offline"), + // localizer.WithLanguage(chatSettings.Language), + // localizer.WithAttribute("channelName", opts.ChannelID), + // localizer.WithAttribute("follower", follower), + // ) + // + // if followerChat.Service == domain.ChatServiceTelegram { + // err = c.messageSender.SendMessageTelegram( + // ctx, + // messagesender.TelegramOpts{ + // ServiceChatID: messagesender.MessageTarget{ + // ServiceChatID: followerChat.ChatID, + // }, + // Text: localizedString, + // }, + // ) + // if err != nil { + // return fmt.Errorf("failed to send message: %w", err) + // } + // } + // } + + return nil +} + +func (c *AnnounceSenderTemporal) SendTitleAndCategoryChange( + ctx context.Context, + opts announcesender.ChannelTitleAndCategoryChangeOpts, +) error { + // followers, err := c.followRepository.GetByChannelID(ctx, opts.ChannelID) + // if err != nil { + // return fmt.Errorf("failed to get followers: %w", err) + // } + // + // for _, follower := range followers { + // followerChat, err := c.chatRepository.GetByID(ctx, follower.ChatID) + // if err != nil { + // return fmt.Errorf("failed to get followerChat: %w", err) + // } + // + // chatSettings, err := c.chatSettingsRepository.GetByChatID(ctx, follower.ChatID) + // if err != nil { + // return fmt.Errorf("failed to get followerChat settings: %w", err) + // } + // + // if !chatSettings.CategoryAndTitleNotifications { + // continue + // } + // + // localizedString := c.localizer.MustLocalize( + // localizer.WithKey("offline"), + // localizer.WithLanguage(chatSettings.Language), + // localizer.WithAttribute("channelName", opts.ChannelID), + // localizer.WithAttribute("follower", follower), + // ) + // + // if followerChat.Service == domain.ChatServiceTelegram { + // err = c.messageSender.SendMessageTelegram( + // ctx, + // messagesender.TelegramOpts{ + // ServiceChatID: messagesender.MessageTarget{ + // ServiceChatID: followerChat.ChatID, + // }, + // Text: localizedString, + // }, + // ) + // if err != nil { + // return fmt.Errorf("failed to send message: %w", err) + // } + // } + // } + + return nil +} diff --git a/internal/announcesender/temporal/worker.go b/internal/announcesender/temporal/worker.go new file mode 100644 index 00000000..9b5f31b8 --- /dev/null +++ b/internal/announcesender/temporal/worker.go @@ -0,0 +1,54 @@ +package temporal + +import ( + "context" + + "github.com/satont/twitch-notifier/pkg/config" + "github.com/satont/twitch-notifier/pkg/logger" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/log" + "go.temporal.io/sdk/worker" + "go.uber.org/fx" +) + +type WorkerOpts struct { + fx.In + LC fx.Lifecycle + + Config config.Config + Logger logger.Logger + Workflow *Workflow + Activities *Activities +} + +func NewWorker(opts WorkerOpts) error { + temporalClient, err := client.Dial( + client.Options{ + Logger: log.NewStructuredLogger(opts.Logger.GetSlog()), + HostPort: opts.Config.TemporalUrl, + }, + ) + if err != nil { + return err + } + + w := worker.New(temporalClient, queueName, worker.Options{}) + + w.RegisterWorkflow(opts.Workflow.SendOnline) + w.RegisterActivity(opts.Activities.GetChannelInformation) + + opts.LC.Append( + fx.Hook{ + OnStart: func(ctx context.Context) error { + return w.Start() + }, + OnStop: func(ctx context.Context) error { + w.Stop() + temporalClient.Close() + return nil + }, + }, + ) + + return nil +} diff --git a/internal/announcesender/temporal/workflow.go b/internal/announcesender/temporal/workflow.go new file mode 100644 index 00000000..3009ffab --- /dev/null +++ b/internal/announcesender/temporal/workflow.go @@ -0,0 +1,148 @@ +package temporal + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/satont/twitch-notifier/internal/announcesender" + "github.com/satont/twitch-notifier/internal/domain" + "github.com/satont/twitch-notifier/internal/i18n/localizer" + "github.com/satont/twitch-notifier/internal/messagesender" + "github.com/satont/twitch-notifier/internal/repository/chat" + "github.com/satont/twitch-notifier/internal/repository/chatsettings" + "github.com/satont/twitch-notifier/internal/repository/follow" + "github.com/satont/twitch-notifier/internal/thumbnailchecker" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" + "go.uber.org/fx" +) + +type WorkflowOpts struct { + fx.In + + Localizer localizer.Localizer + FollowRepository follow.Repository + ChatRepository chat.Repository + ChatSettingsRepository chatsettings.Repository + MessageSender messagesender.MessageSender + ThumbnailChecker thumbnailchecker.ThumbnailChecker + Activities *Activities +} + +func NewWorkflow(opts WorkflowOpts) *Workflow { + return &Workflow{ + localizer: opts.Localizer, + messageSender: opts.MessageSender, + thumbnailChecker: opts.ThumbnailChecker, + followRepository: opts.FollowRepository, + chatRepository: opts.ChatRepository, + chatSettingsRepository: opts.ChatSettingsRepository, + activities: opts.Activities, + } +} + +type Workflow struct { + localizer localizer.Localizer + messageSender messagesender.MessageSender + thumbnailChecker thumbnailchecker.ThumbnailChecker + + followRepository follow.Repository + chatRepository chat.Repository + chatSettingsRepository chatsettings.Repository + activities *Activities +} + +func (c *Workflow) SendOnline( + workflowCtx workflow.Context, + opts announcesender.ChannelOnlineOpts, +) error { + ctx := context.Background() + + logger := workflow.GetLogger(workflowCtx) + logger.Info("Sending online message", "channelId", opts.ChannelID.String()) + + var channelInformation *domain.PlatformChannelInformation + err := workflow.ExecuteActivity( + workflow.WithActivityOptions( + workflowCtx, + workflow.ActivityOptions{ + TaskQueue: queueName, + RetryPolicy: &temporal.RetryPolicy{ + MaximumInterval: 15 * time.Second, + MaximumAttempts: 10, + }, + }, + ), + c.activities.GetChannelInformation, + opts.ChannelID, + ).Get(workflowCtx, &channelInformation) + if err != nil { + return fmt.Errorf("failed to get channel: %w", err) + } + + // TODO: execute this as child workflow + err = c.thumbnailChecker.ValidateThumbnail(ctx, opts.ThumbnailURL) + if err != nil { + return fmt.Errorf("failed to check thumbnail: %w", err) + } + + // db get + followers, err := c.followRepository.GetByChannelID(ctx, opts.ChannelID) + if err != nil { + return fmt.Errorf("failed to get followers: %w", err) + } + + logger.Info("Got followers", "followers", len(followers)) + + var followersSendErrorsErrors []error + for _, follower := range followers { + followerChat, err := c.chatRepository.GetByID(ctx, follower.ChatID) + if err != nil { + return fmt.Errorf("failed to get followerChat: %w", err) + } + + chatSettings, err := c.chatSettingsRepository.GetByChatID(ctx, followerChat.ID) + if err != nil { + return fmt.Errorf("failed to get followerChat settings: %w", err) + } + + localizedString := c.localizer.MustLocalize( + localizer.WithKey("notifications.streams.nowOnline"), + localizer.WithLanguage(chatSettings.Language), + localizer.WithAttribute("channelLink", channelInformation.ChannelLink), + localizer.WithAttribute("category", channelInformation.GameName), + localizer.WithAttribute("title", channelInformation.Title), + ) + + logger.Info( + "Sending message", + "followerChat", + followerChat.ID, + "service", + followerChat.Service, + ) + + if followerChat.Service == domain.ChatServiceTelegram { + err = c.messageSender.SendMessageTelegram( + ctx, + messagesender.TelegramOpts{ + ServiceChatID: followerChat.ChatID, + Text: localizedString, + ImageURL: opts.ThumbnailURL, + }, + ) + if err != nil { + followersSendErrorsErrors = append(followersSendErrorsErrors, err) + } + } + } + + if len(followersSendErrorsErrors) > 0 { + logger.Error("Failed to send messages", "errors", followersSendErrorsErrors) + return errors.Join(followersSendErrorsErrors...) + } + + return nil +} diff --git a/internal/announcesender/temporal/workflow_test.go b/internal/announcesender/temporal/workflow_test.go new file mode 100644 index 00000000..6a49ab5a --- /dev/null +++ b/internal/announcesender/temporal/workflow_test.go @@ -0,0 +1,127 @@ +package temporal + +import ( + "errors" + "testing" + "time" + + "github.com/google/uuid" + "github.com/satont/twitch-notifier/internal/announcesender" + "github.com/satont/twitch-notifier/internal/domain" + mocklocalizer "github.com/satont/twitch-notifier/internal/i18n/localizer/mocks" + mockmessagesender "github.com/satont/twitch-notifier/internal/messagesender/mocks" + mockchat "github.com/satont/twitch-notifier/internal/repository/chat/mocks" + mockchatsettings "github.com/satont/twitch-notifier/internal/repository/chatsettings/mocks" + mockfollow "github.com/satont/twitch-notifier/internal/repository/follow/mocks" + mockthumbnailchecker "github.com/satont/twitch-notifier/internal/thumbnailchecker/mocks" + "github.com/stretchr/testify/require" + "go.temporal.io/sdk/testsuite" + "go.uber.org/mock/gomock" +) + +func Test_OnlineWorkflow(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + + localizer := mocklocalizer.NewMockLocalizer(ctrl) + messageSender := mockmessagesender.NewMockMessageSender(ctrl) + thumbnailChecker := mockthumbnailchecker.NewMockThumbnailChecker(ctrl) + followRepository := mockfollow.NewMockRepository(ctrl) + chatRepository := mockchat.NewMockRepository(ctrl) + chatSettingsRepository := mockchatsettings.NewMockRepository(ctrl) + + workflow := &Workflow{ + localizer: localizer, + messageSender: messageSender, + thumbnailChecker: thumbnailChecker, + followRepository: followRepository, + chatRepository: chatRepository, + chatSettingsRepository: chatSettingsRepository, + } + + testSuite := &testsuite.WorkflowTestSuite{} + env := testSuite.NewTestWorkflowEnvironment() + + channel := &domain.Channel{ + ID: uuid.New(), + ChannelID: "123", + Service: domain.StreamingServiceTwitch, + } + + followerChat := &domain.Chat{ + ID: uuid.New(), + Service: domain.ChatServiceTelegram, + ChatID: "11111", + } + followers := []domain.Follow{ + { + ID: uuid.New(), + ChatID: followerChat.ID, + ChannelID: channel.ID, + CreatedAt: time.Now(), + }, + } + chatSettings := &domain.ChatSettings{ + ID: uuid.New(), + ChatID: followerChat.ID, + Language: domain.LanguageEN, + CategoryChangeNotifications: true, + TitleChangeNotifications: true, + OfflineNotifications: true, + CategoryAndTitleNotifications: true, + ShowThumbnail: true, + } + + thumbnailChecker.EXPECT().ValidateThumbnail(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + followRepository.EXPECT().GetByChannelID(gomock.Any(), channel.ID).Return( + followers, + nil, + ).AnyTimes() + chatRepository.EXPECT().GetByID(gomock.Any(), followers[0].ChatID).Return( + followerChat, + nil, + ).AnyTimes() + chatSettingsRepository.EXPECT().GetByChatID(gomock.Any(), followerChat.ID).Return( + chatSettings, + nil, + ).AnyTimes() + localizer.EXPECT().MustLocalize(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return( + "Hello world", + ).AnyTimes() + messageSender.EXPECT().SendMessageTelegram(gomock.Any(), gomock.Any()).Return(nil) + + env.ExecuteWorkflow( + workflow.SendOnline, + announcesender.ChannelOnlineOpts{ + ChannelID: channel.ID, + Category: "Dota 2", + Title: "Hello world", + ThumbnailURL: "https://twitch.tv/notifier", + }, + ) + + require.True(t, env.IsWorkflowCompleted()) + require.NoError(t, env.GetWorkflowError()) + + messageSender.EXPECT().SendMessageTelegram( + gomock.Any(), + gomock.Any(), + ).Return(errors.New("test")) + + testSuite = &testsuite.WorkflowTestSuite{} + env = testSuite.NewTestWorkflowEnvironment() + + env.ExecuteWorkflow( + workflow.SendOnline, + announcesender.ChannelOnlineOpts{ + ChannelID: channel.ID, + Category: "Dota 2", + Title: "Hello world", + ThumbnailURL: "https://twitch.tv/notifier", + }, + ) + + require.True(t, env.IsWorkflowCompleted()) + require.Error(t, env.GetWorkflowError()) +} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index f45960a5..00000000 --- a/internal/config/config.go +++ /dev/null @@ -1,45 +0,0 @@ -package config - -import ( - "github.com/joho/godotenv" - "github.com/kelseyhightower/envconfig" - "os" - "path/filepath" -) - -type Config struct { - TwitchClientId string `required:"true" envconfig:"TWITCH_CLIENTID"` - TwitchClientSecret string `required:"true" envconfig:"TWITCH_CLIENTSECRET"` - TelegramToken string `required:"true" envconfig:"TELEGRAM_TOKEN"` - AppEnv string `required:"true" envconfig:"APP_ENV" default:"development"` - TelegramBotAdmins []string `required:"false" envconfig:"TELEGRAM_BOT_ADMINS"` - DatabaseUrl string `required:"true" envconfig:"DATABASE_URL"` - SentryDsn string `required:"false" envconfig:"SENTRY_DSN"` -} - -var getWd = os.Getwd -var processEnv = envconfig.Process - -func NewConfig(customPath *string) (*Config, error) { - var newCfg Config - - var err error - - wd, err := getWd() - if err != nil { - return nil, err - } - - envPath := filepath.Join(wd, ".env") - - if customPath != nil { - envPath = *customPath - } - - _ = godotenv.Overload(envPath) - if err = processEnv("", &newCfg); err != nil { - return nil, err - } - - return &newCfg, nil -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go deleted file mode 100644 index 97b37365..00000000 --- a/internal/config/config_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package config - -import ( - "os" - "testing" - - "github.com/kelseyhightower/envconfig" - "github.com/stretchr/testify/assert" -) - -var strConfig = ` -TWITCH_CLIENTID=1 -TWITCH_CLIENTSECRET=2 -TELEGRAM_TOKEN=3 -TELEGRAM_BOT_ADMINS=4 -DATABASE_URL=5 -` - -func Test_NewConfig(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - setupEnv func(t *testing.T) (*Config, error) - checkEnv func(t *testing.T, config *Config, err error) - }{ - { - name: "OK", - setupEnv: func(t *testing.T) (*Config, error) { - file, err := os.CreateTemp("", "temp-env") - assert.NoError(t, err) - - filepath := file.Name() - - _, err = file.Write([]byte(strConfig)) - assert.NoError(t, err) - - defer file.Close() - defer os.Remove(filepath) - - config, err := NewConfig(&filepath) - - return config, err - }, - checkEnv: func(t *testing.T, config *Config, err error) { - assert.NoError(t, err) - - assert.Equal(t, "1", config.TwitchClientId) - assert.Equal(t, "2", config.TwitchClientSecret) - assert.Equal(t, "3", config.TelegramToken) - assert.IsType(t, []string{}, config.TelegramBotAdmins) - assert.Contains(t, config.TelegramBotAdmins, "4") - assert.Equal(t, "5", config.DatabaseUrl) - }, - }, - { - name: "os.Getwd() provides some error", - setupEnv: func(t *testing.T) (*Config, error) { - getWd = func() (string, error) { - return "", os.ErrNotExist - } - defer func() { getWd = os.Getwd }() - - config, err := NewConfig(nil) - - return config, err - }, - checkEnv: func(t *testing.T, config *Config, err error) { - assert.Error(t, err) - assert.ErrorIs(t, err, os.ErrNotExist) - assert.Nil(t, config) - }, - }, - { - name: "envconfig.Process() provides some error", - setupEnv: func(t *testing.T) (*Config, error) { - processEnv = func(s string, i interface{}) error { - return os.ErrNotExist - } - defer func() { processEnv = envconfig.Process }() - - config, err := NewConfig(nil) - - return config, err - }, - checkEnv: func(t *testing.T, config *Config, err error) { - assert.Error(t, err) - assert.ErrorIs(t, err, os.ErrNotExist) - assert.Nil(t, config) - }, - }, - } - - for _, tt := range testCases { - t.Run(tt.name, func(t *testing.T) { - tt.setupEnv(t) - - cfg, err := tt.setupEnv(t) - tt.checkEnv(t, cfg, err) - }) - } -} diff --git a/internal/db/channel.go b/internal/db/channel.go deleted file mode 100644 index 9c9e531e..00000000 --- a/internal/db/channel.go +++ /dev/null @@ -1,41 +0,0 @@ -package db - -import ( - "context" - - "github.com/satont/twitch-notifier/internal/db/db_models" -) - -type ChannelUpdateQuery struct { - IsLive *bool - Category *string - Title *string - - DangerNewChannelId *string -} - -type ChannelInterface interface { - GetByID( - _ context.Context, - id string, - service db_models.ChannelService, - ) (*db_models.Channel, error) - GetByChannelID( - _ context.Context, - channelID string, - service db_models.ChannelService, - ) (*db_models.Channel, error) - Create(_ context.Context, channelID string, service db_models.ChannelService) (*db_models.Channel, error) - Update( - _ context.Context, - channelID string, - service db_models.ChannelService, - updateQuery *ChannelUpdateQuery, - ) (*db_models.Channel, error) - GetByIdOrCreate( - _ context.Context, - channelID string, - service db_models.ChannelService, - ) (*db_models.Channel, error) - GetAll(_ context.Context) ([]*db_models.Channel, error) -} diff --git a/internal/db/channel_impl_ent.go b/internal/db/channel_impl_ent.go deleted file mode 100644 index 91794682..00000000 --- a/internal/db/channel_impl_ent.go +++ /dev/null @@ -1,192 +0,0 @@ -package db - -import ( - "context" - - "github.com/google/uuid" - "github.com/satont/twitch-notifier/ent" - "github.com/satont/twitch-notifier/ent/channel" - "github.com/satont/twitch-notifier/internal/db/db_models" -) - -type channelEntService struct { - entClient *ent.Client -} - -func (c *channelEntService) convertEntity(ch *ent.Channel) *db_models.Channel { - return &db_models.Channel{ - ID: ch.ID, - ChannelID: ch.ChannelID, - Service: db_models.ChannelService(ch.Service.String()), - IsLive: ch.IsLive, - Title: ch.Title, - Category: ch.Category, - UpdatedAt: ch.UpdatedAt, - } -} - -func (c *channelEntService) GetByIdOrCreate( - ctx context.Context, - channelID string, - service db_models.ChannelService, -) (*db_models.Channel, error) { - channelService := channel.Service(service.String()) - - ch, err := c.entClient.Channel. - Query(). - Where(channel.ChannelID(channelID), channel.ServiceEQ(channelService)). - First(ctx) - - if ent.IsNotFound(err) { - newChannel, err := c.Create(ctx, channelID, service) - if err != nil { - return nil, err - } - return newChannel, nil - } else if err != nil { - return nil, err - } - - return c.convertEntity(ch), nil -} - -func (c *channelEntService) GetByID( - ctx context.Context, - id string, - service db_models.ChannelService, -) (*db_models.Channel, error) { - channelService := channel.Service(service.String()) - - idUUID, err := uuid.Parse(id) - if err != nil { - return nil, err - } - - ch, err := c.entClient.Channel. - Query(). - Where(channel.ID(idUUID), channel.ServiceEQ(channelService)). - Only(ctx) - - if err != nil { - if ent.IsNotFound(err) { - return nil, db_models.ChannelNotFoundError - } - - return nil, err - } - - if err != nil { - return nil, err - } - - return c.convertEntity(ch), nil -} - -func (c *channelEntService) GetByChannelID( - ctx context.Context, - channelID string, - service db_models.ChannelService, -) (*db_models.Channel, error) { - channelService := channel.Service(service.String()) - - ch, err := c.entClient.Channel. - Query(). - Where(channel.ChannelID(channelID), channel.ServiceEQ(channelService)). - Only(ctx) - - if err != nil { - if ent.IsNotFound(err) { - return nil, db_models.ChannelNotFoundError - } - - return nil, err - } - - if err != nil { - return nil, err - } - - return c.convertEntity(ch), nil -} - -func (c *channelEntService) Create( - ctx context.Context, - channelID string, - service db_models.ChannelService, -) (*db_models.Channel, error) { - channelService := channel.Service(service.String()) - - ch, err := c.entClient.Channel.Create(). - SetChannelID(channelID). - SetService(channelService).Save(ctx) - if err != nil { - return nil, err - } - - return c.convertEntity(ch), nil -} - -func (c *channelEntService) Update( - ctx context.Context, - channelID string, - service db_models.ChannelService, - query *ChannelUpdateQuery, -) (*db_models.Channel, error) { - channelService := channel.Service(service.String()) - ch, err := c.entClient.Channel. - Query(). - Where(channel.ChannelIDIn(channelID), channel.ServiceEQ(channelService)). - Only(ctx) - if err != nil { - return nil, err - } - - updateQuery := c.entClient.Channel.UpdateOne(ch) - - if query.IsLive != nil { - updateQuery.SetIsLive(*query.IsLive) - } - - if query.Category != nil { - updateQuery.SetCategory(*query.Category) - } - - if query.Title != nil { - updateQuery.SetTitle(*query.Title) - } - - if query.DangerNewChannelId != nil { - updateQuery.SetChannelID(*query.DangerNewChannelId) - } - - newChannel, err := updateQuery.Save(context.Background()) - - if err != nil { - return nil, err - } - - return c.convertEntity(newChannel), nil -} - -func (c *channelEntService) GetAll(ctx context.Context) ([]*db_models.Channel, error) { - channels, err := c.entClient.Channel. - Query(). - All(ctx) - - if err != nil { - return nil, err - } - - result := make([]*db_models.Channel, 0, len(channels)) - for _, ch := range channels { - result = append(result, c.convertEntity(ch)) - } - - return result, nil -} - -func NewChannelEntService(entClient *ent.Client) ChannelInterface { - return &channelEntService{ - entClient: entClient, - } -} diff --git a/internal/db/channel_impl_ent_test.go b/internal/db/channel_impl_ent_test.go deleted file mode 100644 index 09f34a8e..00000000 --- a/internal/db/channel_impl_ent_test.go +++ /dev/null @@ -1,236 +0,0 @@ -package db - -import ( - "context" - "strconv" - "testing" - - "github.com/satont/twitch-notifier/internal/db/db_models" - - "github.com/samber/lo" - "github.com/sourcegraph/conc" - "github.com/stretchr/testify/assert" -) - -func TestChannelEntService_GetByIdOrCreate(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - if err != nil { - t.Fatal(err) - } - defer teardownTest(entClient) - - channelService := NewChannelEntService(entClient) - - channel, err := channelService.GetByIdOrCreate(context.Background(), "123", db_models.ChannelServiceTwitch) - assert.NoError(t, err) - assert.Equal(t, "123", channel.ChannelID) - assert.Equal(t, db_models.ChannelServiceTwitch, channel.Service) - assert.False(t, channel.IsLive) - assert.Nil(t, channel.Title) - assert.Nil(t, channel.Category) - assert.Nil(t, channel.UpdatedAt) -} - -func TestChannelEntService_GetByID(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - if err != nil { - t.Fatal(err) - } - defer teardownTest(entClient) - - channelService := NewChannelEntService(entClient) - - table := []struct { - name string - channelID string - service db_models.ChannelService - wantErr bool - createChannel bool - }{ - { - name: "channel not found", - channelID: "123", - service: db_models.ChannelServiceTwitch, - wantErr: true, - }, - { - name: "channel found", - channelID: "321", - service: db_models.ChannelServiceTwitch, - wantErr: false, - createChannel: true, - }, - } - - for _, tt := range table { - t.Run( - tt.name, func(t *testing.T) { - if tt.createChannel { - _, err := channelService.Create(context.Background(), tt.channelID, tt.service) - assert.NoError(t, err) - } - - channel, err := channelService.GetByChannelID(context.Background(), tt.channelID, tt.service) - if tt.wantErr { - assert.Error(t, err) - assert.EqualError(t, err, db_models.ChannelNotFoundError.Error()) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.channelID, channel.ChannelID) - assert.Equal(t, tt.service, channel.Service) - } - }, - ) - } -} - -func TestChannelEntService_Create(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - if err != nil { - t.Fatal(err) - } - defer teardownTest(entClient) - - channelService := NewChannelEntService(entClient) - - table := []struct { - name string - channel string - service db_models.ChannelService - wantErr bool - }{ - { - name: "channel should be created", - channel: "123", - service: db_models.ChannelServiceTwitch, - }, - { - name: "should fail create because channel exists", - channel: "123", - service: db_models.ChannelServiceTwitch, - wantErr: true, - }, - } - - for _, tt := range table { - t.Run( - tt.name, func(t *testing.T) { - channel, err := channelService.Create(context.Background(), tt.channel, tt.service) - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.channel, channel.ChannelID) - assert.Equal(t, tt.service, channel.Service) - assert.False(t, channel.IsLive) - assert.Nil(t, channel.Title) - assert.Nil(t, channel.Category) - assert.Nil(t, channel.UpdatedAt) - } - }, - ) - } -} - -func TestChannelEntService_Update(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - if err != nil { - t.Fatal(err) - } - defer teardownTest(entClient) - - channelService := NewChannelEntService(entClient) - - table := []struct { - name string - channelID string - service db_models.ChannelService - wantErr bool - createChannel bool - }{ - { - name: "channel should be update", - channelID: "123", - service: db_models.ChannelServiceTwitch, - createChannel: true, - }, - { - name: "should fail update because channel not exists", - channelID: "321", - service: db_models.ChannelServiceTwitch, - wantErr: true, - }, - } - - for _, tt := range table { - t.Run( - tt.name, func(t *testing.T) { - if tt.createChannel { - _, err := channelService.Create(context.Background(), tt.channelID, tt.service) - assert.NoError(t, err) - } - - channel, err := channelService.Update( - context.Background(), - tt.channelID, - tt.service, - &ChannelUpdateQuery{ - IsLive: lo.ToPtr(true), - Category: lo.ToPtr("Category"), - Title: lo.ToPtr("Title"), - }, - ) - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.channelID, channel.ChannelID) - assert.Equal(t, tt.service, channel.Service) - assert.True(t, channel.IsLive) - assert.Equal(t, "Title", *channel.Title) - assert.Equal(t, "Category", *channel.Category) - assert.NotNil(t, channel.UpdatedAt) - } - }, - ) - } -} - -func TestChannelEntService_GetAll(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - if err != nil { - t.Fatal(err) - } - defer teardownTest(entClient) - - channelService := NewChannelEntService(entClient) - - ctx := context.Background() - - wg := conc.NewWaitGroup() - for i := 0; i < 5; i++ { - i := i - wg.Go( - func() { - _, err = channelService.Create(ctx, strconv.Itoa(i), db_models.ChannelServiceTwitch) - assert.NoError(t, err) - }, - ) - } - wg.Wait() - - channels, err := channelService.GetAll(ctx) - assert.NoError(t, err) - - assert.Len(t, channels, 5) -} diff --git a/internal/db/chat.go b/internal/db/chat.go deleted file mode 100644 index d5a597c3..00000000 --- a/internal/db/chat.go +++ /dev/null @@ -1,40 +0,0 @@ -package db - -import ( - "context" - - "github.com/satont/twitch-notifier/internal/db/db_models" -) - -type ChatUpdateSettingsQuery struct { - GameChangeNotification *bool - OfflineNotification *bool - TitleChangeNotification *bool - GameAndTitleChangeNotification *bool - ImageInNotification *bool - ChatLanguage *db_models.ChatLanguage -} - -type ChatUpdateQuery struct { - Settings *ChatUpdateSettingsQuery -} - -type ChatInterface interface { - GetByID( - _ context.Context, - chatId string, - service db_models.ChatService, - ) (*db_models.Chat, error) - Create( - _ context.Context, - chatId string, - service db_models.ChatService, - ) (*db_models.Chat, error) - Update( - _ context.Context, - chatId string, - service db_models.ChatService, - query *ChatUpdateQuery, - ) (*db_models.Chat, error) - GetAllByService(_ context.Context, service db_models.ChatService) ([]*db_models.Chat, error) -} diff --git a/internal/db/chat_ent_impl.go b/internal/db/chat_ent_impl.go deleted file mode 100644 index cb353be0..00000000 --- a/internal/db/chat_ent_impl.go +++ /dev/null @@ -1,163 +0,0 @@ -package db - -import ( - "context" - - "github.com/satont/twitch-notifier/ent" - "github.com/satont/twitch-notifier/ent/chat" - "github.com/satont/twitch-notifier/ent/chatsettings" - "github.com/satont/twitch-notifier/internal/db/db_models" -) - -type chatService struct { - entClient *ent.Client -} - -func (c *chatService) convertEntity(entity *ent.Chat) *db_models.Chat { - settings := &db_models.ChatSettings{ - ID: entity.Edges.Settings.ID, - GameChangeNotification: entity.Edges.Settings.GameChangeNotification, - OfflineNotification: entity.Edges.Settings.OfflineNotification, - TitleChangeNotification: entity.Edges.Settings.TitleChangeNotification, - GameAndTitleChangeNotification: entity.Edges.Settings.GameAndTitleChangeNotification, - ImageInNotification: entity.Edges.Settings.ImageInNotification, - ChatLanguage: db_models.ChatLanguage(entity.Edges.Settings.ChatLanguage), - ChatID: entity.Edges.Settings.ChatID, - } - - return &db_models.Chat{ - ID: entity.ID, - ChatID: entity.ChatID, - Service: db_models.ChatService(entity.Service), - Settings: settings, - } -} - -func (c *chatService) Update( - ctx context.Context, - chatId string, - service db_models.ChatService, - settings *ChatUpdateQuery, -) (*db_models.Chat, error) { - ch, err := c.entClient.Chat. - Query(). - Where(chat.ChatID(chatId), chat.ServiceEQ(chat.Service(service))). - WithSettings(). - Only(ctx) - if err != nil { - return nil, err - } - - if settings.Settings != nil { - updater := ch.Edges.Settings.Update() - - if settings.Settings.ChatLanguage != nil { - updater.SetChatLanguage(chatsettings.ChatLanguage(*settings.Settings.ChatLanguage)) - } - - if settings.Settings.GameChangeNotification != nil { - updater.SetGameChangeNotification(*settings.Settings.GameChangeNotification) - } - - if settings.Settings.OfflineNotification != nil { - updater.SetOfflineNotification(*settings.Settings.OfflineNotification) - } - - if settings.Settings.TitleChangeNotification != nil { - updater.SetTitleChangeNotification(*settings.Settings.TitleChangeNotification) - } - - if settings.Settings.ImageInNotification != nil { - updater.SetImageInNotification(*settings.Settings.ImageInNotification) - } - - if settings.Settings.GameAndTitleChangeNotification != nil { - updater.SetGameAndTitleChangeNotification(*settings.Settings.GameAndTitleChangeNotification) - } - - _, err = updater.Save(ctx) - if err != nil { - return nil, err - } - } - - return c.GetByID(ctx, chatId, service) -} - -func (c *chatService) Create( - ctx context.Context, - chatId string, - service db_models.ChatService, -) (*db_models.Chat, error) { - ch, err := c.entClient.Chat. - Create(). - SetChatID(chatId). - SetService(chat.Service(service.String())). - Save(ctx) - if err != nil { - return nil, err - } - - settings, err := c.entClient.ChatSettings.Create().SetChatID(ch.ID).Save(ctx) - if err != nil { - return nil, err - } - - ch.Edges.Settings = settings - - return c.convertEntity(ch), nil -} - -func (c *chatService) GetByID( - ctx context.Context, - chatId string, - service db_models.ChatService, -) (*db_models.Chat, error) { - ch, err := c.entClient.Chat. - Query(). - Where(chat.ChatID(chatId), chat.ServiceEQ(chat.Service(service))). - WithSettings(). - Only(ctx) - - if err != nil { - if ent.IsNotFound(err) { - return nil, nil - } - - return nil, err - } - - if err != nil { - return nil, err - } - - return c.convertEntity(ch), nil -} - -func (c *chatService) GetAllByService( - ctx context.Context, - service db_models.ChatService, -) ([]*db_models.Chat, error) { - chats, err := c.entClient.Chat. - Query(). - Where(chat.ServiceEQ(chat.Service(service))). - Order(ent.Desc(chat.FieldChatID)). - WithSettings(). - All(ctx) - if err != nil { - return nil, err - } - - var result []*db_models.Chat - for _, ch := range chats { - result = append(result, c.convertEntity(ch)) - } - - return result, nil -} - -func NewChatEntRepository(entClient *ent.Client) ChatInterface { - return &chatService{ - entClient: entClient, - } -} diff --git a/internal/db/chat_ent_impl_test.go b/internal/db/chat_ent_impl_test.go deleted file mode 100644 index ae92382d..00000000 --- a/internal/db/chat_ent_impl_test.go +++ /dev/null @@ -1,280 +0,0 @@ -package db - -import ( - "context" - "strconv" - "testing" - - _ "github.com/mattn/go-sqlite3" - "github.com/samber/lo" - "github.com/satont/twitch-notifier/internal/db/db_models" - "github.com/stretchr/testify/assert" -) - -func TestChatService_GetByID(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - if err != nil { - t.Fatal(err) - } - defer teardownTest(entClient) - - chatService := NewChatEntRepository(entClient) - - _, err = chatService.Create( - context.Background(), - "123", - db_models.ChatServiceTelegram, - ) - assert.NoError(t, err) - - table := []struct { - name string - chatID string - wantNil bool - expects struct { - chatID string - service db_models.ChatService - language db_models.ChatLanguage - gameChangeNotification bool - streamStartNotification bool - titleChangeNotification bool - imageInNotification bool - } - }{ - { - name: "Get chat by id", - chatID: "123", - wantNil: false, - expects: struct { - chatID string - service db_models.ChatService - language db_models.ChatLanguage - gameChangeNotification bool - streamStartNotification bool - titleChangeNotification bool - imageInNotification bool - }{ - chatID: "123", - service: db_models.ChatServiceTelegram, - language: db_models.ChatLanguageEn, - gameChangeNotification: true, - streamStartNotification: true, - titleChangeNotification: false, - imageInNotification: true, - }, - }, - { - name: "Should fail if chat not found", - chatID: "321", - wantNil: true, - }, - } - - for _, tt := range table { - t.Run(tt.chatID, func(t *testing.T) { - chat, err := chatService.GetByID( - context.Background(), - tt.chatID, - db_models.ChatServiceTelegram, - ) - - if tt.wantNil { - assert.Nil(t, chat) - } else { - assert.NoError(t, err) - - assert.Equal(t, tt.expects.chatID, chat.ChatID) - assert.Equal(t, tt.expects.service, chat.Service) - assert.Equal(t, tt.expects.language, chat.Settings.ChatLanguage) - assert.Equal(t, tt.expects.gameChangeNotification, chat.Settings.GameChangeNotification) - assert.Equal(t, tt.expects.titleChangeNotification, chat.Settings.TitleChangeNotification) - assert.Equal(t, tt.expects.streamStartNotification, chat.Settings.OfflineNotification) - assert.Equal(t, tt.expects.imageInNotification, chat.Settings.ImageInNotification) - assert.Equal(t, chat.ID, chat.Settings.ChatID) - } - }) - } -} - -func TestChatService_Create(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - if err != nil { - t.Fatal(err) - } - defer teardownTest(entClient) - - chatService := NewChatEntRepository(entClient) - - table := []struct { - name string - chatID string - wantErr bool - }{ - { - name: "Create chat", - chatID: "123", - wantErr: false, - }, - { - name: "Should fail if chat already exists", - chatID: "123", - wantErr: true, - }, - } - - for _, tt := range table { - t.Run(tt.chatID, func(t *testing.T) { - chat, err := chatService.Create( - context.Background(), - tt.chatID, - db_models.ChatServiceTelegram, - ) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.chatID, chat.ChatID) - assert.Equal(t, db_models.ChatServiceTelegram, chat.Service) - assert.NotEmpty(t, chat.Settings.ID) - assert.Equal(t, db_models.ChatLanguageEn, chat.Settings.ChatLanguage) - assert.Equal(t, true, chat.Settings.GameChangeNotification) - assert.Equal(t, true, chat.Settings.OfflineNotification) - assert.Equal(t, false, chat.Settings.TitleChangeNotification) - assert.Equal(t, true, chat.Settings.ImageInNotification) - assert.Equal(t, chat.ID, chat.Settings.ChatID) - } - }) - } -} - -func TestChatService_Update(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - if err != nil { - t.Fatal(err) - } - defer teardownTest(entClient) - - chatService := NewChatEntRepository(entClient) - - table := []struct { - name string - chatID string - wantErr bool - shouldCreate bool - newValues struct { - language db_models.ChatLanguage - gameChangeNotification bool - streamStartNotification bool - titleChaneNotification bool - imageInNotification bool - } - }{ - { - name: "Update chat", - chatID: "123", - wantErr: false, - shouldCreate: true, - newValues: struct { - language db_models.ChatLanguage - gameChangeNotification bool - streamStartNotification bool - titleChaneNotification bool - imageInNotification bool - }{ - language: db_models.ChatLanguageRu, - gameChangeNotification: false, - streamStartNotification: false, - titleChaneNotification: true, - imageInNotification: true, - }, - }, - { - name: "Should fail if chat not found", - chatID: "321", - wantErr: true, - shouldCreate: false, - }, - } - - for _, tt := range table { - t.Run(tt.chatID, func(t *testing.T) { - if tt.shouldCreate { - _, err = chatService.Create( - context.Background(), - tt.chatID, - db_models.ChatServiceTelegram, - ) - assert.NoError(t, err) - } - - newChat, err := chatService.Update( - context.Background(), - tt.chatID, - db_models.ChatServiceTelegram, - &ChatUpdateQuery{ - Settings: &ChatUpdateSettingsQuery{ - GameChangeNotification: lo.ToPtr(false), - OfflineNotification: lo.ToPtr(false), - TitleChangeNotification: lo.ToPtr(true), - ImageInNotification: lo.ToPtr(true), - ChatLanguage: lo.ToPtr(db_models.ChatLanguageRu), - }, - }, - ) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - - assert.Equal(t, tt.chatID, newChat.ChatID) - assert.Equal(t, tt.newValues.language, newChat.Settings.ChatLanguage) - assert.Equal(t, tt.newValues.gameChangeNotification, newChat.Settings.GameChangeNotification) - assert.Equal(t, tt.newValues.streamStartNotification, newChat.Settings.OfflineNotification) - assert.Equal(t, tt.newValues.titleChaneNotification, newChat.Settings.TitleChangeNotification) - assert.Equal(t, tt.newValues.imageInNotification, newChat.Settings.ImageInNotification) - } - }) - } -} - -func TestChatService_GetAllByService(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - if err != nil { - t.Fatal(err) - } - defer teardownTest(entClient) - - ctx := context.Background() - - chatService := NewChatEntRepository(entClient) - - var created []*db_models.Chat - - for i := 0; i < 10; i++ { - newChat, err := chatService.Create( - ctx, - strconv.Itoa(i), - db_models.ChatServiceTelegram, - ) - assert.NoError(t, err) - created = append(created, newChat) - } - - chats, err := chatService.GetAllByService(ctx, db_models.ChatServiceTelegram) - assert.NoError(t, err) - assert.Len(t, chats, 10) - - for _, chat := range chats { - assert.Contains(t, created, chat) - } -} diff --git a/internal/db/db_models/channel.go b/internal/db/db_models/channel.go deleted file mode 100644 index 053b5122..00000000 --- a/internal/db/db_models/channel.go +++ /dev/null @@ -1,34 +0,0 @@ -package db_models - -import ( - "errors" - "github.com/google/uuid" - "time" -) - -var ( - ChannelNotFoundError = errors.New("channel not found") -) - -type ChannelService string - -const ( - ChannelServiceTwitch ChannelService = "twitch" -) - -func (s ChannelService) String() string { - return string(s) -} - -type Channel struct { - ID uuid.UUID `json:"id,omitempty"` - ChannelID string `json:"channel_id,omitempty"` - Service ChannelService `json:"service,omitempty"` - IsLive bool `json:"is_live,omitempty"` - Title *string `json:"title,omitempty"` - Category *string `json:"category,omitempty"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` - - Follows []*Follow `json:"follows,omitempty"` - Streams []*Stream `json:"streams,omitempty"` -} diff --git a/internal/db/db_models/chat.go b/internal/db/db_models/chat.go deleted file mode 100644 index 801d9091..00000000 --- a/internal/db/db_models/chat.go +++ /dev/null @@ -1,58 +0,0 @@ -package db_models - -import ( - "github.com/google/uuid" -) - -type ChatService string - -const ( - ChatServiceTelegram ChatService = "telegram" -) - -func (s ChatService) String() string { - return string(s) -} - -func LanguageExists(l ChatLanguage) bool { - switch l { - case ChatLanguageRu, ChatLanguageEn, ChatLanguageUk: - return true - default: - return false - } -} - -type Chat struct { - ID uuid.UUID `json:"id,omitempty"` - ChatID string `json:"chat_id,omitempty"` - Service ChatService `json:"service,omitempty"` - - Follows []*Follow `json:"follows,omitempty"` - Settings *ChatSettings `json:"settings,omitempty"` -} - -type ChatLanguage string - -var DefaultChatLanguage = ChatLanguageEn - -var ( - ChatLanguageRu ChatLanguage = "ru" - ChatLanguageEn ChatLanguage = "en" - ChatLanguageUk ChatLanguage = "uk" -) - -func (cl ChatLanguage) String() string { - return string(cl) -} - -type ChatSettings struct { - ID uuid.UUID `json:"id,omitempty"` - GameChangeNotification bool `json:"game_change_notification,omitempty"` - TitleChangeNotification bool `json:"title_change_notification,omitempty"` - GameAndTitleChangeNotification bool `json:"game_and_title_change_notification,omitempty"` - OfflineNotification bool `json:"offline_notification,omitempty"` - ChatLanguage ChatLanguage `json:"chat_language,omitempty"` - ChatID uuid.UUID `json:"chat_id,omitempty"` - ImageInNotification bool `json:"image_in_notification,omitempty"` -} diff --git a/internal/db/db_models/follow.go b/internal/db/db_models/follow.go deleted file mode 100644 index 2fca9253..00000000 --- a/internal/db/db_models/follow.go +++ /dev/null @@ -1,20 +0,0 @@ -package db_models - -import ( - "errors" - "github.com/google/uuid" -) - -var ( - FollowAlreadyExistsError = errors.New("follow already exists") - FollowNotFoundError = errors.New("follow not found") -) - -type Follow struct { - ID uuid.UUID `json:"id,omitempty"` - ChannelID uuid.UUID `json:"channel_id,omitempty"` - ChatID uuid.UUID `json:"chat_id,omitempty"` - - Channel *Channel `json:"channel,omitempty"` - Chat *Chat `json:"chat,omitempty"` -} diff --git a/internal/db/db_models/stream.go b/internal/db/db_models/stream.go deleted file mode 100644 index b7b8323d..00000000 --- a/internal/db/db_models/stream.go +++ /dev/null @@ -1,16 +0,0 @@ -package db_models - -import ( - "github.com/google/uuid" - "time" -) - -type Stream struct { - ID string `json:"id,omitempty"` - ChannelID uuid.UUID `json:"channel_id,omitempty"` - Titles []string `json:"titles,omitempty"` - Categories []string `json:"categories,omitempty"` - StartedAt time.Time `json:"started_at,omitempty"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` - EndedAt *time.Time `json:"ended_at,omitempty"` -} diff --git a/internal/db/follow.go b/internal/db/follow.go deleted file mode 100644 index 827bb216..00000000 --- a/internal/db/follow.go +++ /dev/null @@ -1,20 +0,0 @@ -package db - -import ( - "context" - "github.com/google/uuid" - "github.com/satont/twitch-notifier/internal/db/db_models" -) - -type FollowInterface interface { - Create(_ context.Context, channelID uuid.UUID, chatID uuid.UUID) (*db_models.Follow, error) - Delete(_ context.Context, id uuid.UUID) error - GetByChatAndChannel( - _ context.Context, - channelID uuid.UUID, - chatID uuid.UUID, - ) (*db_models.Follow, error) - GetByChannelID(_ context.Context, channelID uuid.UUID) ([]*db_models.Follow, error) - GetByChatID(_ context.Context, chatID uuid.UUID, limit, offset int) ([]*db_models.Follow, error) - CountByChatID(_ context.Context, chatID uuid.UUID) (int, error) -} diff --git a/internal/db/follow_ent_impl.go b/internal/db/follow_ent_impl.go deleted file mode 100644 index 17ca005a..00000000 --- a/internal/db/follow_ent_impl.go +++ /dev/null @@ -1,201 +0,0 @@ -package db - -import ( - "context" - - "github.com/google/uuid" - "github.com/satont/twitch-notifier/ent" - "github.com/satont/twitch-notifier/ent/channel" - "github.com/satont/twitch-notifier/ent/chat" - "github.com/satont/twitch-notifier/ent/follow" - "github.com/satont/twitch-notifier/internal/db/db_models" -) - -type followService struct { - entClient *ent.Client -} - -func (f *followService) convertEntity(follow *ent.Follow) *db_models.Follow { - convertedFollow := &db_models.Follow{ - ID: follow.ID, - } - - if follow.Edges.Channel != nil { - convertedFollow.ChannelID = follow.Edges.Channel.ID - - convertedFollow.Channel = &db_models.Channel{ - ID: follow.Edges.Channel.ID, - ChannelID: follow.Edges.Channel.ChannelID, - Service: db_models.ChannelService(follow.Edges.Channel.Service), - IsLive: false, - UpdatedAt: follow.Edges.Channel.UpdatedAt, - } - } - - if follow.Edges.Chat != nil { - convertedFollow.ChatID = follow.Edges.Chat.ID - chatSettings := &db_models.ChatSettings{} - - if follow.Edges.Chat.Edges.Settings != nil { - chatSettings.ID = follow.Edges.Chat.Edges.Settings.ID - chatSettings.ChatID = follow.Edges.Chat.Edges.Settings.ChatID - chatSettings.ChatLanguage = db_models.ChatLanguage( - follow.Edges.Chat.Edges.Settings.ChatLanguage, - ) - chatSettings.GameChangeNotification = follow.Edges.Chat.Edges.Settings.GameChangeNotification - chatSettings.TitleChangeNotification = follow.Edges.Chat.Edges.Settings.TitleChangeNotification - chatSettings.OfflineNotification = follow.Edges.Chat.Edges.Settings.OfflineNotification - chatSettings.ImageInNotification = follow.Edges.Chat.Edges.Settings.ImageInNotification - chatSettings.GameAndTitleChangeNotification = follow.Edges.Chat.Edges.Settings.GameAndTitleChangeNotification - } - - convertedFollow.Chat = &db_models.Chat{ - ID: follow.Edges.Chat.ID, - ChatID: follow.Edges.Chat.ChatID, - Service: db_models.ChatService(follow.Edges.Chat.Service), - Settings: chatSettings, - } - } - - return convertedFollow -} - -func (f *followService) Create( - ctx context.Context, - channelID uuid.UUID, - chatID uuid.UUID, -) (*db_models.Follow, error) { - _, err := f.entClient.Follow. - Create(). - SetChatID(chatID). - SetChannelID(channelID). - Save(ctx) - - if ent.IsConstraintError(err) { - return nil, db_models.FollowAlreadyExistsError - } else if err != nil { - return nil, err - } - - return f.GetByChatAndChannel(ctx, channelID, chatID) -} - -func (f *followService) Delete(ctx context.Context, followID uuid.UUID) error { - err := f.entClient.Follow. - DeleteOneID(followID). - Exec(ctx) - if err != nil { - return err - } - - return nil -} - -func (f *followService) GetByChatAndChannel( - ctx context.Context, - channelID uuid.UUID, - chatID uuid.UUID, -) (*db_models.Follow, error) { - fol, err := f.entClient.Follow. - Query(). - Where(follow.ChannelID(channelID), follow.ChatID(chatID)). - WithChannel(). - WithChat( - func(query *ent.ChatQuery) { - query.WithSettings() - }, - ). - First(ctx) - - if err != nil && ent.IsNotFound(err) { - return nil, db_models.FollowNotFoundError - } else if err != nil { - return nil, err - } - - if fol == nil { - return nil, nil - } - - return f.convertEntity(fol), err -} - -func (f *followService) GetByChannelID( - ctx context.Context, - channelID uuid.UUID, -) ([]*db_models.Follow, error) { - follows, err := f.entClient.Follow. - Query(). - Where(follow.HasChannelWith(channel.IDEQ(channelID))). - WithChannel(). - WithChat( - func(query *ent.ChatQuery) { - query.WithSettings() - }, - ). - All(ctx) - - if err != nil { - return nil, err - } - - result := make([]*db_models.Follow, 0, len(follows)) - for _, foll := range follows { - if foll != nil { - result = append(result, f.convertEntity(foll)) - } - } - - return result, nil -} - -func (f *followService) GetByChatID( - ctx context.Context, - chatID uuid.UUID, - limit, - offset int, -) ([]*db_models.Follow, error) { - query := f.entClient.Follow. - Query(). - Where(follow.HasChatWith(chat.IDEQ(chatID))). - WithChat( - func(query *ent.ChatQuery) { - query.WithSettings() - }, - ). - WithChannel(). - Order(ent.Desc(follow.FieldChannelID)) - - if limit > 0 { - query = query.Limit(limit) - } - query.Offset(offset) - - follows, err := query.All(ctx) - - if err != nil { - return nil, err - } - - result := make([]*db_models.Follow, len(follows)) - for i, foll := range follows { - result[i] = f.convertEntity(foll) - } - - return result, nil -} - -func (f *followService) CountByChatID(_ context.Context, chatID uuid.UUID) (int, error) { - count, err := f.entClient.Follow.Query(). - Where(follow.ChatIDEQ(chatID)). - Count(context.Background()) - if err != nil { - return 0, err - } - - return count, nil -} - -func NewFollowService(entClient *ent.Client) FollowInterface { - return &followService{entClient: entClient} -} diff --git a/internal/db/follow_ent_test.go b/internal/db/follow_ent_test.go deleted file mode 100644 index 1d8d48a2..00000000 --- a/internal/db/follow_ent_test.go +++ /dev/null @@ -1,269 +0,0 @@ -package db - -import ( - "context" - "fmt" - "github.com/google/uuid" - db_models2 "github.com/satont/twitch-notifier/internal/db/db_models" - "github.com/stretchr/testify/assert" - "testing" -) - -func TestFollowService_Create(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - assert.NoError(t, err) - defer teardownTest(entClient) - - ctx := context.Background() - - chService := NewChatEntRepository(entClient) - channelsService := NewChannelEntService(entClient) - service := NewFollowService(entClient) - - newChat, err := chService.Create(ctx, "1", db_models2.ChatServiceTelegram) - assert.NoError(t, err) - - newChannel, err := channelsService.Create(ctx, "1", db_models2.ChannelServiceTwitch) - assert.NoError(t, err) - - table := []struct { - name string - chatID uuid.UUID - channelID uuid.UUID - wantErr bool - }{ - { - name: "Create follow", - chatID: newChat.ID, - channelID: newChannel.ID, - wantErr: false, - }, - { - name: "Should fail if follow already exists", - chatID: newChat.ID, - channelID: newChannel.ID, - wantErr: true, - }, - } - - for _, tt := range table { - t.Run(tt.name, func(t *testing.T) { - f, err := service.Create(ctx, tt.channelID, tt.chatID) - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, newChannel.ID, f.ChannelID, "Expects channel_id to be equal.") - assert.Equal(t, newChat.ID, f.ChatID, "Expects chat_id to be equal.") - } - }) - } -} - -func TestFollowService_Delete(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - assert.NoError(t, err) - defer teardownTest(entClient) - - ctx := context.Background() - - chService := NewChatEntRepository(entClient) - channelsService := NewChannelEntService(entClient) - service := NewFollowService(entClient) - - newChat, err := chService.Create(ctx, "1", db_models2.ChatServiceTelegram) - assert.NoError(t, err) - - newChannel, err := channelsService.Create(ctx, "1", db_models2.ChannelServiceTwitch) - assert.NoError(t, err) - - foll, err := service.Create(ctx, newChannel.ID, newChat.ID) - - table := []struct { - name string - id uuid.UUID - wantErr bool - }{ - { - name: "Delete follow", - id: foll.ID, - wantErr: false, - }, - { - name: "Should fail if follow does not exist", - id: uuid.New(), - wantErr: true, - }, - } - - for _, tt := range table { - t.Run(tt.name, func(t *testing.T) { - err := service.Delete(ctx, tt.id) - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestFollowService_GetByChatAndChannel(t *testing.T) { - entClient, err := setupTest() - assert.NoError(t, err) - defer teardownTest(entClient) - - ctx := context.Background() - - chService := NewChatEntRepository(entClient) - channelsService := NewChannelEntService(entClient) - service := NewFollowService(entClient) - - newChat, err := chService.Create(ctx, "1", db_models2.ChatServiceTelegram) - assert.NoError(t, err) - - newChannel, err := channelsService.Create(ctx, "1", db_models2.ChannelServiceTwitch) - assert.NoError(t, err) - - _, err = service.Create(ctx, newChannel.ID, newChat.ID) - assert.NoError(t, err) - - table := []struct { - name string - chatID uuid.UUID - channelID uuid.UUID - wantNil bool - wantErr bool - }{ - { - name: "Get follow", - chatID: newChat.ID, - channelID: newChannel.ID, - wantNil: false, - }, - { - name: "Should fail if follow does not exist", - chatID: uuid.New(), - channelID: uuid.New(), - wantNil: true, - wantErr: true, - }, - } - - for _, tt := range table { - t.Run(tt.name, func(t *testing.T) { - f, err := service.GetByChatAndChannel(ctx, tt.channelID, tt.chatID) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - - } - if tt.wantNil { - assert.Nil(t, f) - } else { - assert.Equal(t, newChannel.ID, f.ChannelID, "Expects channel_id to be equal.") - assert.Equal(t, newChat.ID, f.ChatID, "Expects chat_id to be equal.") - } - }) - } -} - -func TestFollowService_GetByChannelID(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - assert.NoError(t, err) - defer teardownTest(entClient) - - ctx := context.Background() - - chService := NewChatEntRepository(entClient) - channelsService := NewChannelEntService(entClient) - service := NewFollowService(entClient) - - newChat, err := chService.Create(ctx, "1", db_models2.ChatServiceTelegram) - assert.NoError(t, err) - - channelsIds := make([]uuid.UUID, 0) - - for i := 0; i < 5; i++ { - ch, err := channelsService.Create( - ctx, - fmt.Sprintf("%v", i), - db_models2.ChannelServiceTwitch, - ) - assert.NoError(t, err) - channelsIds = append(channelsIds, ch.ID) - } - - for _, channelID := range channelsIds { - f, err := service.Create(ctx, channelID, newChat.ID) - assert.NoError(t, err) - assert.Equal(t, channelID, f.ChannelID, "Expects channel_id to be equal.") - assert.Equal(t, newChat.ID, f.ChatID, "Expects chat_id to be equal.") - } - - for _, channelID := range channelsIds { - follows, err := service.GetByChannelID(ctx, channelID) - assert.NoError(t, err) - - for _, foll := range follows { - assert.Equal(t, channelID, foll.ChannelID, "Expects channel_id to be equal.") - assert.Equal(t, newChat.ID, foll.ChatID, "Expects chat_id to be equal.") - } - } -} - -func TestFollowService_GetByChatID(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - assert.NoError(t, err) - defer teardownTest(entClient) - - ctx := context.Background() - - chService := NewChatEntRepository(entClient) - channelsService := NewChannelEntService(entClient) - service := NewFollowService(entClient) - - newChat, err := chService.Create(ctx, "1", db_models2.ChatServiceTelegram) - assert.NoError(t, err) - - channelsIds := make([]uuid.UUID, 0) - - for i := 0; i < 5; i++ { - ch, err := channelsService.Create( - ctx, - fmt.Sprintf("%v", i), - db_models2.ChannelServiceTwitch, - ) - assert.NoError(t, err) - channelsIds = append(channelsIds, ch.ID) - } - - for _, channelID := range channelsIds { - f, err := service.Create(ctx, channelID, newChat.ID) - assert.NoError(t, err) - assert.Equal(t, channelID, f.ChannelID, "Expects channel_id to be equal.") - assert.Equal(t, newChat.ID, f.ChatID, "Expects chat_id to be equal.") - } - - follows, err := service.GetByChatID(ctx, newChat.ID, 0, 0) - assert.NoError(t, err) - assert.Len(t, follows, 5) - - for _, foll := range follows { - assert.Equal(t, newChat.ID, foll.ChatID, "Expects chat_id to be equal.") - } - - followsPaginated, err := service.GetByChatID(ctx, newChat.ID, 0, 2) - assert.NoError(t, err) - assert.Len(t, followsPaginated, 3) -} diff --git a/internal/db/mock_db.go b/internal/db/mock_db.go deleted file mode 100644 index d29e7be8..00000000 --- a/internal/db/mock_db.go +++ /dev/null @@ -1,26 +0,0 @@ -package db - -import ( - "context" - "fmt" - "github.com/satont/twitch-notifier/ent" - "time" -) - -func setupTest() (*ent.Client, error) { - source := fmt.Sprintf("file:tests%v?mode=memory&cache=shared&_fk=1", time.Now().UnixMicro()) - - entClient, err := ent.Open("sqlite3", source) - if err != nil { - return nil, err - } - if err := entClient.Schema.Create(context.Background()); err != nil { - fmt.Println(err) - return nil, err - } - return entClient, nil -} - -func teardownTest(entClient *ent.Client) { - _ = entClient.Close() -} diff --git a/internal/db/stream.go b/internal/db/stream.go deleted file mode 100644 index 51f0357e..00000000 --- a/internal/db/stream.go +++ /dev/null @@ -1,39 +0,0 @@ -package db - -import ( - "context" - "github.com/google/uuid" - "github.com/satont/twitch-notifier/internal/db/db_models" -) - -type StreamUpdateQuery struct { - StreamID string - IsLive *bool - Category *string - Title *string -} - -type StreamInterface interface { - GetByID(_ context.Context, streamId string) (*db_models.Stream, error) - - GetLatestByChannelID( - _ context.Context, - channelEntityID uuid.UUID, - ) (*db_models.Stream, error) - GetManyByChannelID( - _ context.Context, - channelEntityID uuid.UUID, - limit int, - ) ([]*db_models.Stream, error) - - UpdateOneByStreamID( - _ context.Context, - streamID string, - updateQuery *StreamUpdateQuery, - ) (*db_models.Stream, error) - CreateOneByChannelID( - _ context.Context, - channelEntityID uuid.UUID, - updateQuery *StreamUpdateQuery, - ) (*db_models.Stream, error) -} diff --git a/internal/db/stream_impl_ent.go b/internal/db/stream_impl_ent.go deleted file mode 100644 index 27e3dd21..00000000 --- a/internal/db/stream_impl_ent.go +++ /dev/null @@ -1,161 +0,0 @@ -package db - -import ( - "context" - "errors" - "github.com/google/uuid" - "github.com/lib/pq" - "github.com/satont/twitch-notifier/ent" - "github.com/satont/twitch-notifier/ent/channel" - "github.com/satont/twitch-notifier/ent/stream" - "github.com/satont/twitch-notifier/internal/db/db_models" - "time" -) - -type StreamEntService struct { - entClient *ent.Client -} - -func (s *StreamEntService) convertEntity(stream *ent.Stream) *db_models.Stream { - return &db_models.Stream{ - ID: stream.ID, - ChannelID: stream.ChannelID, - Titles: stream.Titles, - Categories: stream.Categories, - StartedAt: stream.StartedAt, - UpdatedAt: stream.UpdatedAt, - EndedAt: stream.EndedAt, - } -} - -func (s *StreamEntService) GetByID(ctx context.Context, streamID string) (*db_models.Stream, error) { - str, err := s.entClient.Stream.Query().Where(stream.IDEQ(streamID)).Only(ctx) - if err != nil { - if ent.IsNotFound(err) { - return nil, nil - } else { - return nil, err - } - } - - return s.convertEntity(str), nil -} - -func (s *StreamEntService) GetLatestByChannelID( - ctx context.Context, - channelEntityID uuid.UUID, -) (*db_models.Stream, error) { - str, err := s.entClient.Stream. - Query(). - Where(stream.ChannelIDEQ(channelEntityID), stream.EndedAtIsNil()). - Order(ent.Desc(stream.FieldStartedAt)). - First(ctx) - if err != nil { - if ent.IsNotFound(err) { - return nil, nil - } else { - return nil, err - } - } - - return s.convertEntity(str), nil -} - -func (s *StreamEntService) GetManyByChannelID( - ctx context.Context, - channelEntityID uuid.UUID, - limit int, -) ([]*db_models.Stream, error) { - streams, err := s.entClient.Stream. - Query(). - Where(stream.HasChannelWith(channel.IDEQ(channelEntityID))). - Order(ent.Desc(stream.FieldStartedAt)). - Limit(limit). - All(ctx) - - if err != nil { - return nil, err - } - - convertedStreams := make([]*db_models.Stream, len(streams)) - for i, str := range streams { - convertedStreams[i] = s.convertEntity(str) - } - - return convertedStreams, err -} - -func (s *StreamEntService) UpdateOneByStreamID( - ctx context.Context, - streamID string, - updateQuery *StreamUpdateQuery, -) (*db_models.Stream, error) { - str, err := s.GetByID(ctx, streamID) - if err != nil { - return nil, err - } - if str == nil { - return nil, errors.New("stream not found") - } - - query := s.entClient.Stream.UpdateOneID(str.ID) - - if updateQuery.IsLive != nil && *updateQuery.IsLive { - query.SetStartedAt(time.Now().UTC()) - } - - if updateQuery.IsLive != nil && !*updateQuery.IsLive { - query.SetEndedAt(time.Now().UTC()) - } - - if updateQuery.Category != nil { - str.Categories = append(str.Categories, *updateQuery.Category) - query.SetCategories(str.Categories) - } - - if updateQuery.Title != nil { - str.Titles = append(str.Titles, *updateQuery.Title) - query.SetTitles(str.Titles) - } - - newStream, err := query.Save(ctx) - if err != nil { - return nil, err - } - - return s.convertEntity(newStream), nil -} - -func (s *StreamEntService) CreateOneByChannelID( - ctx context.Context, - channelEntityID uuid.UUID, - data *StreamUpdateQuery, -) (*db_models.Stream, error) { - query := s.entClient.Stream.Create() - - query.SetChannelID(channelEntityID) - - query.SetStartedAt(time.Now().UTC()) - query.SetID(data.StreamID) - - if data.Title != nil { - query.SetTitles(pq.StringArray{*data.Title}) - } - - if data.Category != nil { - query.SetCategories(pq.StringArray{*data.Category}) - } - - str, err := query.Save(ctx) - if err != nil { - return nil, err - } - - return s.convertEntity(str), nil -} - -func NewStreamEntService(entClient *ent.Client) *StreamEntService { - return &StreamEntService{ - entClient: entClient, - } -} diff --git a/internal/db/stream_impl_ent_test.go b/internal/db/stream_impl_ent_test.go deleted file mode 100644 index 8fc7e384..00000000 --- a/internal/db/stream_impl_ent_test.go +++ /dev/null @@ -1,293 +0,0 @@ -package db - -import ( - "context" - "github.com/samber/lo" - "github.com/satont/twitch-notifier/internal/db/db_models" - "github.com/stretchr/testify/assert" - "testing" -) - -func TestStreamEntService_GetByID(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - assert.NoError(t, err) - defer teardownTest(entClient) - - ctx := context.Background() - - channelsService := NewChannelEntService(entClient) - service := NewStreamEntService(entClient) - - newChannel, err := channelsService.Create(ctx, "1", db_models.ChannelServiceTwitch) - assert.NoError(t, err) - assert.Equal(t, "1", newChannel.ChannelID, "Expects channel_id to be equal.") - - _, err = channelsService.Create(ctx, "2", db_models.ChannelServiceTwitch) - assert.NoError(t, err) - - table := []struct { - name string - channelID string - wantNil bool - create bool - streamID string - }{ - { - name: "Get stream by id", - channelID: newChannel.ChannelID, - wantNil: false, - create: true, - streamID: "1", - }, - { - name: "Should return nil if stream not found", - channelID: "2", - wantNil: true, - create: false, - streamID: "2", - }, - } - - for _, tt := range table { - t.Run(tt.name, func(t *testing.T) { - if tt.create { - _, err = service.CreateOneByChannelID(ctx, newChannel.ID, &StreamUpdateQuery{ - IsLive: nil, - Category: nil, - Title: nil, - StreamID: tt.streamID, - }) - assert.NoError(t, err) - } - - stream, err := service.GetByID(ctx, tt.streamID) - if tt.wantNil { - assert.Nil(t, stream) - } else { - assert.NoError(t, err) - assert.Equal(t, newChannel.ID, stream.ChannelID, "Expects channel_id to be equal.") - assert.Equal(t, tt.streamID, stream.ID, "Expects stream_id to be equal.") - } - }) - } -} - -func TestStreamEntService_GetLatestByChannelID(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - assert.NoError(t, err) - defer teardownTest(entClient) - - ctx := context.Background() - - channelsService := NewChannelEntService(entClient) - service := NewStreamEntService(entClient) - - newChannel, err := channelsService.Create(ctx, "1", db_models.ChannelServiceTwitch) - assert.NoError(t, err) - - table := []struct { - name string - channelID string - wantNil bool - wantedStreamID string - clearTable bool - before func() - }{ - { - name: "Get latest stream by channel id", - channelID: newChannel.ChannelID, - wantNil: false, - wantedStreamID: "321", - clearTable: true, - before: func() { - _, _ = service.CreateOneByChannelID(ctx, newChannel.ID, &StreamUpdateQuery{ - StreamID: "321", - IsLive: lo.ToPtr(true), - Category: lo.ToPtr("Category"), - Title: lo.ToPtr("Title"), - }) - }, - }, - { - name: "Should return nil if stream not found", - channelID: newChannel.ChannelID, - wantNil: true, - wantedStreamID: "2", - clearTable: true, - before: func() {}, - }, - { - name: "Should return correct stream", - channelID: newChannel.ChannelID, - wantNil: false, - before: func() { - _, _ = service.CreateOneByChannelID(ctx, newChannel.ID, &StreamUpdateQuery{ - StreamID: "321", - IsLive: lo.ToPtr(false), - Category: lo.ToPtr("Category"), - Title: lo.ToPtr("Title"), - }) - _, _ = service.CreateOneByChannelID(ctx, newChannel.ID, &StreamUpdateQuery{ - StreamID: "4321", - IsLive: lo.ToPtr(true), - Category: lo.ToPtr("Category"), - Title: lo.ToPtr("Title"), - }) - }, - wantedStreamID: "4321", - clearTable: true, - }, - } - - for _, tt := range table { - t.Run(tt.name, func(t *testing.T) { - tt.before() - - stream, err := service.GetLatestByChannelID(ctx, newChannel.ID) - assert.NoError(t, err) - - if tt.wantNil { - assert.Nil(t, stream) - } else { - assert.NotNil(t, stream) - assert.Equal(t, newChannel.ID, stream.ChannelID, "Expects channel_id to be equal.") - assert.Equal(t, tt.wantedStreamID, stream.ID, "Expects stream_id to be equal.") - assert.Nil(t, stream.EndedAt, "Expects is_live to be equal.") - assert.Contains(t, stream.Categories, "Category", "Expects category to be equal.") - assert.Contains(t, stream.Titles, "Title", "Expects title to be equal.") - } - - if tt.clearTable { - _, err = entClient.Stream.Delete().Exec(ctx) - assert.NoError(t, err) - } - }) - } - -} - -func TestStreamEntService_GetManyByChannelID(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - assert.NoError(t, err) - - defer teardownTest(entClient) - - ctx := context.Background() - - channelsService := NewChannelEntService(entClient) - service := NewStreamEntService(entClient) - - newChannel, err := channelsService.Create(ctx, "1", db_models.ChannelServiceTwitch) - - assert.NoError(t, err) - - _, err = service.CreateOneByChannelID(ctx, newChannel.ID, &StreamUpdateQuery{ - StreamID: "123", - IsLive: lo.ToPtr(true), - Category: nil, - Title: nil, - }) - assert.NoError(t, err) - - _, err = service.CreateOneByChannelID(ctx, newChannel.ID, &StreamUpdateQuery{ - StreamID: "321", - IsLive: lo.ToPtr(true), - Category: lo.ToPtr("Category"), - Title: nil, - }) - assert.NoError(t, err) - - streams, err := service.GetManyByChannelID(ctx, newChannel.ID, 100) - assert.NoError(t, err) - - assert.Len(t, streams, 2, "Expects streams length to be equal.") - assert.Equal(t, "321", streams[0].ID, "Expects stream_id to be equal.") - assert.Contains(t, streams[0].Categories, "Category", "Expects category to be equal.") - assert.Equal(t, "123", streams[1].ID, "Expects stream_id to be equal.") - - _, err = entClient.Stream.Delete().Exec(ctx) - assert.NoError(t, err) - - streams, err = service.GetManyByChannelID(ctx, newChannel.ID, 100) - assert.NoError(t, err) - assert.Len(t, streams, 0, "Expects streams length to be equal.") -} - -func TestStreamEntService_UpdateOneByStreamID(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - assert.NoError(t, err) - - defer teardownTest(entClient) - - ctx := context.Background() - - channelsService := NewChannelEntService(entClient) - service := NewStreamEntService(entClient) - - newChannel, err := channelsService.Create(ctx, "1", db_models.ChannelServiceTwitch) - assert.NoError(t, err) - - _, err = service.CreateOneByChannelID(ctx, newChannel.ID, &StreamUpdateQuery{ - StreamID: "123", - IsLive: lo.ToPtr(true), - Category: nil, - Title: nil, - }) - assert.NoError(t, err) - - newStream, err := service.UpdateOneByStreamID(ctx, "123", &StreamUpdateQuery{ - IsLive: lo.ToPtr(false), - Title: lo.ToPtr("Title"), - Category: lo.ToPtr("Category"), - }) - assert.NoError(t, err) - - assert.Equal(t, "123", newStream.ID, "Expects stream_id to be equal.") - assert.Equal(t, newChannel.ID, newStream.ChannelID, "Expects channel_id to be equal.") - assert.Equal(t, "Title", newStream.Titles[0], "Expects title to be equal.") - assert.Equal(t, "Category", newStream.Categories[0], "Expects category to be equal.") - assert.NotNil(t, newStream.EndedAt, "Expects ended_at to be not nil.") - - stream, err := service.UpdateOneByStreamID(ctx, "321", &StreamUpdateQuery{}) - assert.Error(t, err) - assert.Nil(t, stream) -} - -func TestStreamEntService_CreateOneByChannelID(t *testing.T) { - t.Parallel() - - entClient, err := setupTest() - assert.NoError(t, err) - - defer teardownTest(entClient) - - ctx := context.Background() - - channelsService := NewChannelEntService(entClient) - service := NewStreamEntService(entClient) - - newChannel, err := channelsService.Create(ctx, "1", db_models.ChannelServiceTwitch) - assert.NoError(t, err) - - newStream, err := service.CreateOneByChannelID(ctx, newChannel.ID, &StreamUpdateQuery{ - StreamID: "123", - IsLive: lo.ToPtr(true), - Category: nil, - Title: nil, - }) - assert.NoError(t, err) - - assert.Equal(t, "123", newStream.ID, "Expects stream_id to be equal.") - assert.Equal(t, newChannel.ID, newStream.ChannelID, "Expects channel_id to be equal.") - assert.Nil(t, newStream.EndedAt, "Expects ended_at to be nil.") - assert.NotNil(t, newStream.StartedAt, "Expects started_at to be not nil.") - assert.Len(t, newStream.Categories, 0, "Expects categories length to be equal.") -} diff --git a/internal/domain/channel.go b/internal/domain/channel.go new file mode 100644 index 00000000..559027b2 --- /dev/null +++ b/internal/domain/channel.go @@ -0,0 +1,19 @@ +package domain + +import ( + "github.com/google/uuid" +) + +type Channel struct { + ID uuid.UUID + ChannelID string + Service StreamingService +} + +type PlatformChannelInformation struct { + BroadcasterID string + BroadcasterName string + GameName string + Title string + ChannelLink string +} diff --git a/internal/domain/chat.go b/internal/domain/chat.go new file mode 100644 index 00000000..f1ef816d --- /dev/null +++ b/internal/domain/chat.go @@ -0,0 +1,11 @@ +package domain + +import ( + "github.com/google/uuid" +) + +type Chat struct { + ID uuid.UUID + Service ChatService + ChatID string +} diff --git a/internal/domain/chat_settings.go b/internal/domain/chat_settings.go new file mode 100644 index 00000000..5386cd8c --- /dev/null +++ b/internal/domain/chat_settings.go @@ -0,0 +1,16 @@ +package domain + +import ( + "github.com/google/uuid" +) + +type ChatSettings struct { + ID uuid.UUID + ChatID uuid.UUID + Language Language + CategoryChangeNotifications bool + TitleChangeNotifications bool + OfflineNotifications bool + CategoryAndTitleNotifications bool + ShowThumbnail bool +} diff --git a/internal/domain/enums.go b/internal/domain/enums.go new file mode 100644 index 00000000..75f7f583 --- /dev/null +++ b/internal/domain/enums.go @@ -0,0 +1,33 @@ +package domain + +type Language string + +func (l Language) String() string { + return string(l) +} + +const ( + LanguageEN Language = "en" + LanguageRU Language = "ru" + LanguageUA Language = "ua" +) + +type ChatService string + +func (c ChatService) String() string { + return string(c) +} + +const ( + ChatServiceTelegram ChatService = "telegram" +) + +type StreamingService string + +func (s StreamingService) String() string { + return string(s) +} + +const ( + StreamingServiceTwitch StreamingService = "twitch" +) diff --git a/internal/domain/follow.go b/internal/domain/follow.go new file mode 100644 index 00000000..b5069b53 --- /dev/null +++ b/internal/domain/follow.go @@ -0,0 +1,14 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" +) + +type Follow struct { + ID uuid.UUID + ChatID uuid.UUID + ChannelID uuid.UUID + CreatedAt time.Time +} diff --git a/internal/domain/stream.go b/internal/domain/stream.go new file mode 100644 index 00000000..38befd3f --- /dev/null +++ b/internal/domain/stream.go @@ -0,0 +1,17 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" +) + +type Stream struct { + ID uuid.UUID + ChannelID uuid.UUID + Titles []string + Categories []string + StartedAt time.Time + UpdatedAt time.Time + EndedAt *time.Time +} diff --git a/internal/i18n/localizer/impl.go b/internal/i18n/localizer/impl.go new file mode 100644 index 00000000..211693b0 --- /dev/null +++ b/internal/i18n/localizer/impl.go @@ -0,0 +1,112 @@ +package localizer + +import ( + "fmt" + "regexp" + "strings" + + "github.com/satont/twitch-notifier/internal/domain" + "github.com/satont/twitch-notifier/internal/i18n/store" +) + +type LocalizeOpts struct { + Store store.I18nStore +} + +func NewLocalizer(i18nStore store.I18nStore) *Impl { + + return &Impl{ + store: i18nStore, + regular: regexp.MustCompile(`{{\s*(\w+)\s*}}`), + } +} + +var _ Localizer = (*Impl)(nil) + +type Impl struct { + store store.I18nStore + regular *regexp.Regexp +} + +const defaultLanguage = domain.LanguageEN + +func (c *Impl) Localize(opts ...Option) (string, error) { + options := &Options{ + attributes: make(map[string]any), + } + for _, opt := range opts { + opt.apply(options) + } + + if options.key == "" { + return "", ErrKeyIsEmpty + } + + key, err := c.store.GetKey(options.language, options.key) + if err != nil { + return "", fmt.Errorf("failed to get key: %w", err) + } + + variablesMatches := c.regular.FindAllStringSubmatch(key, -1) + if len(variablesMatches) == 0 { + return key, nil + } + + res := key + for _, match := range variablesMatches { + if len(match) != 2 { + continue + } + + variableValue := options.attributes[match[1]] + if variableValue == nil { + continue + } + + res = strings.ReplaceAll(res, match[0], fmt.Sprintf("%v", variableValue)) + } + + return res, nil +} + +func (c *Impl) MustLocalize(opts ...Option) string { + key, err := c.Localize(opts...) + if err != nil { + opts = append( + opts, + WithLanguage(defaultLanguage), + ) + key, err = c.Localize(opts...) + if err != nil { + panic(err) + } + } + + return key +} + +func (f applyFunc) apply(s *Options) { f(s) } + +func WithKey(key string) Option { + return applyFunc( + func(s *Options) { + s.key = key + }, + ) +} + +func WithLanguage(language domain.Language) Option { + return applyFunc( + func(s *Options) { + s.language = language + }, + ) +} + +func WithAttribute(key string, value any) Option { + return applyFunc( + func(s *Options) { + s.attributes[key] = value + }, + ) +} diff --git a/internal/i18n/localizer/impl_test.go b/internal/i18n/localizer/impl_test.go new file mode 100644 index 00000000..8a69c668 --- /dev/null +++ b/internal/i18n/localizer/impl_test.go @@ -0,0 +1,173 @@ +package localizer + +import ( + "testing" + + "github.com/satont/twitch-notifier/internal/domain" + "github.com/satont/twitch-notifier/internal/i18n/store" + "github.com/satont/twitch-notifier/internal/i18n/store/mocks" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestImpl_Localize(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + store := mock_store.NewMockI18nStore(ctrl) + + impl := NewLocalizer(store) + + table := []struct { + name string + language domain.Language + key string + attrs map[string]any + expected string + expectedErr error + mock func() + }{ + { + name: "empty key", + language: domain.LanguageEN, + key: "", + attrs: nil, + expected: "", + expectedErr: ErrKeyIsEmpty, + }, + { + name: "correct replace channel name", + language: domain.LanguageEN, + key: "test", + attrs: map[string]any{"channel": "notifier"}, + expected: "test notifier", + expectedErr: nil, + mock: func() { + store.EXPECT().GetKey(domain.LanguageEN, "test").Return("test {{ channel }}", nil) + }, + }, + { + name: "should not replace unknown attribute", + language: domain.LanguageEN, + key: "test", + attrs: nil, + expected: "test {{ channel }}", + expectedErr: nil, + mock: func() { + store.EXPECT().GetKey(domain.LanguageEN, "test").Return("test {{ channel }}", nil) + }, + }, + } + + for _, tt := range table { + t.Run( + tt.name, func(t *testing.T) { + t.Parallel() + + if tt.mock != nil { + tt.mock() + } + + opts := []Option{ + WithLanguage(tt.language), + WithKey(tt.key), + } + for k, v := range tt.attrs { + opts = append(opts, WithAttribute(k, v)) + } + + res, err := impl.Localize(opts...) + if tt.expectedErr != nil { + assert.ErrorIs(t, err, tt.expectedErr) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, res) + } + }, + ) + } +} + +func TestImpl_MustLocalize(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mockedStore := mock_store.NewMockI18nStore(ctrl) + + impl := NewLocalizer(mockedStore) + + table := []struct { + name string + language domain.Language + key string + attrs map[string]any + expected string + expectedErr error + mock func() + shouldPanic bool + }{ + { + name: "should take default language string", + language: domain.LanguageRU, + key: "test", + attrs: nil, + expected: "test {{ channel }}", + expectedErr: nil, + mock: func() { + mockedStore.EXPECT().GetKey(domain.LanguageRU, "test").Return("", store.ErrKeyNotFound) + mockedStore.EXPECT().GetKey(domain.LanguageEN, "test").Return("test {{ channel }}", nil) + }, + }, + { + name: "should panic if default language string not found", + language: domain.LanguageRU, + key: "test", + mock: func() { + mockedStore.EXPECT().GetKey(domain.LanguageRU, "test").Return("", store.ErrKeyNotFound) + mockedStore.EXPECT().GetKey(domain.LanguageEN, "test").Return("", store.ErrKeyNotFound) + }, + shouldPanic: true, + }, + } + + for _, tt := range table { + t.Run( + tt.name, func(t *testing.T) { + t.Parallel() + + if tt.mock != nil { + tt.mock() + } + + if tt.shouldPanic { + assert.Panics( + t, + func() { + opts := []Option{ + WithLanguage(tt.language), + WithKey(tt.key), + } + for k, v := range tt.attrs { + opts = append(opts, WithAttribute(k, v)) + } + + impl.MustLocalize(opts...) + }, + ) + return + } + + opts := []Option{ + WithLanguage(tt.language), + WithKey(tt.key), + } + for k, v := range tt.attrs { + opts = append(opts, WithAttribute(k, v)) + } + + res := impl.MustLocalize(opts...) + assert.Equal(t, tt.expected, res) + }, + ) + } +} diff --git a/internal/i18n/localizer/localizer.go b/internal/i18n/localizer/localizer.go new file mode 100644 index 00000000..e8b96f24 --- /dev/null +++ b/internal/i18n/localizer/localizer.go @@ -0,0 +1,31 @@ +package localizer + +import ( + "errors" + + "github.com/satont/twitch-notifier/internal/domain" +) + +var ErrKeyIsEmpty = errors.New("key is empty") +var ErrTranslateError = errors.New("failed to translate") + +//go:generate go run go.uber.org/mock/mockgen -source=localizer.go -destination=mocks/mock.go + +type Localizer interface { + Localize(opts ...Option) (string, error) + MustLocalize(opts ...Option) string +} + +type Options struct { + key string + language domain.Language + attributes map[string]any +} + +type ( + Option interface { + apply(options *Options) + } + + applyFunc func(options *Options) +) diff --git a/internal/i18n/localizer/mocks/mock.go b/internal/i18n/localizer/mocks/mock.go new file mode 100644 index 00000000..97ae7984 --- /dev/null +++ b/internal/i18n/localizer/mocks/mock.go @@ -0,0 +1,112 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: localizer.go +// +// Generated by this command: +// +// mockgen -source=localizer.go -destination=mocks/mock.go +// + +// Package mock_localizer is a generated GoMock package. +package mock_localizer + +import ( + reflect "reflect" + + localizer "github.com/satont/twitch-notifier/internal/i18n/localizer" + gomock "go.uber.org/mock/gomock" +) + +// MockLocalizer is a mock of Localizer interface. +type MockLocalizer struct { + ctrl *gomock.Controller + recorder *MockLocalizerMockRecorder +} + +// MockLocalizerMockRecorder is the mock recorder for MockLocalizer. +type MockLocalizerMockRecorder struct { + mock *MockLocalizer +} + +// NewMockLocalizer creates a new mock instance. +func NewMockLocalizer(ctrl *gomock.Controller) *MockLocalizer { + mock := &MockLocalizer{ctrl: ctrl} + mock.recorder = &MockLocalizerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockLocalizer) EXPECT() *MockLocalizerMockRecorder { + return m.recorder +} + +// Localize mocks base method. +func (m *MockLocalizer) Localize(opts ...localizer.Option) (string, error) { + m.ctrl.T.Helper() + varargs := []any{} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Localize", varargs...) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Localize indicates an expected call of Localize. +func (mr *MockLocalizerMockRecorder) Localize(opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Localize", reflect.TypeOf((*MockLocalizer)(nil).Localize), opts...) +} + +// MustLocalize mocks base method. +func (m *MockLocalizer) MustLocalize(opts ...localizer.Option) string { + m.ctrl.T.Helper() + varargs := []any{} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "MustLocalize", varargs...) + ret0, _ := ret[0].(string) + return ret0 +} + +// MustLocalize indicates an expected call of MustLocalize. +func (mr *MockLocalizerMockRecorder) MustLocalize(opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MustLocalize", reflect.TypeOf((*MockLocalizer)(nil).MustLocalize), opts...) +} + +// MockOption is a mock of Option interface. +type MockOption struct { + ctrl *gomock.Controller + recorder *MockOptionMockRecorder +} + +// MockOptionMockRecorder is the mock recorder for MockOption. +type MockOptionMockRecorder struct { + mock *MockOption +} + +// NewMockOption creates a new mock instance. +func NewMockOption(ctrl *gomock.Controller) *MockOption { + mock := &MockOption{ctrl: ctrl} + mock.recorder = &MockOptionMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockOption) EXPECT() *MockOptionMockRecorder { + return m.recorder +} + +// apply mocks base method. +func (m *MockOption) apply(options *localizer.Options) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "apply", options) +} + +// apply indicates an expected call of apply. +func (mr *MockOptionMockRecorder) apply(options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "apply", reflect.TypeOf((*MockOption)(nil).apply), options) +} diff --git a/internal/i18n/store/i18n_store.go b/internal/i18n/store/i18n_store.go new file mode 100644 index 00000000..3361d2e6 --- /dev/null +++ b/internal/i18n/store/i18n_store.go @@ -0,0 +1,12 @@ +package store + +import ( + "github.com/satont/twitch-notifier/internal/domain" +) + +//go:generate go run go.uber.org/mock/mockgen -source=i18n_store.go -destination=mocks/mock.go + +type I18nStore interface { + GetKey(language domain.Language, key string) (string, error) + GetSupportedLanguages() []domain.Language +} diff --git a/internal/i18n/store/impl.go b/internal/i18n/store/impl.go new file mode 100644 index 00000000..6a906b9a --- /dev/null +++ b/internal/i18n/store/impl.go @@ -0,0 +1,113 @@ +package store + +import ( + "encoding/json" + "errors" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/satont/twitch-notifier/internal/domain" + "github.com/satont/twitch-notifier/pkg/logger" +) + +type translation map[string]any + +func New(l logger.Logger) (*Store, error) { + store := &Store{ + locales: make(map[domain.Language]translation), + } + + err := store.readLocales() + if err != nil { + return nil, err + } + + supported := store.GetSupportedLanguages() + l.Info("Locales loaded", slog.Any("locales", supported)) + + return store, nil +} + +type Store struct { + locales map[domain.Language]translation +} + +var _ I18nStore = (*Store)(nil) + +func (c *Store) readLocales() error { + pwd, err := os.Getwd() + if err != nil { + return err + } + + files, err := os.ReadDir(filepath.Join(pwd, "locales")) + if err != nil { + return err + } + + for _, file := range files { + if file.IsDir() { + panic("locales directory should not contain directories") + } + + data := make(map[string]any) + + content, err := os.ReadFile(filepath.Join(pwd, "locales", file.Name())) + if err != nil { + return err + } + + if err := json.Unmarshal(content, &data); err != nil { + return err + } + + name := strings.Replace(file.Name(), ".json", "", 1) + + c.locales[domain.Language(name)] = data + } + + return nil +} + +var ErrLocaleNotFound = errors.New("locale not found") +var ErrKeyNotFound = errors.New("key not found") + +func (c *Store) getMaybeNestedKey(v any, keys ...string) (string, bool) { + res := v + for _, key := range keys { + mp, ok := res.(map[string]any) + if !ok { + var e string + return e, false + } + res = mp[key] + } + a, ok := res.(string) + return a, ok +} + +func (c *Store) GetKey(language domain.Language, key string) (string, error) { + lang := c.locales[language] + if lang == nil { + return "", ErrLocaleNotFound + } + + value, ok := c.getMaybeNestedKey(lang, strings.Split(key, ".")...) + if !ok { + return "", ErrKeyNotFound + } + + return value, nil +} + +func (c *Store) GetSupportedLanguages() []domain.Language { + languages := make([]domain.Language, 0, len(c.locales)) + + for language := range c.locales { + languages = append(languages, language) + } + + return languages +} diff --git a/internal/i18n/store/mocks/mock.go b/internal/i18n/store/mocks/mock.go new file mode 100644 index 00000000..caf3c038 --- /dev/null +++ b/internal/i18n/store/mocks/mock.go @@ -0,0 +1,69 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: i18n_store.go +// +// Generated by this command: +// +// mockgen -source=i18n_store.go -destination=mocks/mock.go +// + +// Package mock_store is a generated GoMock package. +package mock_store + +import ( + reflect "reflect" + + domain "github.com/satont/twitch-notifier/internal/domain" + gomock "go.uber.org/mock/gomock" +) + +// MockI18nStore is a mock of I18nStore interface. +type MockI18nStore struct { + ctrl *gomock.Controller + recorder *MockI18nStoreMockRecorder +} + +// MockI18nStoreMockRecorder is the mock recorder for MockI18nStore. +type MockI18nStoreMockRecorder struct { + mock *MockI18nStore +} + +// NewMockI18nStore creates a new mock instance. +func NewMockI18nStore(ctrl *gomock.Controller) *MockI18nStore { + mock := &MockI18nStore{ctrl: ctrl} + mock.recorder = &MockI18nStoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockI18nStore) EXPECT() *MockI18nStoreMockRecorder { + return m.recorder +} + +// GetKey mocks base method. +func (m *MockI18nStore) GetKey(language domain.Language, key string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetKey", language, key) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetKey indicates an expected call of GetKey. +func (mr *MockI18nStoreMockRecorder) GetKey(language, key any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetKey", reflect.TypeOf((*MockI18nStore)(nil).GetKey), language, key) +} + +// GetSupportedLanguages mocks base method. +func (m *MockI18nStore) GetSupportedLanguages() []domain.Language { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSupportedLanguages") + ret0, _ := ret[0].([]domain.Language) + return ret0 +} + +// GetSupportedLanguages indicates an expected call of GetSupportedLanguages. +func (mr *MockI18nStoreMockRecorder) GetSupportedLanguages() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSupportedLanguages", reflect.TypeOf((*MockI18nStore)(nil).GetSupportedLanguages)) +} diff --git a/internal/message_sender/message_sender.go b/internal/message_sender/message_sender.go deleted file mode 100644 index e177f143..00000000 --- a/internal/message_sender/message_sender.go +++ /dev/null @@ -1,36 +0,0 @@ -package message_sender - -import ( - "context" - - "github.com/mr-linch/go-tg" - "github.com/satont/twitch-notifier/internal/db/db_models" -) - -type MessageOpts struct { - Text string - ImageURL string - ParseMode *tg.ParseMode - Buttons [][]KeyboardButton - SkipButtons bool -} - -type KeyboardButton struct { - // kostil chto bi skipnut knopki v gruppah - SkipInGroup bool - - Text string `json:"text"` - CallbackData string `json:"callback_data,omitempty"` - // this is not needed currently - // URL string `json:"url,omitempty"` - // WebApp *WebAppInfo `json:"web_app,omitempty"` - // LoginURL *LoginURL `json:"login_url,omitempty"` - // SwitchInlineQuery string `json:"switch_inline_query,omitempty"` - // SwitchInlineQueryCurrentChat string `json:"switch_inline_query_current_chat,omitempty"` - // CallbackGame *CallbackGame `json:"callback_game,omitempty"` - // Pay bool `json:"pay,omitempty"` -} - -type MessageSenderInterface interface { - SendMessage(ctx context.Context, chat *db_models.Chat, opts *MessageOpts) error -} diff --git a/internal/message_sender/message_sender_impl.go b/internal/message_sender/message_sender_impl.go deleted file mode 100644 index 95c9f491..00000000 --- a/internal/message_sender/message_sender_impl.go +++ /dev/null @@ -1,87 +0,0 @@ -package message_sender - -import ( - "context" - "strconv" - - "github.com/mr-linch/go-tg" - "github.com/satont/twitch-notifier/internal/db/db_models" -) - -type MessageSender struct { - telegram *tg.Client -} - -func (m *MessageSender) SendMessage(ctx context.Context, chat *db_models.Chat, opts *MessageOpts) error { - if chat.Service == db_models.ChatServiceTelegram { - chatId, err := strconv.Atoi(chat.ChatID) - if err != nil { - return err - } - - var keyboard *tg.InlineKeyboardMarkup - if opts.Buttons != nil && len(opts.Buttons) > 0 { - keyboard = &tg.InlineKeyboardMarkup{ - InlineKeyboard: make([][]tg.InlineKeyboardButton, 0, len(opts.Buttons)), - } - - for _, row := range opts.Buttons { - var buttons []tg.InlineKeyboardButton - for _, button := range row { - if button.SkipInGroup && chatId < 0 { - continue - } - - buttons = append( - buttons, tg.InlineKeyboardButton{ - Text: button.Text, - CallbackData: button.CallbackData, - }, - ) - } - - if len(buttons) != 0 { - keyboard.InlineKeyboard = append(keyboard.InlineKeyboard, buttons) - } - } - } - - if opts.ImageURL != "" { - query := m.telegram. - SendPhoto(tg.ChatID(chatId), tg.FileArg{URL: opts.ImageURL}). - Caption(opts.Text) - - if opts.ParseMode != nil { - query = query.ParseMode(*opts.ParseMode) - } - - if keyboard != nil && keyboard.InlineKeyboard != nil && len(keyboard.InlineKeyboard) > 0 { - query = query.ReplyMarkup(keyboard) - } - - return query.DoVoid(ctx) - } else { - query := m.telegram. - SendMessage(tg.ChatID(chatId), opts.Text). - DisableWebPagePreview(true) - - if keyboard != nil && keyboard.InlineKeyboard != nil && len(keyboard.InlineKeyboard) > 0 { - query = query.ReplyMarkup(keyboard) - } - - if opts.ParseMode != nil { - query = query.ParseMode(*opts.ParseMode) - } - - return query.DoVoid(ctx) - } - } - - return nil -} - -func NewMessageSender(telegram *tg.Client) MessageSenderInterface { - return &MessageSender{ - telegram: telegram, - } -} diff --git a/internal/message_sender/message_sender_impl_test.go b/internal/message_sender/message_sender_impl_test.go deleted file mode 100644 index 1b0283bc..00000000 --- a/internal/message_sender/message_sender_impl_test.go +++ /dev/null @@ -1,240 +0,0 @@ -package message_sender - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/mr-linch/go-tg" - "github.com/satont/twitch-notifier/internal/db/db_models" - - "github.com/satont/twitch-notifier/internal/test_utils" - "github.com/stretchr/testify/assert" -) - -func TestMessageSender_SendMessage(t *testing.T) { - t.Parallel() - - chat := &db_models.Chat{ - ChatID: "-123", - Service: db_models.ChatServiceTelegram, - } - - table := []struct { - name string - chat *db_models.Chat - opts *MessageOpts - createServer func(*testing.T) *httptest.Server - }{ - { - name: "should call send message method", - chat: chat, - opts: &MessageOpts{ - Text: "test", - }, - createServer: func(t *testing.T) *httptest.Server { - return httptest.NewServer( - http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - query, err := url.ParseQuery(string(body)) - assert.NoError(t, err) - - assert.Equal(t, "test", query.Get("text")) - assert.Equal(t, "-123", query.Get("chat_id")) - - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal( - t, - fmt.Sprintf("/bot%s/sendMessage", test_utils.TelegramClientToken), - r.URL.Path, - ) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - }, - ), - ) - }, - }, - { - name: "should call send photo method", - chat: chat, - opts: &MessageOpts{ - Text: "test photo", - ImageURL: "https://example.com/image.jpg", - }, - createServer: func(t *testing.T) *httptest.Server { - return httptest.NewServer( - http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - query, err := url.ParseQuery(string(body)) - assert.NoError(t, err) - - assert.Equal(t, "test photo", query.Get("caption")) - assert.Equal(t, "https://example.com/image.jpg", query.Get("photo")) - assert.Equal(t, "-123", query.Get("chat_id")) - - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal( - t, - fmt.Sprintf("/bot%s/sendPhoto", test_utils.TelegramClientToken), - r.URL.Path, - ) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - }, - ), - ) - }, - }, - { - name: "should call send message method with parse mode", - chat: chat, - opts: &MessageOpts{ - Text: "test md", - ParseMode: &tg.MD, - }, - createServer: func(t *testing.T) *httptest.Server { - return httptest.NewServer( - http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - query, err := url.ParseQuery(string(body)) - assert.NoError(t, err) - - assert.Equal(t, "test md", query.Get("text")) - assert.Equal(t, "-123", query.Get("chat_id")) - assert.Equal(t, "Markdown", query.Get("parse_mode")) - - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal( - t, - fmt.Sprintf("/bot%s/sendMessage", test_utils.TelegramClientToken), - r.URL.Path, - ) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - }, - ), - ) - }, - }, - { - name: "should send keyboard buttons", - chat: chat, - opts: &MessageOpts{ - Text: "test buttons", - Buttons: [][]KeyboardButton{ - { - KeyboardButton{Text: "click me", CallbackData: "click"}, - }, - }, - }, - createServer: func(t *testing.T) *httptest.Server { - return httptest.NewServer( - http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - query, err := url.ParseQuery(string(body)) - assert.NoError(t, err) - - assert.Equal(t, "test buttons", query.Get("text")) - assert.Equal(t, "-123", query.Get("chat_id")) - - keyboard := map[string]any{} - - err = json.Unmarshal([]byte(query.Get("reply_markup")), &keyboard) - assert.NoError(t, err) - - assert.Equal( - t, - "click me", - keyboard["inline_keyboard"].([]interface{})[0].([]interface{})[0].(map[string]any)["text"], - ) - assert.Equal( - t, - "click", - keyboard["inline_keyboard"].([]interface{})[0].([]interface{})[0].(map[string]any)["callback_data"], - ) - - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal( - t, - fmt.Sprintf("/bot%s/sendMessage", test_utils.TelegramClientToken), - r.URL.Path, - ) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - }, - ), - ) - }, - }, - { - name: "should skip button", - chat: chat, - opts: &MessageOpts{ - Text: "test buttons", - Buttons: [][]KeyboardButton{ - { - KeyboardButton{Text: "click me", CallbackData: "click", SkipInGroup: true}, - }, - }, - }, - createServer: func(t *testing.T) *httptest.Server { - return httptest.NewServer( - http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - query, err := url.ParseQuery(string(body)) - assert.NoError(t, err) - - assert.Equal(t, "test buttons", query.Get("text")) - assert.Equal(t, "-123", query.Get("chat_id")) - assert.Empty(t, query.Get("reply_markup")) - - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal( - t, - fmt.Sprintf("/bot%s/sendMessage", test_utils.TelegramClientToken), - r.URL.Path, - ) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - }, - ), - ) - }, - }, - } - - for _, tt := range table { - t.Run( - tt.name, func(c *testing.T) { - server := tt.createServer(c) - tgClient := test_utils.NewTelegramClient(server) - sender := NewMessageSender(tgClient) - - err := sender.SendMessage(context.Background(), tt.chat, tt.opts) - assert.NoError(c, err) - assert.Nil(c, err) - }, - ) - } -} diff --git a/internal/messagesender/fx/fx.go b/internal/messagesender/fx/fx.go new file mode 100644 index 00000000..0974eca1 --- /dev/null +++ b/internal/messagesender/fx/fx.go @@ -0,0 +1,16 @@ +package fx + +import ( + "github.com/satont/twitch-notifier/internal/messagesender" + "github.com/satont/twitch-notifier/internal/messagesender/temporal" + "go.uber.org/fx" +) + +var Module = fx.Options( + fx.Provide( + temporal.NewActivity, + temporal.NewWorkflow, + fx.Annotate(temporal.NewImpl, fx.As(new(messagesender.MessageSender))), + ), + fx.Invoke(temporal.NewWorker), +) diff --git a/internal/messagesender/message_sender.go b/internal/messagesender/message_sender.go new file mode 100644 index 00000000..bcd40421 --- /dev/null +++ b/internal/messagesender/message_sender.go @@ -0,0 +1,19 @@ +package messagesender + +import ( + "context" +) + +//go:generate go run go.uber.org/mock/mockgen -source=message_sender.go -destination=mocks/mock.go + +type MessageSender interface { + SendMessageTelegram(ctx context.Context, opts TelegramOpts) error + // For add new service we need to implement new method, for example: + // SendMessageDiscord(ctx context.Context, opts TelegramOpts) error +} + +type TelegramOpts struct { + ServiceChatID string + Text string + ImageURL string +} diff --git a/internal/messagesender/mocks/mock.go b/internal/messagesender/mocks/mock.go new file mode 100644 index 00000000..eafebf01 --- /dev/null +++ b/internal/messagesender/mocks/mock.go @@ -0,0 +1,55 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: message_sender.go +// +// Generated by this command: +// +// mockgen -source=message_sender.go -destination=mocks/mock.go +// + +// Package mock_messagesender is a generated GoMock package. +package mock_messagesender + +import ( + context "context" + reflect "reflect" + + messagesender "github.com/satont/twitch-notifier/internal/messagesender" + gomock "go.uber.org/mock/gomock" +) + +// MockMessageSender is a mock of MessageSender interface. +type MockMessageSender struct { + ctrl *gomock.Controller + recorder *MockMessageSenderMockRecorder +} + +// MockMessageSenderMockRecorder is the mock recorder for MockMessageSender. +type MockMessageSenderMockRecorder struct { + mock *MockMessageSender +} + +// NewMockMessageSender creates a new mock instance. +func NewMockMessageSender(ctrl *gomock.Controller) *MockMessageSender { + mock := &MockMessageSender{ctrl: ctrl} + mock.recorder = &MockMessageSenderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockMessageSender) EXPECT() *MockMessageSenderMockRecorder { + return m.recorder +} + +// SendMessageTelegram mocks base method. +func (m *MockMessageSender) SendMessageTelegram(ctx context.Context, opts messagesender.TelegramOpts) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SendMessageTelegram", ctx, opts) + ret0, _ := ret[0].(error) + return ret0 +} + +// SendMessageTelegram indicates an expected call of SendMessageTelegram. +func (mr *MockMessageSenderMockRecorder) SendMessageTelegram(ctx, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendMessageTelegram", reflect.TypeOf((*MockMessageSender)(nil).SendMessageTelegram), ctx, opts) +} diff --git a/internal/messagesender/temporal/activity.go b/internal/messagesender/temporal/activity.go new file mode 100644 index 00000000..893f9fd0 --- /dev/null +++ b/internal/messagesender/temporal/activity.go @@ -0,0 +1,23 @@ +package temporal + +import ( + "context" + + "github.com/satont/twitch-notifier/internal/messagesender" + "go.uber.org/fx" +) + +type ActivityOpts struct { + fx.In +} + +func NewActivity() *Activity { + return &Activity{} +} + +type Activity struct { +} + +func (c *Activity) SendTelegram(ctx context.Context, opts messagesender.TelegramOpts) error { + return nil +} diff --git a/internal/messagesender/temporal/impl.go b/internal/messagesender/temporal/impl.go new file mode 100644 index 00000000..37fd4b0b --- /dev/null +++ b/internal/messagesender/temporal/impl.go @@ -0,0 +1,65 @@ +package temporal + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/satont/twitch-notifier/internal/messagesender" + "github.com/satont/twitch-notifier/pkg/logger" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/temporal" + "go.uber.org/fx" +) + +type TemporalOpts struct { + fx.In + + Workflow *Workflow + Logger logger.Logger +} + +func NewImpl(opts TemporalOpts) (*Temporal, error) { + cl, err := client.Dial(client.Options{}) + if err != nil { + return nil, err + } + + return &Temporal{ + client: cl, + workflow: opts.Workflow, + logger: opts.Logger, + }, nil +} + +const queueName = "message-sender" + +type Temporal struct { + client client.Client + workflow *Workflow + logger logger.Logger +} + +var _ messagesender.MessageSender = (*Temporal)(nil) + +func (c *Temporal) SendMessageTelegram(ctx context.Context, opts messagesender.TelegramOpts) error { + workflowOptions := client.StartWorkflowOptions{ + ID: fmt.Sprintf("MSG: Telegram to %s #%s", uuid.NewString(), opts.ServiceChatID), + TaskQueue: queueName, + RetryPolicy: &temporal.RetryPolicy{ + MaximumAttempts: 1, + }, + } + + we, err := c.client.ExecuteWorkflow(ctx, workflowOptions, c.workflow.SendTelegram, opts) + if err != nil { + return err + } + + err = we.Get(ctx, nil) + if err != nil { + return err + } + + return nil +} diff --git a/internal/messagesender/temporal/worker.go b/internal/messagesender/temporal/worker.go new file mode 100644 index 00000000..1da95978 --- /dev/null +++ b/internal/messagesender/temporal/worker.go @@ -0,0 +1,54 @@ +package temporal + +import ( + "context" + + "github.com/satont/twitch-notifier/pkg/config" + "github.com/satont/twitch-notifier/pkg/logger" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/log" + "go.temporal.io/sdk/worker" + "go.uber.org/fx" +) + +type WorkerOpts struct { + fx.In + LC fx.Lifecycle + + Workflow *Workflow + Activity *Activity + Logger logger.Logger + Config config.Config +} + +func NewWorker(opts WorkerOpts) error { + temporalClient, err := client.Dial( + client.Options{ + Logger: log.NewStructuredLogger(opts.Logger.GetSlog()), + HostPort: opts.Config.TemporalUrl, + }, + ) + if err != nil { + return err + } + + w := worker.New(temporalClient, queueName, worker.Options{}) + + w.RegisterWorkflow(opts.Workflow.SendTelegram) + w.RegisterActivity(opts.Activity.SendTelegram) + + opts.LC.Append( + fx.Hook{ + OnStart: func(ctx context.Context) error { + return w.Start() + }, + OnStop: func(ctx context.Context) error { + w.Stop() + temporalClient.Close() + return nil + }, + }, + ) + + return nil +} diff --git a/internal/messagesender/temporal/workflow.go b/internal/messagesender/temporal/workflow.go new file mode 100644 index 00000000..778a519e --- /dev/null +++ b/internal/messagesender/temporal/workflow.go @@ -0,0 +1,64 @@ +package temporal + +import ( + "time" + + "github.com/satont/twitch-notifier/internal/messagesender" + "github.com/satont/twitch-notifier/pkg/logger" + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" + "go.uber.org/fx" +) + +type WorkflowOpts struct { + fx.In + + Logger logger.Logger + Activity *Activity +} + +func NewWorkflow(opts WorkflowOpts) *Workflow { + return &Workflow{ + logger: opts.Logger, + activity: opts.Activity, + } +} + +type Workflow struct { + logger logger.Logger + activity *Activity +} + +const telegramActivityMaximumAttempts = 5 + +func (c *Workflow) SendTelegram(ctx workflow.Context, opts messagesender.TelegramOpts) error { + ao := workflow.ActivityOptions{ + TaskQueue: queueName, + StartToCloseTimeout: 10 * time.Second, + RetryPolicy: &temporal.RetryPolicy{ + MaximumAttempts: telegramActivityMaximumAttempts, + InitialInterval: 2 * time.Second, + }, + } + ctx = workflow.WithActivityOptions(ctx, ao) + + log := workflow.GetLogger(ctx) + log.Info("Sending message", "chatId", opts.ServiceChatID) + + err := workflow.ExecuteActivity( + ctx, + c.activity.SendTelegram, + opts, + ).Get( + ctx, + nil, + ) + if err != nil { + log.Error("Send failed", "Error", err) + return err + } + + log.Info("Message sent") + + return nil +} diff --git a/internal/pgx/pgx.go b/internal/pgx/pgx.go new file mode 100644 index 00000000..a04f095e --- /dev/null +++ b/internal/pgx/pgx.go @@ -0,0 +1,48 @@ +package pgx + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/satont/twitch-notifier/pkg/config" + "github.com/satont/twitch-notifier/pkg/logger" + "go.uber.org/fx" +) + +type Opts struct { + fx.In + LC fx.Lifecycle + + Logger logger.Logger + Config config.Config +} + +func New(opts Opts) (*pgxpool.Pool, error) { + pgx, err := pgxpool.New( + context.Background(), + opts.Config.PostgresUrl, + ) + if err != nil { + return nil, err + } + + opts.LC.Append( + fx.Hook{ + OnStop: func(ctx context.Context) error { + pgx.Close() + return nil + }, + OnStart: func(ctx context.Context) error { + err := pgx.Ping(ctx) + if err != nil { + return err + } + + opts.Logger.Info("Connected to postgres") + return nil + }, + }, + ) + + return pgx, nil +} diff --git a/internal/repository/channel/channel.go b/internal/repository/channel/channel.go new file mode 100644 index 00000000..67b75f8b --- /dev/null +++ b/internal/repository/channel/channel.go @@ -0,0 +1,43 @@ +package channel + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/satont/twitch-notifier/internal/domain" +) + +type Channel struct { + ID uuid.UUID + ChannelID string + Service StreamingService +} + +type StreamingService string + +func (s StreamingService) String() string { + return string(s) +} + +const ( + StreamingServiceTwitch StreamingService = "twitch" +) + +var ErrNotFound = errors.New("channel not found") +var ErrCannotCreate = errors.New("cannot create channel") +var ErrCannotDelete = errors.New("cannot delete channel") + +//go:generate go run go.uber.org/mock/mockgen -source=channel.go -destination=mocks/mock.go + +type Repository interface { + GetById(ctx context.Context, id uuid.UUID) (*domain.Channel, error) + GetByStreamServiceAndID(ctx context.Context, service StreamingService, id string) ( + *domain.Channel, + error, + ) + GetAll(ctx context.Context) ([]domain.Channel, error) + Create(ctx context.Context, channel domain.Channel) error + // Update(ctx context.Context, channel Channel) error + Delete(ctx context.Context, id uuid.UUID) error +} diff --git a/internal/repository/channel/fx.go b/internal/repository/channel/fx.go new file mode 100644 index 00000000..db87dea7 --- /dev/null +++ b/internal/repository/channel/fx.go @@ -0,0 +1,12 @@ +package channel + +import ( + "go.uber.org/fx" +) + +var Module = fx.Provide( + fx.Annotate( + NewPgx, + fx.As(new(Repository)), + ), +) diff --git a/internal/repository/channel/mocks/mock.go b/internal/repository/channel/mocks/mock.go new file mode 100644 index 00000000..0028c94f --- /dev/null +++ b/internal/repository/channel/mocks/mock.go @@ -0,0 +1,116 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: channel.go +// +// Generated by this command: +// +// mockgen -source=channel.go -destination=mocks/mock.go +// + +// Package mock_channel is a generated GoMock package. +package mock_channel + +import ( + context "context" + reflect "reflect" + + uuid "github.com/google/uuid" + domain "github.com/satont/twitch-notifier/internal/domain" + channel "github.com/satont/twitch-notifier/internal/repository/channel" + gomock "go.uber.org/mock/gomock" +) + +// MockRepository is a mock of Repository interface. +type MockRepository struct { + ctrl *gomock.Controller + recorder *MockRepositoryMockRecorder +} + +// MockRepositoryMockRecorder is the mock recorder for MockRepository. +type MockRepositoryMockRecorder struct { + mock *MockRepository +} + +// NewMockRepository creates a new mock instance. +func NewMockRepository(ctrl *gomock.Controller) *MockRepository { + mock := &MockRepository{ctrl: ctrl} + mock.recorder = &MockRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockRepository) Create(ctx context.Context, channel domain.Channel) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", ctx, channel) + ret0, _ := ret[0].(error) + return ret0 +} + +// Create indicates an expected call of Create. +func (mr *MockRepositoryMockRecorder) Create(ctx, channel any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockRepository)(nil).Create), ctx, channel) +} + +// Delete mocks base method. +func (m *MockRepository) Delete(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockRepositoryMockRecorder) Delete(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockRepository)(nil).Delete), ctx, id) +} + +// GetAll mocks base method. +func (m *MockRepository) GetAll(ctx context.Context) ([]domain.Channel, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAll", ctx) + ret0, _ := ret[0].([]domain.Channel) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAll indicates an expected call of GetAll. +func (mr *MockRepositoryMockRecorder) GetAll(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAll", reflect.TypeOf((*MockRepository)(nil).GetAll), ctx) +} + +// GetById mocks base method. +func (m *MockRepository) GetById(ctx context.Context, id uuid.UUID) (*domain.Channel, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetById", ctx, id) + ret0, _ := ret[0].(*domain.Channel) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetById indicates an expected call of GetById. +func (mr *MockRepositoryMockRecorder) GetById(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetById", reflect.TypeOf((*MockRepository)(nil).GetById), ctx, id) +} + +// GetByStreamServiceAndID mocks base method. +func (m *MockRepository) GetByStreamServiceAndID(ctx context.Context, service channel.StreamingService, id string) (*domain.Channel, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetByStreamServiceAndID", ctx, service, id) + ret0, _ := ret[0].(*domain.Channel) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetByStreamServiceAndID indicates an expected call of GetByStreamServiceAndID. +func (mr *MockRepositoryMockRecorder) GetByStreamServiceAndID(ctx, service, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByStreamServiceAndID", reflect.TypeOf((*MockRepository)(nil).GetByStreamServiceAndID), ctx, service, id) +} diff --git a/internal/repository/channel/pgx.go b/internal/repository/channel/pgx.go new file mode 100644 index 00000000..cbcf99ec --- /dev/null +++ b/internal/repository/channel/pgx.go @@ -0,0 +1,197 @@ +package channel + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/satont/twitch-notifier/internal/domain" + "github.com/satont/twitch-notifier/internal/repository" +) + +func NewPgx(pg *pgxpool.Pool) *Pgx { + return &Pgx{ + pg: pg, + } +} + +var _ Repository = (*Pgx)(nil) + +type Pgx struct { + pg *pgxpool.Pool +} + +const tableName = "channels" + +func (c *Pgx) GetById(ctx context.Context, id uuid.UUID) (*domain.Channel, error) { + channel := Channel{} + + query, args, err := repository.Sq. + Select("id", "channel_id", "service"). + From(tableName). + Where( + "id = ?", + id, + ).ToSql() + if err != nil { + return nil, repository.ErrBadQuery + } + + err = c.pg.QueryRow(ctx, query, args...).Scan(&channel.ID, &channel.ChannelID, &channel.Service) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + + return nil, err + } + + return &domain.Channel{ + ID: channel.ID, + ChannelID: channel.ChannelID, + Service: domain.StreamingService(channel.Service), + }, nil +} + +func (c *Pgx) GetByStreamServiceAndID( + ctx context.Context, + service StreamingService, + id string, +) (*domain.Channel, error) { + query, args, err := repository.Sq. + Select("id", "channel_id", "service"). + From(tableName). + Where( + "channel_id = ? AND service = ?", + id, + service, + ).ToSql() + if err != nil { + return nil, err + } + + channel := Channel{} + err = c.pg.QueryRow(ctx, query, args...).Scan(&channel.ID, &channel.ChannelID, &channel.Service) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + + return &domain.Channel{ + ID: channel.ID, + ChannelID: channel.ChannelID, + Service: domain.StreamingService(channel.Service), + }, nil +} + +func (c *Pgx) GetAll(ctx context.Context) ([]domain.Channel, error) { + query, args, err := repository.Sq. + Select("id", "channel_id", "service"). + From(tableName). + ToSql() + if err != nil { + return nil, repository.ErrBadQuery + } + + rows, err := c.pg.Query(ctx, query, args...) + if err != nil { + return nil, err + } + + var channels []Channel + defer rows.Close() + for rows.Next() { + channel := Channel{} + err = rows.Scan(&channel.ID, &channel.ChannelID, &channel.Service) + if err != nil { + return nil, err + } + channels = append(channels, channel) + } + + resultChannels := make([]domain.Channel, len(channels)) + for i, channel := range channels { + resultChannels[i] = domain.Channel{ + ID: channel.ID, + ChannelID: channel.ChannelID, + Service: domain.StreamingService(channel.Service), + } + } + + return resultChannels, nil +} + +func (c *Pgx) Create(ctx context.Context, channel domain.Channel) error { + query, args, err := repository.Sq.Insert(tableName).Columns( + "id", + "channel_id", + "service", + ).Values( + channel.ID, + channel.ChannelID, + channel.Service, + ).ToSql() + if err != nil { + return repository.ErrBadQuery + } + + _, err = c.pg.Exec(ctx, query, args...) + if err != nil { + // TODO: viyasnit kak luchshe + return errors.Join(err, ErrCannotCreate) + } + + return nil +} + +// func (c *Pgx) Update(ctx context.Context, channel Channel) error { +// query, args, err := repository.Sq.Update("channel").Set( +// "channel_id", +// channel.ChannelID, +// ).Set( +// "service", +// channel.Service, +// ).Where( +// "id = ?", +// channel.ID, +// ).ToSql() +// +// if err != nil { +// return err +// } +// +// _, err = c.pg.Exec(ctx, query, args...) +// if err != nil { +// return err +// } +// +// return nil +// } + +func (c *Pgx) Delete(ctx context.Context, id uuid.UUID) error { + query, args, err := repository.Sq. + Delete(tableName). + Where( + "id = ?", + id, + ).ToSql() + if err != nil { + return repository.ErrBadQuery + } + + res, err := c.pg.Exec(ctx, query, args...) + if err != nil { + // TODO: viyasnit kak luchshe + return errors.Join(err, ErrCannotDelete) + } + + if res.RowsAffected() == 0 { + return ErrNotFound + } + + return nil +} diff --git a/internal/repository/chat/chat.go b/internal/repository/chat/chat.go new file mode 100644 index 00000000..caeadbf7 --- /dev/null +++ b/internal/repository/chat/chat.go @@ -0,0 +1,43 @@ +package chat + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/satont/twitch-notifier/internal/domain" +) + +type Chat struct { + ID uuid.UUID + Service ChatService + ChatID string +} + +type ChatService string + +func (c ChatService) String() string { + return string(c) +} + +const ( + ChatServiceTelegram ChatService = "telegram" +) + +var ErrNotFound = errors.New("chat not found") +var ErrCannotCreate = errors.New("cannot create chat") +var ErrCannotDelete = errors.New("cannot delete chat") + +//go:generate go run go.uber.org/mock/mockgen -source=chat.go -destination=mocks/mock.go + +type Repository interface { + GetByID(ctx context.Context, id uuid.UUID) (*domain.Chat, error) + GetByChatServiceAndChatID(ctx context.Context, service ChatService, chatID string) ( + *domain.Chat, + error, + ) + GetAll(ctx context.Context) ([]domain.Chat, error) + Create(ctx context.Context, user domain.Chat) error + // Update(ctx context.Context, user Chat) error + Delete(ctx context.Context, id uuid.UUID) error +} diff --git a/internal/repository/chat/fx.go b/internal/repository/chat/fx.go new file mode 100644 index 00000000..dc35c6cb --- /dev/null +++ b/internal/repository/chat/fx.go @@ -0,0 +1,12 @@ +package chat + +import ( + "go.uber.org/fx" +) + +var Module = fx.Provide( + fx.Annotate( + NewPgx, + fx.As(new(Repository)), + ), +) diff --git a/internal/repository/chat/mocks/mock.go b/internal/repository/chat/mocks/mock.go new file mode 100644 index 00000000..4eba3057 --- /dev/null +++ b/internal/repository/chat/mocks/mock.go @@ -0,0 +1,116 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: chat.go +// +// Generated by this command: +// +// mockgen -source=chat.go -destination=mocks/mock.go +// + +// Package mock_chat is a generated GoMock package. +package mock_chat + +import ( + context "context" + reflect "reflect" + + uuid "github.com/google/uuid" + domain "github.com/satont/twitch-notifier/internal/domain" + chat "github.com/satont/twitch-notifier/internal/repository/chat" + gomock "go.uber.org/mock/gomock" +) + +// MockRepository is a mock of Repository interface. +type MockRepository struct { + ctrl *gomock.Controller + recorder *MockRepositoryMockRecorder +} + +// MockRepositoryMockRecorder is the mock recorder for MockRepository. +type MockRepositoryMockRecorder struct { + mock *MockRepository +} + +// NewMockRepository creates a new mock instance. +func NewMockRepository(ctrl *gomock.Controller) *MockRepository { + mock := &MockRepository{ctrl: ctrl} + mock.recorder = &MockRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockRepository) Create(ctx context.Context, user domain.Chat) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", ctx, user) + ret0, _ := ret[0].(error) + return ret0 +} + +// Create indicates an expected call of Create. +func (mr *MockRepositoryMockRecorder) Create(ctx, user any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockRepository)(nil).Create), ctx, user) +} + +// Delete mocks base method. +func (m *MockRepository) Delete(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockRepositoryMockRecorder) Delete(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockRepository)(nil).Delete), ctx, id) +} + +// GetAll mocks base method. +func (m *MockRepository) GetAll(ctx context.Context) ([]domain.Chat, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAll", ctx) + ret0, _ := ret[0].([]domain.Chat) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAll indicates an expected call of GetAll. +func (mr *MockRepositoryMockRecorder) GetAll(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAll", reflect.TypeOf((*MockRepository)(nil).GetAll), ctx) +} + +// GetByChatServiceAndChatID mocks base method. +func (m *MockRepository) GetByChatServiceAndChatID(ctx context.Context, service chat.ChatService, chatID string) (*domain.Chat, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetByChatServiceAndChatID", ctx, service, chatID) + ret0, _ := ret[0].(*domain.Chat) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetByChatServiceAndChatID indicates an expected call of GetByChatServiceAndChatID. +func (mr *MockRepositoryMockRecorder) GetByChatServiceAndChatID(ctx, service, chatID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByChatServiceAndChatID", reflect.TypeOf((*MockRepository)(nil).GetByChatServiceAndChatID), ctx, service, chatID) +} + +// GetByID mocks base method. +func (m *MockRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.Chat, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetByID", ctx, id) + ret0, _ := ret[0].(*domain.Chat) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetByID indicates an expected call of GetByID. +func (mr *MockRepositoryMockRecorder) GetByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByID", reflect.TypeOf((*MockRepository)(nil).GetByID), ctx, id) +} diff --git a/internal/repository/chat/pgx.go b/internal/repository/chat/pgx.go new file mode 100644 index 00000000..cb5669ee --- /dev/null +++ b/internal/repository/chat/pgx.go @@ -0,0 +1,167 @@ +package chat + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/satont/twitch-notifier/internal/domain" + "github.com/satont/twitch-notifier/internal/repository" +) + +func NewPgx(pg *pgxpool.Pool) *Pgx { + return &Pgx{ + pg: pg, + } +} + +var _ Repository = (*Pgx)(nil) + +type Pgx struct { + pg *pgxpool.Pool +} + +func (c *Pgx) GetByID(ctx context.Context, id uuid.UUID) (*domain.Chat, error) { + query, args, err := repository.Sq. + Select("id", "chat_id", "service"). + From("chats"). + Where( + "id = ?", + id, + ).ToSql() + if err != nil { + return nil, repository.ErrBadQuery + } + + chat := Chat{} + err = c.pg.QueryRow(ctx, query, args...).Scan(&chat.ID, &chat.ChatID, &chat.Service) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + + return &domain.Chat{ + ID: chat.ID, + Service: domain.ChatService(chat.Service), + ChatID: chat.ChatID, + }, nil +} + +func (c *Pgx) GetByChatServiceAndChatID( + ctx context.Context, + service ChatService, + chatID string, +) (*domain.Chat, error) { + query, args, err := repository.Sq. + Select("id", "chat_id", "service"). + From("chats"). + Where( + "chat_id = ? AND service = ?", + chatID, + service, + ).ToSql() + if err != nil { + return nil, repository.ErrBadQuery + } + + chat := Chat{} + err = c.pg.QueryRow(ctx, query, args...).Scan(&chat.ID, &chat.ChatID, &chat.Service) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + + return &domain.Chat{ + ID: chat.ID, + Service: domain.ChatService(chat.Service), + ChatID: chat.ChatID, + }, nil +} + +func (c *Pgx) GetAll(ctx context.Context) ([]domain.Chat, error) { + query, args, err := repository.Sq. + Select("id", "chat_id", "service"). + From("chats"). + ToSql() + if err != nil { + return nil, repository.ErrBadQuery + } + + rows, err := c.pg.Query(ctx, query, args...) + if err != nil { + return nil, err + } + + defer rows.Close() + var chats []Chat + for rows.Next() { + var chat Chat + err = rows.Scan(&chat.ID, &chat.ChatID, &chat.Service) + if err != nil { + return nil, err + } + chats = append(chats, chat) + } + + domainChats := make([]domain.Chat, len(chats)) + for i, chat := range chats { + domainChats[i] = domain.Chat{ + ID: chat.ID, + Service: domain.ChatService(chat.Service), + ChatID: chat.ChatID, + } + } + + return domainChats, nil +} + +func (c *Pgx) Create(ctx context.Context, user domain.Chat) error { + query, args, err := repository.Sq. + Insert("chats"). + Columns( + "id", + "chat_id", + "service", + ).Values( + user.ID, + user.ChatID, + user.Service, + ).ToSql() + if err != nil { + return repository.ErrBadQuery + } + + _, err = c.pg.Exec(ctx, query, args...) + if err != nil { + return errors.Join(err, ErrCannotCreate) + } + + return nil +} + +func (c *Pgx) Delete(ctx context.Context, id uuid.UUID) error { + query, args, err := repository.Sq.Delete("chats").Where( + "id = ?", + id, + ).ToSql() + if err != nil { + return repository.ErrBadQuery + } + + rows, err := c.pg.Exec(ctx, query, args...) + if err != nil { + return err + } + + if rows.RowsAffected() == 0 { + return errors.Join(err, ErrCannotDelete) + } + + return nil +} diff --git a/internal/repository/chatsettings/chatsettings.go b/internal/repository/chatsettings/chatsettings.go new file mode 100644 index 00000000..a57f7479 --- /dev/null +++ b/internal/repository/chatsettings/chatsettings.go @@ -0,0 +1,46 @@ +package chatsettings + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/satont/twitch-notifier/internal/domain" +) + +type ChatSettings struct { + ID uuid.UUID + ChatID uuid.UUID + Language Language + CategoryChangeNotifications bool + TitleChangeNotifications bool + OfflineNotifications bool + CategoryAndTitleNotifications bool + ShowThumbnail bool +} + +type Language string + +func (l Language) String() string { + return string(l) +} + +const ( + LanguageEN Language = "en" + LanguageRU Language = "ru" + LanguageUA Language = "ua" +) + +var ErrNotFound = errors.New("chatsettings not found") +var ErrCannotCreate = errors.New("cannot create chatsettings") +var ErrCannotDelete = errors.New("cannot delete chatsettings") + +//go:generate go run go.uber.org/mock/mockgen -source=chatsettings.go -destination=mocks/mock.go + +type Repository interface { + GetByID(ctx context.Context, id uuid.UUID) (*domain.ChatSettings, error) + GetByChatID(ctx context.Context, chatID uuid.UUID) (*domain.ChatSettings, error) + Create(ctx context.Context, chatSettings domain.ChatSettings) error + Update(ctx context.Context, chatSettings domain.ChatSettings) error + Delete(ctx context.Context, id uuid.UUID) error +} diff --git a/internal/repository/chatsettings/fx.go b/internal/repository/chatsettings/fx.go new file mode 100644 index 00000000..f395fda4 --- /dev/null +++ b/internal/repository/chatsettings/fx.go @@ -0,0 +1,12 @@ +package chatsettings + +import ( + "go.uber.org/fx" +) + +var Module = fx.Provide( + fx.Annotate( + NewPgx, + fx.As(new(Repository)), + ), +) diff --git a/internal/repository/chatsettings/mocks/mock.go b/internal/repository/chatsettings/mocks/mock.go new file mode 100644 index 00000000..5ec9b997 --- /dev/null +++ b/internal/repository/chatsettings/mocks/mock.go @@ -0,0 +1,114 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: chatsettings.go +// +// Generated by this command: +// +// mockgen -source=chatsettings.go -destination=mocks/mock.go +// + +// Package mock_chatsettings is a generated GoMock package. +package mock_chatsettings + +import ( + context "context" + reflect "reflect" + + uuid "github.com/google/uuid" + domain "github.com/satont/twitch-notifier/internal/domain" + gomock "go.uber.org/mock/gomock" +) + +// MockRepository is a mock of Repository interface. +type MockRepository struct { + ctrl *gomock.Controller + recorder *MockRepositoryMockRecorder +} + +// MockRepositoryMockRecorder is the mock recorder for MockRepository. +type MockRepositoryMockRecorder struct { + mock *MockRepository +} + +// NewMockRepository creates a new mock instance. +func NewMockRepository(ctrl *gomock.Controller) *MockRepository { + mock := &MockRepository{ctrl: ctrl} + mock.recorder = &MockRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockRepository) Create(ctx context.Context, chatSettings domain.ChatSettings) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", ctx, chatSettings) + ret0, _ := ret[0].(error) + return ret0 +} + +// Create indicates an expected call of Create. +func (mr *MockRepositoryMockRecorder) Create(ctx, chatSettings any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockRepository)(nil).Create), ctx, chatSettings) +} + +// Delete mocks base method. +func (m *MockRepository) Delete(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockRepositoryMockRecorder) Delete(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockRepository)(nil).Delete), ctx, id) +} + +// GetByChatID mocks base method. +func (m *MockRepository) GetByChatID(ctx context.Context, chatID uuid.UUID) (*domain.ChatSettings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetByChatID", ctx, chatID) + ret0, _ := ret[0].(*domain.ChatSettings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetByChatID indicates an expected call of GetByChatID. +func (mr *MockRepositoryMockRecorder) GetByChatID(ctx, chatID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByChatID", reflect.TypeOf((*MockRepository)(nil).GetByChatID), ctx, chatID) +} + +// GetByID mocks base method. +func (m *MockRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.ChatSettings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetByID", ctx, id) + ret0, _ := ret[0].(*domain.ChatSettings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetByID indicates an expected call of GetByID. +func (mr *MockRepositoryMockRecorder) GetByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByID", reflect.TypeOf((*MockRepository)(nil).GetByID), ctx, id) +} + +// Update mocks base method. +func (m *MockRepository) Update(ctx context.Context, chatSettings domain.ChatSettings) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Update", ctx, chatSettings) + ret0, _ := ret[0].(error) + return ret0 +} + +// Update indicates an expected call of Update. +func (mr *MockRepositoryMockRecorder) Update(ctx, chatSettings any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockRepository)(nil).Update), ctx, chatSettings) +} diff --git a/internal/repository/chatsettings/pgx.go b/internal/repository/chatsettings/pgx.go new file mode 100644 index 00000000..639de40e --- /dev/null +++ b/internal/repository/chatsettings/pgx.go @@ -0,0 +1,220 @@ +package chatsettings + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/satont/twitch-notifier/internal/domain" + "github.com/satont/twitch-notifier/internal/repository" +) + +func NewPgx(pg *pgxpool.Pool) *Pgx { + return &Pgx{ + pg: pg, + } +} + +var _ Repository = (*Pgx)(nil) + +type Pgx struct { + pg *pgxpool.Pool +} + +const tableName = "chat_settings" + +func (c *Pgx) GetByID(ctx context.Context, id uuid.UUID) (*domain.ChatSettings, error) { + query, args, err := repository.Sq. + Select( + "id", + "chat_id", + "language", + "game_change_notifications", + "title_change_notifications", + "offline_notifications", + "game_and_title_notifications", + "show_thumbnail", + ). + From(tableName). + Where( + "id = ?", + id, + ).ToSql() + if err != nil { + return nil, repository.ErrBadQuery + } + + settings := ChatSettings{} + err = c.pg.QueryRow(ctx, query, args...).Scan( + &settings.ID, + &settings.ChatID, + &settings.Language, + &settings.CategoryChangeNotifications, + &settings.TitleChangeNotifications, + &settings.OfflineNotifications, + &settings.CategoryAndTitleNotifications, + &settings.ShowThumbnail, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + + return &domain.ChatSettings{ + ID: settings.ID, + ChatID: settings.ChatID, + Language: domain.Language(settings.Language), + CategoryChangeNotifications: settings.CategoryChangeNotifications, + TitleChangeNotifications: settings.TitleChangeNotifications, + OfflineNotifications: settings.OfflineNotifications, + CategoryAndTitleNotifications: settings.CategoryAndTitleNotifications, + ShowThumbnail: settings.ShowThumbnail, + }, err +} + +func (c *Pgx) GetByChatID(ctx context.Context, chatID uuid.UUID) (*domain.ChatSettings, error) { + query, args, err := repository.Sq. + Select( + "id", + "chat_id", + "language", + "game_change_notifications", + "title_change_notifications", + "offline_notifications", + "game_and_title_notifications", + "show_thumbnail", + ). + From(tableName). + Where( + "chat_id = ?", + chatID, + ).ToSql() + if err != nil { + return nil, repository.ErrBadQuery + } + + settings := ChatSettings{} + err = c.pg.QueryRow(ctx, query, args...).Scan( + &settings.ID, + &settings.ChatID, + &settings.Language, + &settings.CategoryChangeNotifications, + &settings.TitleChangeNotifications, + &settings.OfflineNotifications, + &settings.CategoryAndTitleNotifications, + &settings.ShowThumbnail, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + + return &domain.ChatSettings{ + ID: settings.ID, + ChatID: settings.ChatID, + Language: domain.Language(settings.Language), + CategoryChangeNotifications: settings.CategoryChangeNotifications, + TitleChangeNotifications: settings.TitleChangeNotifications, + OfflineNotifications: settings.OfflineNotifications, + CategoryAndTitleNotifications: settings.CategoryAndTitleNotifications, + ShowThumbnail: settings.ShowThumbnail, + }, err +} + +func (c *Pgx) Create(ctx context.Context, chatSettings domain.ChatSettings) error { + query, args, err := repository.Sq.Insert(tableName).Columns( + "id", + "chat_id", + "language", + "game_change_notifications", + "title_change_notifications", + "offline_notifications", + "game_and_title_notifications", + "show_thumbnail", + ).Values( + chatSettings.ID, + chatSettings.ChatID, + chatSettings.Language, + chatSettings.CategoryChangeNotifications, + chatSettings.TitleChangeNotifications, + chatSettings.OfflineNotifications, + chatSettings.CategoryAndTitleNotifications, + chatSettings.ShowThumbnail, + ).ToSql() + if err != nil { + return repository.ErrBadQuery + } + + _, err = c.pg.Exec(ctx, query, args...) + if err != nil { + return errors.Join(err, ErrCannotCreate) + } + + return nil +} + +func (c *Pgx) Update(ctx context.Context, chatSettings domain.ChatSettings) error { + query, args, err := repository.Sq. + Update(tableName). + Set( + "language", + chatSettings.Language, + ). + Set( + "game_change_notifications", + chatSettings.CategoryChangeNotifications, + ). + Set( + "title_change_notifications", + chatSettings.TitleChangeNotifications, + ). + Set( + "offline_notifications", + chatSettings.OfflineNotifications, + ). + Set( + "game_and_title_notifications", + chatSettings.CategoryAndTitleNotifications, + ). + Set( + "show_thumbnail", + chatSettings.ShowThumbnail, + ). + Where( + "id = ?", + chatSettings.ID, + ).ToSql() + if err != nil { + return repository.ErrBadQuery + } + + _, err = c.pg.Exec(ctx, query, args...) + return err +} + +func (c *Pgx) Delete(ctx context.Context, id uuid.UUID) error { + query, args, err := repository.Sq.Delete(tableName).Where( + "id = ?", + id, + ).ToSql() + if err != nil { + return repository.ErrBadQuery + } + + rows, err := c.pg.Exec(ctx, query, args...) + if err != nil { + return ErrCannotDelete + } + + if rows.RowsAffected() == 0 { + return ErrNotFound + } + + return nil +} diff --git a/internal/repository/follow/follow.go b/internal/repository/follow/follow.go new file mode 100644 index 00000000..8098a28b --- /dev/null +++ b/internal/repository/follow/follow.go @@ -0,0 +1,31 @@ +package follow + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/satont/twitch-notifier/internal/domain" +) + +type Follow struct { + ID uuid.UUID + ChatID uuid.UUID + ChannelID uuid.UUID + CreatedAt time.Time +} + +var ErrNotFound = errors.New("follow not found") +var ErrCannotCreate = errors.New("cannot create follow") +var ErrCannotDelete = errors.New("cannot delete follow") + +//go:generate go run go.uber.org/mock/mockgen -source=follow.go -destination=mocks/mock.go + +type Repository interface { + GetByID(ctx context.Context, id uuid.UUID) (*domain.Follow, error) + GetByChatID(ctx context.Context, chatID uuid.UUID) ([]domain.Follow, error) + GetByChannelID(ctx context.Context, channelID uuid.UUID) ([]domain.Follow, error) + Create(ctx context.Context, follow domain.Follow) error + Delete(ctx context.Context, id uuid.UUID) error +} diff --git a/internal/repository/follow/fx.go b/internal/repository/follow/fx.go new file mode 100644 index 00000000..e5369cc3 --- /dev/null +++ b/internal/repository/follow/fx.go @@ -0,0 +1,12 @@ +package follow + +import ( + "go.uber.org/fx" +) + +var Module = fx.Provide( + fx.Annotate( + NewPgx, + fx.As(new(Repository)), + ), +) diff --git a/internal/repository/follow/mocks/mock.go b/internal/repository/follow/mocks/mock.go new file mode 100644 index 00000000..41776e0a --- /dev/null +++ b/internal/repository/follow/mocks/mock.go @@ -0,0 +1,115 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: follow.go +// +// Generated by this command: +// +// mockgen -source=follow.go -destination=mocks/mock.go +// + +// Package mock_follow is a generated GoMock package. +package mock_follow + +import ( + context "context" + reflect "reflect" + + uuid "github.com/google/uuid" + domain "github.com/satont/twitch-notifier/internal/domain" + gomock "go.uber.org/mock/gomock" +) + +// MockRepository is a mock of Repository interface. +type MockRepository struct { + ctrl *gomock.Controller + recorder *MockRepositoryMockRecorder +} + +// MockRepositoryMockRecorder is the mock recorder for MockRepository. +type MockRepositoryMockRecorder struct { + mock *MockRepository +} + +// NewMockRepository creates a new mock instance. +func NewMockRepository(ctrl *gomock.Controller) *MockRepository { + mock := &MockRepository{ctrl: ctrl} + mock.recorder = &MockRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockRepository) Create(ctx context.Context, follow domain.Follow) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", ctx, follow) + ret0, _ := ret[0].(error) + return ret0 +} + +// Create indicates an expected call of Create. +func (mr *MockRepositoryMockRecorder) Create(ctx, follow any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockRepository)(nil).Create), ctx, follow) +} + +// Delete mocks base method. +func (m *MockRepository) Delete(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockRepositoryMockRecorder) Delete(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockRepository)(nil).Delete), ctx, id) +} + +// GetByChannelID mocks base method. +func (m *MockRepository) GetByChannelID(ctx context.Context, channelID uuid.UUID) ([]domain.Follow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetByChannelID", ctx, channelID) + ret0, _ := ret[0].([]domain.Follow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetByChannelID indicates an expected call of GetByChannelID. +func (mr *MockRepositoryMockRecorder) GetByChannelID(ctx, channelID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByChannelID", reflect.TypeOf((*MockRepository)(nil).GetByChannelID), ctx, channelID) +} + +// GetByChatID mocks base method. +func (m *MockRepository) GetByChatID(ctx context.Context, chatID uuid.UUID) ([]domain.Follow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetByChatID", ctx, chatID) + ret0, _ := ret[0].([]domain.Follow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetByChatID indicates an expected call of GetByChatID. +func (mr *MockRepositoryMockRecorder) GetByChatID(ctx, chatID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByChatID", reflect.TypeOf((*MockRepository)(nil).GetByChatID), ctx, chatID) +} + +// GetByID mocks base method. +func (m *MockRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.Follow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetByID", ctx, id) + ret0, _ := ret[0].(*domain.Follow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetByID indicates an expected call of GetByID. +func (mr *MockRepositoryMockRecorder) GetByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByID", reflect.TypeOf((*MockRepository)(nil).GetByID), ctx, id) +} diff --git a/internal/repository/follow/pgx.go b/internal/repository/follow/pgx.go new file mode 100644 index 00000000..b8f1282b --- /dev/null +++ b/internal/repository/follow/pgx.go @@ -0,0 +1,217 @@ +package follow + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/satont/twitch-notifier/internal/domain" + "github.com/satont/twitch-notifier/internal/repository" +) + +func NewPgx(pg *pgxpool.Pool) *Pgx { + return &Pgx{ + pg: pg, + } +} + +var _ Repository = (*Pgx)(nil) + +type Pgx struct { + pg *pgxpool.Pool +} + +const tableName = "follows" + +func (c *Pgx) GetByID(ctx context.Context, id uuid.UUID) (*domain.Follow, error) { + follow := Follow{} + + query, args, err := repository.Sq. + Select( + "id", + "chat_id", + "channel_id", + "created_at", + ). + From(tableName). + Where( + "id = ?", + id, + ).ToSql() + if err != nil { + return nil, repository.ErrBadQuery + } + + err = c.pg.QueryRow(ctx, query, args...).Scan( + &follow.ID, + &follow.ChatID, + &follow.ChannelID, + &follow.CreatedAt, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + + return nil, err + } + + return &domain.Follow{ + ID: follow.ID, + ChatID: follow.ChatID, + ChannelID: follow.ChannelID, + CreatedAt: follow.CreatedAt, + }, err +} + +func (c *Pgx) GetByChatID(ctx context.Context, chatID uuid.UUID) ([]domain.Follow, error) { + query, args, err := repository.Sq. + Select( + "id", + "chat_id", + "channel_id", + "created_at", + ). + From(tableName). + Where( + "chat_id = ?", + chatID, + ).ToSql() + if err != nil { + return nil, repository.ErrBadQuery + } + + rows, err := c.pg.Query(ctx, query, args...) + if err != nil { + return nil, err + } + + defer rows.Close() + + var follows []Follow + for rows.Next() { + follow := Follow{} + err = rows.Scan( + &follow.ID, + &follow.ChatID, + &follow.ChannelID, + &follow.CreatedAt, + ) + if err != nil { + return nil, err + } + follows = append(follows, follow) + } + + domainFollows := make([]domain.Follow, len(follows)) + for i, follow := range follows { + domainFollows[i] = domain.Follow{ + ID: follow.ID, + ChatID: follow.ChatID, + ChannelID: follow.ChannelID, + CreatedAt: follow.CreatedAt, + } + } + + return domainFollows, err +} + +func (c *Pgx) GetByChannelID(ctx context.Context, channelID uuid.UUID) ([]domain.Follow, error) { + query, args, err := repository.Sq. + Select( + "id", + "chat_id", + "channel_id", + "created_at", + ). + From(tableName). + Where( + "channel_id = ?", + channelID, + ).ToSql() + if err != nil { + return nil, repository.ErrBadQuery + } + + rows, err := c.pg.Query(ctx, query, args...) + if err != nil { + return nil, err + } + + defer rows.Close() + var follows []Follow + for rows.Next() { + follow := Follow{} + err = rows.Scan( + &follow.ID, + &follow.ChatID, + &follow.ChannelID, + &follow.CreatedAt, + ) + if err != nil { + return nil, err + } + follows = append(follows, follow) + } + + domainFollows := make([]domain.Follow, len(follows)) + for i, follow := range follows { + domainFollows[i] = domain.Follow{ + ID: follow.ID, + ChatID: follow.ChatID, + ChannelID: follow.ChannelID, + CreatedAt: follow.CreatedAt, + } + } + + return domainFollows, err +} + +func (c *Pgx) Create(ctx context.Context, follow domain.Follow) error { + query, args, err := repository.Sq. + Insert(tableName). + Columns( + "id", + "chat_id", + "channel_id", + ). + Values( + follow.ID, + follow.ChatID, + follow.ChannelID, + ).ToSql() + if err != nil { + return repository.ErrBadQuery + } + + _, err = c.pg.Exec(ctx, query, args...) + if err != nil { + return errors.Join(err, ErrCannotCreate) + } + return err +} + +func (c *Pgx) Delete(ctx context.Context, id uuid.UUID) error { + query, args, err := repository.Sq. + Delete(tableName). + Where( + "id = ?", + id, + ).ToSql() + if err != nil { + return repository.ErrBadQuery + } + + rows, err := c.pg.Exec(ctx, query, args...) + if err != nil { + return errors.Join(err, ErrCannotDelete) + } + + if rows.RowsAffected() == 0 { + return ErrNotFound + } + + return nil +} diff --git a/internal/repository/fx/fx.go b/internal/repository/fx/fx.go new file mode 100644 index 00000000..74d9aa56 --- /dev/null +++ b/internal/repository/fx/fx.go @@ -0,0 +1,18 @@ +package fx + +import ( + "github.com/satont/twitch-notifier/internal/repository/channel" + "github.com/satont/twitch-notifier/internal/repository/chat" + "github.com/satont/twitch-notifier/internal/repository/chatsettings" + "github.com/satont/twitch-notifier/internal/repository/follow" + "github.com/satont/twitch-notifier/internal/repository/stream" + "go.uber.org/fx" +) + +var Module = fx.Options( + channel.Module, + chat.Module, + chatsettings.Module, + follow.Module, + stream.Module, +) diff --git a/internal/repository/sq.go b/internal/repository/sq.go new file mode 100644 index 00000000..a7e6ad4b --- /dev/null +++ b/internal/repository/sq.go @@ -0,0 +1,11 @@ +package repository + +import ( + "errors" + + "github.com/Masterminds/squirrel" +) + +var Sq = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar) + +var ErrBadQuery = errors.New("bad query") diff --git a/internal/repository/stream/fx.go b/internal/repository/stream/fx.go new file mode 100644 index 00000000..194bc324 --- /dev/null +++ b/internal/repository/stream/fx.go @@ -0,0 +1,12 @@ +package stream + +import ( + "go.uber.org/fx" +) + +var Module = fx.Provide( + fx.Annotate( + NewPgx, + fx.As(new(Repository)), + ), +) diff --git a/internal/repository/stream/mocks/mock.go b/internal/repository/stream/mocks/mock.go new file mode 100644 index 00000000..90d431c9 --- /dev/null +++ b/internal/repository/stream/mocks/mock.go @@ -0,0 +1,129 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: stream.go +// +// Generated by this command: +// +// mockgen -source=stream.go -destination=mocks/mock.go +// + +// Package mock_stream is a generated GoMock package. +package mock_stream + +import ( + context "context" + reflect "reflect" + + uuid "github.com/google/uuid" + domain "github.com/satont/twitch-notifier/internal/domain" + gomock "go.uber.org/mock/gomock" +) + +// MockRepository is a mock of Repository interface. +type MockRepository struct { + ctrl *gomock.Controller + recorder *MockRepositoryMockRecorder +} + +// MockRepositoryMockRecorder is the mock recorder for MockRepository. +type MockRepositoryMockRecorder struct { + mock *MockRepository +} + +// NewMockRepository creates a new mock instance. +func NewMockRepository(ctrl *gomock.Controller) *MockRepository { + mock := &MockRepository{ctrl: ctrl} + mock.recorder = &MockRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockRepository) Create(ctx context.Context, stream domain.Stream) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", ctx, stream) + ret0, _ := ret[0].(error) + return ret0 +} + +// Create indicates an expected call of Create. +func (mr *MockRepositoryMockRecorder) Create(ctx, stream any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockRepository)(nil).Create), ctx, stream) +} + +// Delete mocks base method. +func (m *MockRepository) Delete(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockRepositoryMockRecorder) Delete(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockRepository)(nil).Delete), ctx, id) +} + +// GetByChannelId mocks base method. +func (m *MockRepository) GetByChannelId(ctx context.Context, channelId uuid.UUID) ([]domain.Stream, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetByChannelId", ctx, channelId) + ret0, _ := ret[0].([]domain.Stream) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetByChannelId indicates an expected call of GetByChannelId. +func (mr *MockRepositoryMockRecorder) GetByChannelId(ctx, channelId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByChannelId", reflect.TypeOf((*MockRepository)(nil).GetByChannelId), ctx, channelId) +} + +// GetById mocks base method. +func (m *MockRepository) GetById(ctx context.Context, id uuid.UUID) (*domain.Stream, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetById", ctx, id) + ret0, _ := ret[0].(*domain.Stream) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetById indicates an expected call of GetById. +func (mr *MockRepositoryMockRecorder) GetById(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetById", reflect.TypeOf((*MockRepository)(nil).GetById), ctx, id) +} + +// GetLatestByChannelId mocks base method. +func (m *MockRepository) GetLatestByChannelId(ctx context.Context, channelId uuid.UUID) (*domain.Stream, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLatestByChannelId", ctx, channelId) + ret0, _ := ret[0].(*domain.Stream) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetLatestByChannelId indicates an expected call of GetLatestByChannelId. +func (mr *MockRepositoryMockRecorder) GetLatestByChannelId(ctx, channelId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestByChannelId", reflect.TypeOf((*MockRepository)(nil).GetLatestByChannelId), ctx, channelId) +} + +// Update mocks base method. +func (m *MockRepository) Update(ctx context.Context, stream domain.Stream) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Update", ctx, stream) + ret0, _ := ret[0].(error) + return ret0 +} + +// Update indicates an expected call of Update. +func (mr *MockRepositoryMockRecorder) Update(ctx, stream any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockRepository)(nil).Update), ctx, stream) +} diff --git a/internal/repository/stream/pgx.go b/internal/repository/stream/pgx.go new file mode 100644 index 00000000..35bcf190 --- /dev/null +++ b/internal/repository/stream/pgx.go @@ -0,0 +1,256 @@ +package stream + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/satont/twitch-notifier/internal/domain" + "github.com/satont/twitch-notifier/internal/repository" + "gopkg.in/guregu/null.v4" +) + +func NewPgx(pg *pgxpool.Pool) *Pgx { + return &Pgx{ + pg: pg, + } +} + +var _ Repository = (*Pgx)(nil) + +type Pgx struct { + pg *pgxpool.Pool +} + +func (c *Pgx) GetById(ctx context.Context, id uuid.UUID) (*domain.Stream, error) { + query, args, err := repository.Sq. + Select( + "id", + "channel_id", + "titles", + "categories", + "started_at", + "updated_at", + "ended_at", + ). + From("streams"). + Where("id = ?", id). + ToSql() + if err != nil { + return nil, repository.ErrBadQuery + } + + stream := Stream{} + err = c.pg.QueryRow(ctx, query, args...).Scan( + &stream.ID, + &stream.ChannelID, + &stream.Titles, + &stream.Categories, + &stream.StartedAt, + &stream.UpdatedAt, + &stream.EndedAt, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + + return nil, err + } + + return &domain.Stream{ + ID: stream.ID, + ChannelID: stream.ChannelID, + Titles: stream.Titles, + Categories: stream.Categories, + StartedAt: stream.StartedAt, + UpdatedAt: stream.UpdatedAt, + EndedAt: stream.EndedAt.Ptr(), + }, err +} + +func (c *Pgx) GetLatestByChannelId(ctx context.Context, channelId uuid.UUID) ( + *domain.Stream, + error, +) { + query, args, err := repository.Sq. + Select( + "id", + "channel_id", + "titles", + "categories", + "started_at", + "updated_at", + "ended_at", + ). + From("streams"). + Where("channel_id = ?", channelId). + OrderBy("started_at DESC"). + Limit(1). + ToSql() + if err != nil { + return nil, repository.ErrBadQuery + } + + stream := Stream{} + err = c.pg.QueryRow(ctx, query, args...).Scan( + &stream.ID, + &stream.ChannelID, + &stream.Titles, + &stream.Categories, + &stream.StartedAt, + &stream.UpdatedAt, + &stream.EndedAt, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + + return nil, err + } + + return &domain.Stream{ + ID: stream.ID, + ChannelID: stream.ChannelID, + Titles: stream.Titles, + Categories: stream.Categories, + StartedAt: stream.StartedAt, + UpdatedAt: stream.UpdatedAt, + EndedAt: stream.EndedAt.Ptr(), + }, err +} + +func (c *Pgx) GetByChannelId(ctx context.Context, channelId uuid.UUID) ([]domain.Stream, error) { + query, args, err := repository.Sq. + Select( + "id", + "channel_id", + "titles", + "categories", + "started_at", + "updated_at", + "ended_at", + ). + From("streams"). + Where("channel_id = ?", channelId). + OrderBy("started_at DESC"). + ToSql() + if err != nil { + return nil, repository.ErrBadQuery + } + + rows, err := c.pg.Query(ctx, query, args...) + if err != nil { + return nil, err + } + + defer rows.Close() + var streams []Stream + for rows.Next() { + stream := Stream{} + err = rows.Scan( + &stream.ID, + &stream.ChannelID, + &stream.Titles, + &stream.Categories, + &stream.StartedAt, + &stream.UpdatedAt, + &stream.EndedAt, + ) + if err != nil { + return nil, err + } + streams = append(streams, stream) + } + + domainStreams := make([]domain.Stream, len(streams)) + for i, stream := range streams { + domainStreams[i] = domain.Stream{ + ID: stream.ID, + ChannelID: stream.ChannelID, + Titles: stream.Titles, + Categories: stream.Categories, + StartedAt: stream.StartedAt, + UpdatedAt: stream.UpdatedAt, + EndedAt: stream.EndedAt.Ptr(), + } + } + + return domainStreams, err +} + +func (c *Pgx) Create(ctx context.Context, stream domain.Stream) error { + query, args, err := repository.Sq. + Insert("streams"). + Columns( + "id", + "channel_id", + "titles", + "categories", + "started_at", + "updated_at", + "ended_at", + ). + Values( + stream.ID, + stream.ChannelID, + stream.Titles, + stream.Categories, + stream.StartedAt, + stream.UpdatedAt, + null.Time{}, + ).ToSql() + if err != nil { + return repository.ErrBadQuery + } + + _, err = c.pg.Exec(ctx, query, args...) + if err != nil { + return errors.Join(err, ErrCannotCreate) + } + + return nil +} + +func (c *Pgx) Update(ctx context.Context, stream domain.Stream) error { + query, args, err := repository.Sq. + Update("streams"). + Set("channel_id", stream.ChannelID). + Set("titles", stream.Titles). + Set("categories", stream.Categories). + Set("started_at", stream.StartedAt). + Set("updated_at", stream.UpdatedAt). + Set("ended_at", null.TimeFromPtr(stream.EndedAt)). + Where("id = ?", stream.ID). + ToSql() + if err != nil { + return repository.ErrBadQuery + } + + _, err = c.pg.Exec(ctx, query, args...) + return err +} + +func (c *Pgx) Delete(ctx context.Context, id uuid.UUID) error { + query, args, err := repository.Sq. + Delete("streams"). + Where("id = ?", id). + ToSql() + if err != nil { + return repository.ErrBadQuery + } + + rows, err := c.pg.Exec(ctx, query, args...) + if err != nil { + return errors.Join(err, ErrCannotDelete) + } + + if rows.RowsAffected() == 0 { + return ErrNotFound + } + + return nil +} diff --git a/internal/repository/stream/stream.go b/internal/repository/stream/stream.go new file mode 100644 index 00000000..a75ba3cb --- /dev/null +++ b/internal/repository/stream/stream.go @@ -0,0 +1,36 @@ +package stream + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/satont/twitch-notifier/internal/domain" + "gopkg.in/guregu/null.v4" +) + +type Stream struct { + ID uuid.UUID + ChannelID uuid.UUID + Titles []string + Categories []string + StartedAt time.Time + UpdatedAt time.Time + EndedAt null.Time +} + +var ErrNotFound = errors.New("stream not found") +var ErrCannotCreate = errors.New("cannot create stream") +var ErrCannotDelete = errors.New("cannot delete stream") + +//go:generate go run go.uber.org/mock/mockgen -source=stream.go -destination=mocks/mock.go + +type Repository interface { + GetById(ctx context.Context, id uuid.UUID) (*domain.Stream, error) + GetLatestByChannelId(ctx context.Context, channelId uuid.UUID) (*domain.Stream, error) + GetByChannelId(ctx context.Context, channelId uuid.UUID) ([]domain.Stream, error) + Create(ctx context.Context, stream domain.Stream) error + Update(ctx context.Context, stream domain.Stream) error + Delete(ctx context.Context, id uuid.UUID) error +} diff --git a/internal/streams/handler/streams_handler.go b/internal/streams/handler/streams_handler.go new file mode 100644 index 00000000..3aae6b7e --- /dev/null +++ b/internal/streams/handler/streams_handler.go @@ -0,0 +1,35 @@ +package handler + +import ( + "context" +) + +type StreamsHandler interface { + Online(ctx context.Context, opts ChannelOnlineOpts) error + Offline(ctx context.Context, opts ChannelOfflineOpts) error + MetadataChange(ctx context.Context, opts ChannelMetaDataChangedOpts) error +} + +type ChannelOnlineOpts struct { + ChannelID string + ThumbnailURL string + Category Category + Title string +} + +type ChannelOfflineOpts struct { + ChannelID string +} + +type ChannelMetaDataChangedOpts struct { + ChannelID string + OldTitle string + NewTitle string + OldCategory Category + NewCategory Category +} + +type Category struct { + ID string + Name string +} diff --git a/internal/streams/handler/twitch.go b/internal/streams/handler/twitch.go new file mode 100644 index 00000000..82dc515b --- /dev/null +++ b/internal/streams/handler/twitch.go @@ -0,0 +1,115 @@ +package handler + +import ( + "context" + + "github.com/satont/twitch-notifier/internal/domain" + "github.com/satont/twitch-notifier/internal/repository/channel" +) + +type TwitchHandlerOpts struct { + AnnounceSender announcesender.AnnounceSender + ChannelRepository channel.Repository +} + +func NewTwitch(opts TwitchHandlerOpts) *TwitchHandler { + return &TwitchHandler{ + announceSender: opts.AnnounceSender, + channelRepository: opts.ChannelRepository, + } +} + +var _ StreamsHandler = (*TwitchHandler)(nil) + +type TwitchHandler struct { + announceSender announcesender.AnnounceSender + channelRepository channel.Repository +} + +func (c *TwitchHandler) Online(ctx context.Context, opts ChannelOnlineOpts) error { + streamChannel, err := c.channelRepository.GetByStreamServiceAndID( + ctx, + domain.StreamingServiceTwitch, + opts.ChannelID, + ) + if err != nil { + return err + } + + return c.announceSender.SendOnline( + ctx, + announcesender.ChannelOnlineOpts{ + ChannelID: streamChannel.ID, + Category: opts.Category.Name, + Title: opts.Title, + ThumbnailURL: opts.ThumbnailURL, + }, + ) +} + +func (c *TwitchHandler) Offline(ctx context.Context, opts ChannelOfflineOpts) error { + streamChannel, err := c.channelRepository.GetByStreamServiceAndID( + ctx, + domain.StreamingServiceTwitch, + opts.ChannelID, + ) + if err != nil { + return err + } + + return c.announceSender.SendOffline( + ctx, + announcesender.ChannelOfflineOpts{ + ChannelID: streamChannel.ID, + }, + ) +} + +func (c *TwitchHandler) MetadataChange(ctx context.Context, opts ChannelMetaDataChangedOpts) error { + streamChannel, err := c.channelRepository.GetByStreamServiceAndID( + ctx, + domain.StreamingServiceTwitch, + opts.ChannelID, + ) + if err != nil { + return err + } + + // TODO: this is bad, need to rethink + + if opts.OldCategory.Name != opts.NewCategory.Name && opts.OldTitle != opts.NewTitle { + return c.announceSender.SendTitleAndCategoryChange( + ctx, + announcesender.ChannelTitleAndCategoryChangeOpts{ + ChannelID: streamChannel.ID, + OldCategory: opts.OldCategory.Name, + NewCategory: opts.NewCategory.Name, + OldTitle: opts.OldTitle, + }, + ) + } + + if opts.OldCategory.Name != opts.NewCategory.Name { + return c.announceSender.SendCategoryChange( + ctx, + announcesender.ChannelCategoryChangeOpts{ + ChannelID: streamChannel.ID, + OldCategory: opts.OldCategory.Name, + NewCategory: opts.NewCategory.Name, + }, + ) + } + + if opts.OldTitle != opts.NewTitle { + return c.announceSender.SendTitleChange( + ctx, + announcesender.ChannelTitleChangeOpts{ + ChannelID: streamChannel.ID, + OldTitle: opts.OldTitle, + NewTitle: opts.NewTitle, + }, + ) + } + + return nil +} diff --git a/internal/streams/watchers/twitch/impl.go b/internal/streams/watchers/twitch/impl.go new file mode 100644 index 00000000..333e1aed --- /dev/null +++ b/internal/streams/watchers/twitch/impl.go @@ -0,0 +1,22 @@ +package twitch + +type Opts struct { + Handler streamshandler.StreamsHandler +} + +func New(opts Opts) *Impl { + return &Impl{ + handler: opts.Handler, + } +} + +var _ Watcher = (*Impl)(nil) + +type Impl struct { + handler streamshandler.StreamsHandler +} + +func (c *Impl) Start() error { + // TODO implement me + panic("implement me") +} diff --git a/internal/streams/watchers/twitch/twitch.go b/internal/streams/watchers/twitch/twitch.go new file mode 100644 index 00000000..ff9871c4 --- /dev/null +++ b/internal/streams/watchers/twitch/twitch.go @@ -0,0 +1,5 @@ +package twitch + +type Watcher interface { + Start() error +} diff --git a/internal/telegram/commands/broadcast.go b/internal/telegram/commands/broadcast.go deleted file mode 100644 index 55582591..00000000 --- a/internal/telegram/commands/broadcast.go +++ /dev/null @@ -1,77 +0,0 @@ -package commands - -import ( - "context" - "strconv" - "strings" - "sync" - - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/samber/lo" - "github.com/satont/twitch-notifier/internal/db/db_models" - tgtypes "github.com/satont/twitch-notifier/internal/telegram/types" - "github.com/satont/twitch-notifier/internal/types" - "go.uber.org/zap" -) - -type BroadcastCommand struct { - *tgtypes.CommandOpts -} - -func (c *BroadcastCommand) HandleCommand(ctx context.Context, msg *tgb.MessageUpdate) error { - chats, err := c.Services.Chat.GetAllByService(ctx, db_models.ChatServiceTelegram) - if err != nil { - zap.S().Error(err) - return msg.Answer("Error").DoVoid(ctx) - } - - wg := sync.WaitGroup{} - wg.Add(len(chats)) - - for _, chat := range chats { - go func(chat *db_models.Chat) { - defer wg.Done() - - chatId, _ := strconv.Atoi(chat.ChatID) - - // filter channels, thay have negative id. - if chatId <= 0 { - return - } - - err := msg.Client. - SendMessage( - tg.ChatID(chatId), - strings.Replace(msg.Message.Text, "/broadcast ", "", 1), - ).DoVoid(ctx) - if err != nil { - zap.S().Error(err) - } - }(chat) - } - - wg.Wait() - - return nil -} - -var ( - broadcastCommandFilter = tgb.Command("broadcast") - broadcastCommandAdminFilter = func(services *types.Services) tgb.Filter { - return tgb.FilterFunc(func(ctx context.Context, update *tgb.Update) (bool, error) { - return lo.Contains(services.Config.TelegramBotAdmins, update.Message.Chat.ID.PeerID()), nil - }) - } -) - -func NewBroadcastCommand(opts *tgtypes.CommandOpts) { - cmd := &BroadcastCommand{ - CommandOpts: opts, - } - opts.Router.Message( - cmd.HandleCommand, - broadcastCommandFilter, - broadcastCommandAdminFilter(opts.Services), - ) -} diff --git a/internal/telegram/commands/broadcast_test.go b/internal/telegram/commands/broadcast_test.go deleted file mode 100644 index 2debeb35..00000000 --- a/internal/telegram/commands/broadcast_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package commands - -import ( - "context" - "fmt" - "github.com/satont/twitch-notifier/internal/db/db_models" - "github.com/satont/twitch-notifier/internal/telegram/types" - "github.com/satont/twitch-notifier/internal/types" - "io" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/satont/twitch-notifier/internal/test_utils" - "github.com/satont/twitch-notifier/internal/test_utils/mocks" - "github.com/stretchr/testify/assert" -) - -func TestBroadcastCommand_HandleCommand(t *testing.T) { - t.Parallel() - - ctx := context.Background() - chatMock := &mocks.DbChatMock{} - - table := []struct { - name string - message *tgb.MessageUpdate - serverMock *httptest.Server - setupMocks func() - }{ - { - name: "Should call SendMessage for each chat", - message: &tgb.MessageUpdate{ - Message: &tg.Message{ - Text: "/broadcast test", - }, - }, - serverMock: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - query, err := url.ParseQuery(string(body)) - assert.NoError(t, err) - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal( - t, - fmt.Sprintf("/bot%s/sendMessage", test_utils.TelegramClientToken), - r.URL.Path, - ) - assert.Equal(t, "test", query.Get("text")) - assert.Contains(t, []string{"1", "2"}, query.Get("chat_id")) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - })), - setupMocks: func() { - chatMock. - On("GetAllByService", ctx, db_models.ChatServiceTelegram). - Return( - []*db_models.Chat{{ChatID: "1"}, {ChatID: "2"}}, - nil, - ) - }, - }, - } - - for _, tt := range table { - tt := tt - t.Run(tt.name, func(t *testing.T) { - defer tt.serverMock.Close() - tt.setupMocks() - client := test_utils.NewTelegramClient(tt.serverMock) - tt.message.Client = client - cmd := &BroadcastCommand{ - CommandOpts: &tg_types.CommandOpts{ - Services: &types.Services{ - Chat: chatMock, - }, - }, - } - err := cmd.HandleCommand(ctx, tt.message) - assert.NoError(t, err) - - chatMock.AssertExpectations(t) - }) - } -} diff --git a/internal/telegram/commands/change_channel_id.go b/internal/telegram/commands/change_channel_id.go deleted file mode 100644 index 2701a5e2..00000000 --- a/internal/telegram/commands/change_channel_id.go +++ /dev/null @@ -1,64 +0,0 @@ -package commands - -import ( - "context" - "github.com/mr-linch/go-tg/tgb" - "github.com/samber/lo" - "github.com/satont/twitch-notifier/internal/db" - "github.com/satont/twitch-notifier/internal/db/db_models" - tgtypes "github.com/satont/twitch-notifier/internal/telegram/types" - "github.com/satont/twitch-notifier/internal/types" - "go.uber.org/zap" - "strings" -) - -type ChangeChannelId struct { - *tgtypes.CommandOpts -} - -func (c *ChangeChannelId) HandleCommand(ctx context.Context, msg *tgb.MessageUpdate) error { - text := strings.ReplaceAll(msg.Message.Text, "/change_channel_id ", "") - splittedText := strings.Split(strings.TrimSpace(text), " ") - - if len(splittedText) != 2 { - return nil - } - - sourceChannelID := splittedText[0] - targetChannelID := splittedText[1] - - _, err := c.Services.Channel.Update( - ctx, - sourceChannelID, - db_models.ChannelServiceTwitch, - &db.ChannelUpdateQuery{ - DangerNewChannelId: &targetChannelID, - }, - ) - - if err != nil { - zap.S().Error(err) - } - - return msg.Answer("done").DoVoid(ctx) -} - -var ( - changeChannelIdFilter = tgb.Command("change_channel_id") - changeChannelIdFilterAdminFilter = func(services *types.Services) tgb.Filter { - return tgb.FilterFunc(func(ctx context.Context, update *tgb.Update) (bool, error) { - return lo.Contains(services.Config.TelegramBotAdmins, update.Message.Chat.ID.PeerID()), nil - }) - } -) - -func NewChangeChannelId(opts *tgtypes.CommandOpts) { - cmd := &ChangeChannelId{ - CommandOpts: opts, - } - opts.Router.Message( - cmd.HandleCommand, - changeChannelIdFilter, - changeChannelIdFilterAdminFilter(opts.Services), - ) -} diff --git a/internal/telegram/commands/filters.go b/internal/telegram/commands/filters.go deleted file mode 100644 index 028fb146..00000000 --- a/internal/telegram/commands/filters.go +++ /dev/null @@ -1,37 +0,0 @@ -package commands - -import ( - "context" - - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" -) - -var channelsAdminFilter = tgb.FilterFunc(func(ctx context.Context, update *tgb.Update) (bool, error) { - if update.Chat().Type == tg.ChatTypePrivate || update.Chat().Type == tg.ChatTypeSender { - return true, nil - } - - admins, err := update.Client.GetChatAdministrators(update.Chat().ID).Do(ctx) - if err != nil { - return false, err - } - - if update.CallbackQuery != nil { - for _, admin := range admins { - if admin.User.ID == update.CallbackQuery.From.ID { - return true, nil - } - } - } else if update.Message != nil && update.Message.From != nil { - for _, admin := range admins { - if admin.User.ID == update.Message.From.ID { - return true, nil - } - } - } else { - return true, nil - } - - return false, nil -}) diff --git a/internal/telegram/commands/follow.go b/internal/telegram/commands/follow.go deleted file mode 100644 index bd834332..00000000 --- a/internal/telegram/commands/follow.go +++ /dev/null @@ -1,180 +0,0 @@ -package commands - -import ( - "context" - "errors" - "github.com/mr-linch/go-tg/tgb" - "github.com/satont/twitch-notifier/internal/db/db_models" - tgtypes "github.com/satont/twitch-notifier/internal/telegram/types" - "go.uber.org/zap" - "regexp" - "strings" -) - -type FollowCommand struct { - *tgtypes.CommandOpts -} - -var ( - twitchInvalidNamesString = "Invalid login names, emails or IDs in request" - channelNotFoundError = errors.New("channel not found") - invalidNameError = errors.New(twitchInvalidNamesString) - TwitchLinkRegular = regexp.MustCompile(`(?:https?://)?(?:www\.)?twitch\.tv/(\w+)`) -) - -func (c *FollowCommand) createFollow( - ctx context.Context, - chat *db_models.Chat, - input string, -) (*db_models.Follow, error) { - twitchChannel, err := c.Services.Twitch.GetUser("", input) - if err != nil { - if err.Error() == twitchInvalidNamesString { - return nil, invalidNameError - } - - return nil, err - } - - if twitchChannel == nil { - return nil, channelNotFoundError - } - - dbChannel, err := c.Services.Channel.GetByIdOrCreate( - ctx, - twitchChannel.ID, - db_models.ChannelServiceTwitch, - ) - if err != nil { - return nil, err - } - - follow, err := c.Services.Follow.Create(ctx, dbChannel.ID, chat.ID) - if err != nil { - return nil, err - } - - return follow, nil -} - -func (c *FollowCommand) handleScene(ctx context.Context, msg *tgb.MessageUpdate) error { - chat := c.SessionManager.Get(ctx).Chat - - nicknames := make([]string, 0) - - regularMatches := TwitchLinkRegular.FindAllStringSubmatch(msg.Text, -1) - - if len(regularMatches) > 0 { - for _, match := range regularMatches { - nicknames = append(nicknames, match[1]) - } - } else { - nicknames = append(nicknames, msg.Text) - } - - succeeded := make([]string, 0) - failed := make([]string, 0) - - for _, nickname := range nicknames { - _, err := c.createFollow(ctx, chat, nickname) - - if errors.Is(err, channelNotFoundError) { - message := c.Services.I18N.Translate( - "commands.follow.errors.streamerNotFound", - chat.Settings.ChatLanguage.String(), - map[string]string{ - "streamer": nickname, - }, - ) - failed = append(failed, message) - } else if errors.Is(err, db_models.FollowAlreadyExistsError) { - message := c.Services.I18N.Translate( - "commands.follow.errors.alreadyFollowed", - chat.Settings.ChatLanguage.String(), - map[string]string{ - "streamer": nickname, - }, - ) - failed = append(failed, message) - } else if errors.Is(err, invalidNameError) { - message := c.Services.I18N.Translate( - "commands.follow.errors.badUsername", - chat.Settings.ChatLanguage.String(), - map[string]string{ - "streamer": nickname, - }, - ) - failed = append(failed, message) - } else if err != nil { - zap.S().Error(err) - failed = append(failed, "internal error") - } else { - message := c.Services.I18N.Translate( - "commands.follow.success", - chat.Settings.ChatLanguage.String(), - map[string]string{ - "streamer": nickname, - }, - ) - succeeded = append(succeeded, message) - } - } - - c.SessionManager.Get(ctx).Scene = "" - - message := strings.Join(succeeded, "\n") - message += "\n\n" - message += strings.Join(failed, "\n") - - return msg.Answer(message).DoVoid(ctx) -} - -func (c *FollowCommand) HandleCommand(ctx context.Context, msg *tgb.MessageUpdate) error { - session := c.SessionManager.Get(ctx) - - text := strings.ReplaceAll(msg.Text, "/follow", "") - text = strings.TrimSpace(text) - - if text != "" { - msg.Text = text - return c.handleScene(ctx, msg) - } else { - c.SessionManager.Get(ctx).Scene = "follow" - return msg. - Answer(c.Services.I18N.Translate( - "commands.follow.enter", - session.Chat.Settings.ChatLanguage.String(), - nil, - )). - DoVoid(ctx) - } -} - -var ( - followCommandQuery = tgb.Command("follow") -) - -func NewFollowCommand(opts *tgtypes.CommandOpts) { - cmd := &FollowCommand{ - CommandOpts: opts, - } - - sceneFilter := []tgb.Filter{ - channelsAdminFilter, - tgb.FilterFunc(func(ctx context.Context, update *tgb.Update) (bool, error) { - session := opts.SessionManager.Get(ctx) - return session.Scene == "follow", nil - }), - } - - opts.Router.Message(cmd.handleScene, sceneFilter...) - opts.Router.ChannelPost(cmd.handleScene, sceneFilter...) - - messageFilter := []tgb.Filter{ - channelsAdminFilter, - followCommandQuery, - } - - opts.Router.Message(cmd.HandleCommand, messageFilter...) - opts.Router.ChannelPost(cmd.HandleCommand, messageFilter...) -} diff --git a/internal/telegram/commands/follow_test.go b/internal/telegram/commands/follow_test.go deleted file mode 100644 index 79afe2e5..00000000 --- a/internal/telegram/commands/follow_test.go +++ /dev/null @@ -1,424 +0,0 @@ -package commands - -import ( - "context" - "errors" - "fmt" - "github.com/satont/twitch-notifier/internal/db/db_models" - "github.com/satont/twitch-notifier/internal/telegram/types" - "github.com/satont/twitch-notifier/internal/types" - "github.com/satont/twitch-notifier/pkg/i18n/mocks" - "github.com/stretchr/testify/mock" - "net/http" - "net/http/httptest" - "testing" - - "github.com/google/uuid" - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/nicklaw5/helix/v2" - "github.com/satont/twitch-notifier/internal/test_utils" - "github.com/satont/twitch-notifier/internal/test_utils/mocks" - "github.com/stretchr/testify/assert" -) - -func TestFollowService(t *testing.T) { - t.Parallel() - - mockedTwitch := &mocks.TwitchApiMock{} - channelsMock := &mocks.DbChannelMock{} - followsMock := &mocks.DbFollowMock{} - i18nMock := i18nmocks.NewI18nMock() - - userLogin := "fukushine" - user := &helix.User{ - ID: "1", - Login: userLogin, - DisplayName: "Fukushine", - } - - ctx := context.Background() - - chat := &db_models.Chat{ - ID: uuid.New(), - } - chann := &db_models.Channel{ - ID: uuid.New(), - ChannelID: "1", - } - f := &db_models.Follow{} - - follow := &FollowCommand{ - &tg_types.CommandOpts{ - Services: &types.Services{ - Twitch: mockedTwitch, - Channel: channelsMock, - Follow: followsMock, - I18N: i18nMock, - }, - }, - } - - // table tests - table := []struct { - name string - input string - want *db_models.Follow - wantErr bool - setupMocks func() - }{ - { - name: "Should fail because of GetUser error", - input: "fukushine2", - want: nil, - wantErr: true, - setupMocks: func() { - mockedTwitch.On("GetUser", "", "fukushine2").Return((*helix.User)(nil), nil) - }, - }, - { - name: "Should create", - input: userLogin, - want: f, - wantErr: false, - setupMocks: func() { - mockedTwitch. - On("GetUser", "", userLogin).Return(user, nil) - channelsMock. - On("GetByIdOrCreate", ctx, user.ID, db_models.ChannelServiceTwitch).Return(chann, nil) - followsMock. - On("Create", ctx, chann.ID, chat.ID).Return(f, nil) - }, - }, - { - name: "Should fail because follow exists", - input: userLogin, - want: nil, - wantErr: true, - setupMocks: func() { - mockedTwitch. - On("GetUser", "", userLogin).Return(user, nil) - channelsMock. - On("GetByIdOrCreate", ctx, user.ID, db_models.ChannelServiceTwitch).Return(chann, nil) - followsMock. - On("Create", ctx, chann.ID, chat.ID).Return((*db_models.Follow)(nil), db_models.FollowAlreadyExistsError) - }, - }, - } - - for _, tt := range table { - t.Run(tt.name, func(t *testing.T) { - tt.setupMocks() - - got, err := follow.createFollow(ctx, chat, tt.input) - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.want, got) - } - - mockedTwitch.AssertExpectations(t) - channelsMock.AssertExpectations(t) - followsMock.AssertExpectations(t) - - mockedTwitch.ExpectedCalls = nil - channelsMock.ExpectedCalls = nil - followsMock.ExpectedCalls = nil - }) - } -} - -func TestFollowCommand_HandleCommand(t *testing.T) { - t.Parallel() - - ctx := context.Background() - - sessionService := tg_types.NewMockedSessionManager() - - sessionService.On("Get", ctx).Return(&tg_types.Session{ - Chat: &db_models.Chat{ - ChatID: "123", - Settings: &db_models.ChatSettings{ - ChatLanguage: db_models.ChatLanguageEn, - }, - }, - }) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal( - t, - fmt.Sprintf("/bot%s/sendMessage", test_utils.TelegramClientToken), - r.URL.Path, - ) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - })) - - tgClient := test_utils.NewTelegramClient(server) - - i18nMock := i18nmocks.NewI18nMock() - i18nMock. - On( - "Translate", - "commands.follow.enter", - "en", - (map[string]string)(nil), - ). - Return("test") - - followCommand := &FollowCommand{ - &tg_types.CommandOpts{ - SessionManager: sessionService, - Services: &types.Services{ - I18N: i18nMock, - }, - }, - } - - assert.Equal(t, "", sessionService.Get(ctx).Scene) - err := followCommand.HandleCommand(ctx, &tgb.MessageUpdate{ - Client: tgClient, - Message: &tg.Message{ - Chat: tg.Chat{ - ID: 123, - }, - }, - }) - assert.NoError(t, err) - assert.Equal(t, "follow", sessionService.Get(ctx).Scene) - - sessionService.AssertExpectations(t) - i18nMock.AssertExpectations(t) -} - -func TestFollowCommand_HandleScene(t *testing.T) { - t.Parallel() - - mockedTwitch := &mocks.TwitchApiMock{} - channelsMock := &mocks.DbChannelMock{} - followsMock := &mocks.DbFollowMock{} - i18nMock := i18nmocks.NewI18nMock() - sessionMock := tg_types.NewMockedSessionManager() - - ctx := context.Background() - - userLogin := "satont" - helixUser := &helix.User{ - ID: "1", - Login: userLogin, - DisplayName: "Satont", - } - - dbChat := &db_models.Chat{ - ID: uuid.New(), - Settings: &db_models.ChatSettings{ - ChatLanguage: db_models.ChatLanguageEn, - }, - } - dbChannel := &db_models.Channel{ - ID: uuid.New(), - ChannelID: "1", - } - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal( - t, - fmt.Sprintf("/bot%s/sendMessage", test_utils.TelegramClientToken), - r.URL.Path, - ) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - })) - defer server.Close() - tgMockedServer := test_utils.NewTelegramClient(server) - - sessionMock.On("Get", ctx).Return(&tg_types.Session{ - Chat: dbChat, - }) - - var clearMocks = func() { - mockedTwitch.ExpectedCalls = nil - channelsMock.ExpectedCalls = nil - followsMock.ExpectedCalls = nil - i18nMock.ExpectedCalls = nil - - mockedTwitch.Calls = nil - channelsMock.Calls = nil - followsMock.Calls = nil - i18nMock.Calls = nil - } - - table := []struct { - name string - input string - setupMocks func() - asserts func(t *testing.T, err error) - }{ - { - name: "Should fail because of GetUser error", - input: "satont", - setupMocks: func() { - mockedTwitch. - On("GetUser", "", userLogin). - Return((*helix.User)(nil), nil) - i18nMock.On( - "Translate", - "commands.follow.errors.streamerNotFound", - "en", - map[string]string{"streamer": userLogin}, - ).Return("") - }, - asserts: func(t *testing.T, err error) { - assert.NoError(t, err) - - i18nMock.AssertExpectations(t) - mockedTwitch.AssertExpectations(t) - channelsMock.AssertExpectations(t) - followsMock.AssertExpectations(t) - - clearMocks() - }, - }, - { - name: "Should fail because db follow exists", - input: userLogin, - setupMocks: func() { - mockedTwitch.On("GetUser", "", userLogin).Return(helixUser, nil) - channelsMock. - On("GetByIdOrCreate", ctx, helixUser.ID, db_models.ChannelServiceTwitch). - Return(dbChannel, nil) - followsMock. - On("Create", ctx, dbChannel.ID, dbChat.ID). - Return((*db_models.Follow)(nil), db_models.FollowAlreadyExistsError) - i18nMock.On( - "Translate", - "commands.follow.errors.alreadyFollowed", - "en", - map[string]string{"streamer": userLogin}, - ).Return("") - }, - asserts: func(t *testing.T, err error) { - assert.NoError(t, err) - - i18nMock.AssertExpectations(t) - mockedTwitch.AssertExpectations(t) - channelsMock.AssertExpectations(t) - followsMock.AssertExpectations(t) - - clearMocks() - }, - }, - { - name: "Should fail because db channel cannot be created", - input: userLogin, - setupMocks: func() { - mockedTwitch.On("GetUser", "", userLogin).Return(helixUser, nil) - channelsMock. - On("GetByIdOrCreate", ctx, helixUser.ID, db_models.ChannelServiceTwitch). - Return(dbChannel, errors.New("some error")) - }, - asserts: func(t *testing.T, err error) { - assert.NoError(t, err) - - i18nMock.AssertExpectations(t) - mockedTwitch.AssertExpectations(t) - channelsMock.AssertExpectations(t) - followsMock.AssertExpectations(t) - - clearMocks() - }, - }, - { - name: "Should success", - input: userLogin, - setupMocks: func() { - mockedTwitch.On("GetUser", "", userLogin).Return(helixUser, nil) - channelsMock. - On("GetByIdOrCreate", ctx, helixUser.ID, db_models.ChannelServiceTwitch). - Return(dbChannel, nil) - followsMock. - On("Create", ctx, dbChannel.ID, dbChat.ID). - Return((*db_models.Follow)(nil), nil) - i18nMock.On( - "Translate", - "commands.follow.success", - "en", - map[string]string{"streamer": userLogin}, - ).Return("") - }, - asserts: func(t *testing.T, err error) { - assert.NoError(t, err) - - i18nMock.AssertExpectations(t) - mockedTwitch.AssertExpectations(t) - channelsMock.AssertExpectations(t) - followsMock.AssertExpectations(t) - - clearMocks() - }, - }, - { - name: "Should create multiple follows", - input: "https://www.twitch.tv/satont, https://www.twitch.tv/satont2", - setupMocks: func() { - mockedTwitch.On("GetUser", mock.Anything, mock.Anything).Return(helixUser, nil) - channelsMock. - On("GetByIdOrCreate", ctx, helixUser.ID, db_models.ChannelServiceTwitch). - Return(dbChannel, nil) - followsMock. - On("Create", ctx, dbChannel.ID, dbChat.ID). - Return((*db_models.Follow)(nil), nil) - i18nMock.On( - "Translate", - "commands.follow.success", - "en", - mock.Anything, - ).Return("") - }, - asserts: func(t *testing.T, err error) { - assert.NoError(t, err) - - mockedTwitch.AssertNumberOfCalls(t, "GetUser", 2) - channelsMock.AssertNumberOfCalls(t, "GetByIdOrCreate", 2) - followsMock.AssertNumberOfCalls(t, "Create", 2) - i18nMock.AssertNumberOfCalls(t, "Translate", 2) - - clearMocks() - }, - }, - } - - for _, tt := range table { - t.Run(tt.name, func(t *testing.T) { - tt.setupMocks() - - followCommand := &FollowCommand{ - &tg_types.CommandOpts{ - SessionManager: sessionMock, - Services: &types.Services{ - Twitch: mockedTwitch, - Channel: channelsMock, - Follow: followsMock, - I18N: i18nMock, - }, - }, - } - - tgMsg := &tgb.MessageUpdate{ - Client: tgMockedServer, - Message: &tg.Message{ - Chat: tg.Chat{ID: 1}, - Text: tt.input, - }, - } - - err := followCommand.handleScene(ctx, tgMsg) - tt.asserts(t, err) - }) - } -} diff --git a/internal/telegram/commands/follows.go b/internal/telegram/commands/follows.go deleted file mode 100644 index 8c908c45..00000000 --- a/internal/telegram/commands/follows.go +++ /dev/null @@ -1,249 +0,0 @@ -package commands - -import ( - "context" - "errors" - "fmt" - "math" - "strings" - - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/samber/lo" - "github.com/satont/twitch-notifier/internal/db/db_models" - tgtypes "github.com/satont/twitch-notifier/internal/telegram/types" - "go.uber.org/zap" -) - -type FollowsCommand struct { - *tgtypes.CommandOpts -} - -const followsMaxRows = 3 -const followsPerRow = 3 - -func (c *FollowsCommand) newKeyboard( - ctx context.Context, - maxRows, perRow int, -) (*tg.InlineKeyboardMarkup, error) { - session := c.SessionManager.Get(ctx) - - limit := maxRows * perRow - offset := (session.FollowsMenu.CurrentPage - 1) * limit - - if offset < 0 { - offset = 0 - } - - layout := tg.NewButtonLayout[tg.InlineKeyboardButton](perRow) - - follows, err := c.Services.Follow.GetByChatID( - ctx, - session.Chat.ID, - limit, - offset, - ) - if err != nil { - zap.S().Error(err) - return nil, err - } - if len(follows) == 0 { - markup := tg.NewInlineKeyboardMarkup(layout.Keyboard()...) - return &markup, nil - } - - totalFollows, err := c.Services.Follow.CountByChatID(ctx, session.Chat.ID) - if err != nil { - zap.S().Error(err) - return nil, err - } - - session.FollowsMenu.TotalPages = int(math.Ceil(float64(totalFollows) / float64(limit))) - // spew.Dump(totalFollows) - // spew.Dump(session.FollowsMenu) - // spew.Dump(session.FollowsMenu.CurrentPage) - - channelsIds := lo.Map( - follows, func(follow *db_models.Follow, _ int) string { - return follow.Channel.ChannelID - }, - ) - - channels, err := c.Services.Twitch.GetChannelsByUserIds(channelsIds) - - if err != nil { - return nil, err - } - - for _, channel := range channels { - internalChannel, _ := lo.Find( - follows, - func(follow *db_models.Follow) bool { - return follow.Channel.ChannelID == channel.BroadcasterID - }, - ) - - layout.Insert( - tg.NewInlineKeyboardButtonCallback( - channel.BroadcasterName, - fmt.Sprintf("channels_unfollow_%s", internalChannel.ChannelID), - ), - ) - } - - var paginationRow *tg.ButtonLayout[tg.InlineKeyboardButton] - - if session.FollowsMenu.CurrentPage > 1 || - session.FollowsMenu.CurrentPage < session.FollowsMenu.TotalPages { - paginationRow = layout.Row() - - // Add "Prev" button - if session.FollowsMenu.CurrentPage > 1 { - paginationRow.Insert( - tg.NewInlineKeyboardButtonCallback( - "«", - "channels_unfollow_prev_page", - ), - ) - } - - // Add "Next" button - if session.FollowsMenu.CurrentPage < session.FollowsMenu.TotalPages { - paginationRow.Insert( - tg.NewInlineKeyboardButtonCallback( - "»", - "channels_unfollow_next_page", - ), - ) - } - } - - markup := tg.NewInlineKeyboardMarkup(layout.Keyboard()...) - - return &markup, nil -} - -func (c *FollowsCommand) HandleCommand(ctx context.Context, msg *tgb.MessageUpdate) error { - session := c.SessionManager.Get(ctx) - - session.FollowsMenu.TotalPages = 1 - session.FollowsMenu.CurrentPage = 1 - - keyboard, err := c.newKeyboard(ctx, followsMaxRows, followsPerRow) - if err != nil { - zap.S().Error(err) - return msg.Answer("internal error").DoVoid(ctx) - } - - totalFollows, err := c.Services.Follow.CountByChatID(ctx, session.Chat.ID) - - return msg. - Answer( - c.Services.I18N.Translate( - "commands.follows.total", - session.Chat.Settings.ChatLanguage.String(), - map[string]string{"count": fmt.Sprintf("%v", totalFollows)}, - ), - ). - ReplyMarkup(keyboard).DoVoid(ctx) -} - -func (c *FollowsCommand) handleUnfollow( - ctx context.Context, - chat *db_models.Chat, - input string, -) error { - channelID := strings.Replace(input, "channels_unfollow_", "", 1) - - channel, err := c.Services.Channel.GetByID(ctx, channelID, db_models.ChannelServiceTwitch) - if err != nil { - return err - } - - follow, err := c.Services.Follow.GetByChatAndChannel(ctx, channel.ID, chat.ID) - if err != nil { - return err - } - - return c.Services.Follow.Delete(ctx, follow.ID) -} - -func (c *FollowsCommand) unfollowQuery(ctx context.Context, msg *tgb.CallbackQueryUpdate) error { - chat := c.SessionManager.Get(ctx).Chat - - if err := c.handleUnfollow(ctx, chat, msg.CallbackQuery.Data); err != nil { - if errors.Is(err, db_models.FollowNotFoundError) { - return msg.Answer().Text("already unfollowed").DoVoid(ctx) - } - - zap.S().Error(err) - - return msg.Answer().Text("internal error").DoVoid(ctx) - } - - return msg.Answer().Text("unfollowed").DoVoid(ctx) -} - -func (c *FollowsCommand) prevPageQuery(ctx context.Context, msg *tgb.CallbackQueryUpdate) error { - session := c.SessionManager.Get(ctx) - - if session.FollowsMenu.CurrentPage > 0 { - session.FollowsMenu.CurrentPage-- - } - - keyboard, err := c.newKeyboard(ctx, followsMaxRows, followsPerRow) - if err != nil { - zap.S().Error(err) - } - - return msg.Client. - EditMessageReplyMarkup(msg.Message.Chat.ID, msg.Message.ID). - ReplyMarkup(*keyboard). - DoVoid(ctx) -} - -func (c *FollowsCommand) nextPageQuery(ctx context.Context, msg *tgb.CallbackQueryUpdate) error { - session := c.SessionManager.Get(ctx) - - if session.FollowsMenu.CurrentPage+1 <= session.FollowsMenu.TotalPages { - session.FollowsMenu.CurrentPage++ - } - - keyboard, err := c.newKeyboard(ctx, followsMaxRows, followsPerRow) - if err != nil { - zap.S().Error(err) - } - - return msg.Client. - EditMessageReplyMarkup(msg.Message.Chat.ID, msg.Message.ID). - ReplyMarkup(*keyboard). - DoVoid(ctx) -} - -var ( - followsCommandFilter = tgb.Command( - "follows", - tgb.WithCommandAlias("unfollow"), - ) - followsPrevPageQuery = tgb.TextEqual("channels_unfollow_prev_page") - followsNextPageQuery = tgb.TextEqual("channels_unfollow_next_page") - followUnfollowQuery = tgb.TextHasPrefix("channels_unfollow_") -) - -func NewFollowsCommand(opts *tgtypes.CommandOpts) { - cmd := &FollowsCommand{ - CommandOpts: opts, - } - - messageFilter := []tgb.Filter{ - channelsAdminFilter, - followsCommandFilter, - } - - opts.Router.Message(cmd.HandleCommand, messageFilter...) - opts.Router.ChannelPost(cmd.HandleCommand, messageFilter...) - - opts.Router.CallbackQuery(cmd.prevPageQuery, channelsAdminFilter, followsPrevPageQuery) - opts.Router.CallbackQuery(cmd.nextPageQuery, channelsAdminFilter, followsNextPageQuery) - opts.Router.CallbackQuery(cmd.unfollowQuery, channelsAdminFilter, followUnfollowQuery) -} diff --git a/internal/telegram/commands/follows_test.go b/internal/telegram/commands/follows_test.go deleted file mode 100644 index 68f9293e..00000000 --- a/internal/telegram/commands/follows_test.go +++ /dev/null @@ -1,429 +0,0 @@ -package commands - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "net/url" - "strconv" - "testing" - - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/nicklaw5/helix/v2" - "github.com/samber/lo" - db_models2 "github.com/satont/twitch-notifier/internal/db/db_models" - "github.com/satont/twitch-notifier/internal/telegram/types" - "github.com/satont/twitch-notifier/internal/test_utils" - "github.com/satont/twitch-notifier/internal/types" - i18nmocks "github.com/satont/twitch-notifier/pkg/i18n/mocks" - - "github.com/google/uuid" - "github.com/satont/twitch-notifier/internal/test_utils/mocks" - "github.com/stretchr/testify/assert" -) - -func TestFollowsCommand_handleUnfollow(t *testing.T) { - t.Parallel() - - type fields struct { - CommandOpts *tg_types.CommandOpts - } - type args struct { - ctx context.Context - chat *db_models2.Chat - input string - } - - // mockedTwitch := &twitch.MockedService{} - channelsMock := &mocks.DbChannelMock{} - followsMock := &mocks.DbFollowMock{} - - ctx := context.Background() - chat := &db_models2.Chat{ - ID: uuid.New(), - ChatID: "1", - } - - commandOpts := &tg_types.CommandOpts{ - Services: &types.Services{ - Channel: channelsMock, - Follow: followsMock, - }, - } - - tests := []struct { - name string - fields fields - args args - wantErr bool - wantedErr error - setupMocks func() - }{ - { - name: "should return error if channel not found", - fields: fields{CommandOpts: commandOpts}, - args: args{ - ctx: ctx, - chat: chat, - input: "channels_unfollow_1", - }, - wantErr: true, - wantedErr: db_models2.ChannelNotFoundError, - setupMocks: func() { - channelsMock. - On("GetByID", ctx, "1", db_models2.ChannelServiceTwitch). - Return((*db_models2.Channel)(nil), db_models2.ChannelNotFoundError) - }, - }, - { - name: "should return error if follow not found", - fields: fields{ - CommandOpts: commandOpts, - }, - args: args{ - ctx: ctx, - chat: chat, - input: "channels_unfollow_1", - }, - wantErr: true, - wantedErr: db_models2.FollowNotFoundError, - setupMocks: func() { - channelId := uuid.New() - channelsMock. - On("GetByID", ctx, "1", db_models2.ChannelServiceTwitch). - Return( - &db_models2.Channel{ - ID: channelId, - ChannelID: "1", - }, nil, - ) - followsMock. - On("GetByChatAndChannel", ctx, channelId, chat.ID). - Return((*db_models2.Follow)(nil), db_models2.FollowNotFoundError) - }, - }, - { - name: "should return nil", - fields: fields{ - CommandOpts: commandOpts, - }, - args: args{ - ctx: ctx, - chat: chat, - input: "channels_unfollow_1", - }, - wantErr: false, - wantedErr: nil, - setupMocks: func() { - channelID := uuid.New() - followID := uuid.New() - channelsMock. - On("GetByID", ctx, "1", db_models2.ChannelServiceTwitch). - Return( - &db_models2.Channel{ - ID: channelID, - ChannelID: "1", - }, nil, - ) - followsMock. - On("GetByChatAndChannel", ctx, channelID, chat.ID). - Return( - &db_models2.Follow{ - ID: followID, - }, nil, - ) - followsMock. - On("Delete", ctx, followID). - Return(nil) - }, - }, - } - for _, tt := range tests { - t.Run( - tt.name, func(t *testing.T) { - c := &FollowsCommand{ - CommandOpts: tt.fields.CommandOpts, - } - - tt.setupMocks() - - err := c.handleUnfollow(tt.args.ctx, tt.args.chat, tt.args.input) - if tt.wantErr { - assert.ErrorIs(t, err, tt.wantedErr) - } - - channelsMock.AssertExpectations(t) - - channelsMock.ExpectedCalls = nil - }, - ) - } -} - -func TestFollowsCommand_HandleCommand(t *testing.T) { - t.Parallel() - - sessionMock := tg_types.NewMockedSessionManager() - followsMock := &mocks.DbFollowMock{} - i18nMock := i18nmocks.NewI18nMock() - - ctx := context.Background() - chat := &db_models2.Chat{ - ID: uuid.New(), - ChatID: "1", - Settings: &db_models2.ChatSettings{ - ChatLanguage: db_models2.ChatLanguageEn, - }, - } - - session := &tg_types.Session{ - Chat: chat, - FollowsMenu: &tg_types.Menu{ - CurrentPage: 5, - TotalPages: 10, - }, - } - - sessionMock.On("Get", ctx).Return(session) - followsMock.On("GetByChatID", ctx, chat.ID, 9, 0).Return([]*db_models2.Follow{}, nil) - followsMock.On("CountByChatID", ctx, chat.ID).Return(1, nil) - i18nMock. - On( - "Translate", - "commands.follows.total", - "en", - map[string]string{"count": "1"}, - ).Return("Total: 1") - - commandOpts := &tg_types.CommandOpts{ - Services: &types.Services{ - Follow: followsMock, - I18N: i18nMock, - }, - SessionManager: sessionMock, - } - - cmd := &FollowsCommand{CommandOpts: commandOpts} - - server := httptest.NewServer( - http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - query, _ := url.ParseQuery(string(body)) - - assert.Greater(t, len(query.Get("text")), 1) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - }, - ), - ) - defer server.Close() - - msg := &tgb.MessageUpdate{ - Client: test_utils.NewTelegramClient(server), - Message: &tg.Message{ - Chat: tg.Chat{ID: 1}, - }, - } - - err := cmd.HandleCommand(ctx, msg) - assert.NoError(t, err) -} - -func TestFollowsCommand_newKeyboard(t *testing.T) { - t.Parallel() - - ctx := context.Background() - - followsMock := &mocks.DbFollowMock{} - sessionsMock := tg_types.NewMockedSessionManager() - twitchMock := &mocks.TwitchApiMock{} - - dbChat := &db_models2.Chat{ - ID: uuid.New(), - ChatID: "1", - Settings: &db_models2.ChatSettings{ - ChatLanguage: db_models2.ChatLanguageEn, - }, - } - - session := &tg_types.Session{ - Chat: dbChat, - FollowsMenu: &tg_types.Menu{ - CurrentPage: 1, - TotalPages: 0, - }, - } - - entityId := uuid.New() - channelId := uuid.New() - - table := []struct { - name string - setupMocks func() - asserts func(t *testing.T, keyboard *tg.InlineKeyboardMarkup) - }{ - { - name: "should return keyboard with 1 page and no next and prev buttons", - setupMocks: func() { - sessionsMock.On("Get", ctx).Return(session) - followsMock.On("GetByChatID", ctx, dbChat.ID, 9, 0). - Return( - []*db_models2.Follow{ - { - ID: entityId, - ChannelID: channelId, - ChatID: dbChat.ID, - Channel: &db_models2.Channel{ - ID: channelId, - ChannelID: "1", - }, - }, - }, nil, - ) - followsMock.On("CountByChatID", ctx, dbChat.ID). - Return(1, nil) - twitchMock.On("GetChannelsByUserIds", []string{"1"}). - Return( - []helix.ChannelInformation{ - {BroadcasterID: "1", BroadcasterName: "Satont"}, - }, nil, - ) - }, - asserts: func(t *testing.T, keyboard *tg.InlineKeyboardMarkup) { - assert.Len(t, keyboard.InlineKeyboard, 1) - assert.Len(t, keyboard.InlineKeyboard[0], 1) - assert.Equal(t, keyboard.InlineKeyboard[0][0].Text, "Satont") - assert.Equal(t, keyboard.InlineKeyboard[0][0].CallbackData, "channels_unfollow_"+channelId.String()) - }, - }, - { - name: "should return keyboard with 2 pages and next buttons", - setupMocks: func() { - sessionsMock.On("Get", ctx).Return(session) - follows := make([]*db_models2.Follow, 0, 20) - for i := 0; i < 20; i++ { - follows = append( - follows, &db_models2.Follow{ - ID: uuid.New(), - ChannelID: uuid.New(), - ChatID: dbChat.ID, - Channel: &db_models2.Channel{ - ID: uuid.New(), - ChannelID: strconv.Itoa(i), - }, - }, - ) - } - followsMock.On("GetByChatID", ctx, dbChat.ID, 9, 0). - Return(follows, nil) - followsMock.On("CountByChatID", ctx, dbChat.ID). - Return(len(follows), nil) - channelsIds := lo.Map( - follows, func(f *db_models2.Follow, _ int) string { - return f.Channel.ChannelID - }, - ) - twitchMock.On("GetChannelsByUserIds", channelsIds). - Return( - lo.Map( - follows, func(item *db_models2.Follow, _ int) helix.ChannelInformation { - return helix.ChannelInformation{ - BroadcasterID: item.Channel.ChannelID, - BroadcasterName: item.Channel.ChannelID, - } - }, - ), nil, - ) - }, - asserts: func(t *testing.T, keyboard *tg.InlineKeyboardMarkup) { - assert.Greater(t, len(keyboard.InlineKeyboard), 2) - assert.Contains( - t, - keyboard.InlineKeyboard[len(keyboard.InlineKeyboard)-1][0].CallbackData, - "channels_unfollow_next_page", - ) - }, - }, - { - name: "should return keyboard with few pages and next and prev buttons", - setupMocks: func() { - session.FollowsMenu.CurrentPage = 3 - sessionsMock.On("Get", ctx).Return(session) - follows := make([]*db_models2.Follow, 0, 15) - for i := 0; i < 15; i++ { - follows = append( - follows, &db_models2.Follow{ - ID: uuid.New(), - ChannelID: uuid.New(), - ChatID: dbChat.ID, - Channel: &db_models2.Channel{ - ID: uuid.New(), - ChannelID: strconv.Itoa(i), - }, - }, - ) - } - followsMock.On("GetByChatID", ctx, dbChat.ID, 9, 18). - Return(follows, nil) - followsMock.On("CountByChatID", ctx, dbChat.ID). - Return(100, nil) - channelsIds := lo.Map( - follows, func(f *db_models2.Follow, _ int) string { - return f.Channel.ChannelID - }, - ) - twitchMock.On("GetChannelsByUserIds", channelsIds). - Return( - lo.Map( - follows, func(item *db_models2.Follow, _ int) helix.ChannelInformation { - return helix.ChannelInformation{ - BroadcasterID: item.Channel.ChannelID, - BroadcasterName: item.Channel.ChannelID, - } - }, - ), nil, - ) - }, - asserts: func(t *testing.T, keyboard *tg.InlineKeyboardMarkup) { - assert.Greater(t, len(keyboard.InlineKeyboard), 2) - latestRow := keyboard.InlineKeyboard[len(keyboard.InlineKeyboard)-1] - assert.Equal(t, latestRow[0].CallbackData, "channels_unfollow_prev_page") - assert.Equal(t, latestRow[1].CallbackData, "channels_unfollow_next_page") - }, - }, - } - - for _, tt := range table { - t.Run( - tt.name, func(t *testing.T) { - tt.setupMocks() - - cmd := &FollowsCommand{ - CommandOpts: &tg_types.CommandOpts{ - Services: &types.Services{ - Follow: followsMock, - Twitch: twitchMock, - }, - SessionManager: sessionsMock, - }, - } - - keyboard, err := cmd.newKeyboard(ctx, followsMaxRows, followsPerRow) - assert.NoError(t, err) - tt.asserts(t, keyboard) - - sessionsMock.AssertExpectations(t) - followsMock.AssertExpectations(t) - twitchMock.AssertExpectations(t) - - sessionsMock.ExpectedCalls = nil - followsMock.ExpectedCalls = nil - twitchMock.ExpectedCalls = nil - }, - ) - } -} diff --git a/internal/telegram/commands/language_picker.go b/internal/telegram/commands/language_picker.go deleted file mode 100644 index bd5c1422..00000000 --- a/internal/telegram/commands/language_picker.go +++ /dev/null @@ -1,108 +0,0 @@ -package commands - -import ( - "context" - "errors" - "fmt" - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/satont/twitch-notifier/internal/db" - "github.com/satont/twitch-notifier/internal/db/db_models" - tgtypes "github.com/satont/twitch-notifier/internal/telegram/types" - "go.uber.org/zap" - "strings" -) - -type LanguagePicker struct { - *tgtypes.CommandOpts -} - -func (c *LanguagePicker) buildKeyboard() (*tg.InlineKeyboardMarkup, error) { - layout := tg.NewButtonLayout[tg.InlineKeyboardButton](1) - - codes := c.Services.I18N.GetLanguagesCodes() - - for _, code := range codes { - layout.Add( - tg.NewInlineKeyboardButtonCallback( - fmt.Sprintf( - "%s %s", - c.Services.I18N.Translate("language.emoji", code, nil), - c.Services.I18N.Translate("language.name", code, nil), - ), - "language_picker_set_"+code, - ), - ) - } - - layout.Add(tg.NewInlineKeyboardButtonCallback("«", "start_command_menu")) - - markup := tg.NewInlineKeyboardMarkup(layout.Keyboard()...) - - return &markup, nil -} - -func (c *LanguagePicker) HandleCallback(ctx context.Context, msg *tgb.CallbackQueryUpdate) error { - keyboard, err := c.buildKeyboard() - if err != nil { - return msg.Answer().Text("internal error").DoVoid(ctx) - } - - return msg.Client. - EditMessageReplyMarkup(msg.Message.Chat.ID, msg.Message.ID). - ReplyMarkup(*keyboard). - DoVoid(ctx) -} - -func (c *LanguagePicker) handleSetLanguage(ctx context.Context, msg *tgb.CallbackQueryUpdate) error { - chat := c.SessionManager.Get(ctx).Chat - if chat == nil { - return errors.New("no chat") - } - - lang := db_models.ChatLanguage( - strings.TrimPrefix(msg.CallbackQuery.Data, "language_picker_set_"), - ) - if !db_models.LanguageExists(lang) { - return errors.New("language not exists") - } - - _, err := c.Services.Chat.Update( - ctx, - msg.Message.Chat.ID.PeerID(), - db_models.ChatServiceTelegram, - &db.ChatUpdateQuery{ - Settings: &db.ChatUpdateSettingsQuery{ - ChatLanguage: &lang, - }, - }, - ) - if err != nil { - zap.S().Error(err) - return err - } - - chat.Settings.ChatLanguage = lang - - err = msg. - Answer(). - Text(c.Services.I18N.Translate("language.changed", lang.String(), nil)). - DoVoid(ctx) - if err != nil { - zap.S().Error(err) - return err - } - - return nil -} - -func NewLanguagePicker(opts *tgtypes.CommandOpts) { - picker := &LanguagePicker{opts} - - opts.Router.CallbackQuery(picker.HandleCallback, channelsAdminFilter, tgb.TextEqual("language_picker")) - opts.Router.CallbackQuery( - picker.handleSetLanguage, - channelsAdminFilter, - tgb.TextHasPrefix("language_picker_set_"), - ) -} diff --git a/internal/telegram/commands/language_picker_test.go b/internal/telegram/commands/language_picker_test.go deleted file mode 100644 index f134383b..00000000 --- a/internal/telegram/commands/language_picker_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package commands - -import ( - "context" - "fmt" - "github.com/satont/twitch-notifier/internal/db" - "github.com/satont/twitch-notifier/internal/db/db_models" - "github.com/satont/twitch-notifier/internal/telegram/types" - "github.com/satont/twitch-notifier/internal/types" - "io" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/google/uuid" - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/satont/twitch-notifier/internal/test_utils" - "github.com/satont/twitch-notifier/internal/test_utils/mocks" - i18nmocks "github.com/satont/twitch-notifier/pkg/i18n/mocks" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" -) - -//func TestLanguagePicker_buildKeyboard(t *testing.T) { -// t.Parallel() -// -// i18nMock := i18n.NewI18nMock() -// -// cmd := &LanguagePicker{ -// CommandOpts: &tgtypes.CommandOpts{ -// Services: &types.Services{ -// I18N: i18nMock, -// }, -// }, -// } -// -// i18nMock.On("GetLanguagesCodes").Return([]string{"en", "ru"}) -// -// englishFlag := "🇬🇧" -// englishName := "English" -// -// russianFlag := "🇷🇺" -// russianName := "Русский" -// -// i18nMock. -// On("Translate", "language.emoji", "en", map[string]string(nil)). -// Return(englishFlag) -// i18nMock. -// On("Translate", "language.name", "en", map[string]string(nil)). -// Return(englishName) -// -// i18nMock. -// On("Translate", "language.emoji", "ru", map[string]string(nil)). -// Return(russianFlag) -// i18nMock. -// On("Translate", "language.name", "ru", map[string]string(nil)). -// Return(russianName) -// -// keyboard, err := cmd.buildKeyboard() -// assert.NoError(t, err) -// -// assert.Equal(t, -// fmt.Sprintf("%s %s", englishFlag, englishName), -// keyboard.InlineKeyboard[0][0].Text, -// ) -// assert.Equal(t, -// "language_picker_set_en", -// keyboard.InlineKeyboard[0][0].CallbackData, -// ) -// -// assert.Equal(t, -// fmt.Sprintf("%s %s", russianFlag, russianName), -// keyboard.InlineKeyboard[1][0].Text, -// ) -// assert.Equal(t, -// "language_picker_set_ru", -// keyboard.InlineKeyboard[1][0].CallbackData, -// ) -// -// assert.Equal(t, -// "«", -// keyboard.InlineKeyboard[2][0].Text, -// ) -// assert.Equal(t, "start_command_menu", keyboard.InlineKeyboard[2][0].CallbackData) -// -// i18nMock.AssertExpectations(t) -//} - -func TestLanguagePicker_HandleCallback(t *testing.T) { - t.Parallel() - - ctx := context.Background() - - i18nMock := i18nmocks.NewI18nMock() - i18nMock.On("GetLanguagesCodes").Return([]string{"en"}) - - englishFlag := "🇬🇧" - englishName := "English" - - i18nMock. - On("Translate", "language.emoji", "en", map[string]string(nil)). - Return(englishFlag) - i18nMock. - On("Translate", "language.name", "en", map[string]string(nil)). - Return(englishName) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - query, err := url.ParseQuery(string(body)) - assert.NoError(t, err) - - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal( - t, - fmt.Sprintf("/bot%s/editMessageReplyMarkup", test_utils.TelegramClientToken), - r.URL.Path, - ) - assert.NotEmpty(t, query.Get("reply_markup")) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - })) - - cmd := &LanguagePicker{ - CommandOpts: &tg_types.CommandOpts{ - Services: &types.Services{ - I18N: i18nMock, - }, - }, - } - - err := cmd.HandleCallback(ctx, &tgb.CallbackQueryUpdate{ - Client: test_utils.NewTelegramClient(server), - CallbackQuery: &tg.CallbackQuery{ - Message: &tg.Message{ - ID: 1, - Chat: tg.Chat{ - ID: tg.ChatID(1), - }, - }, - }, - }) - assert.NoError(t, err) -} - -func TestLanguagePicker_handleSetLanguage(t *testing.T) { - t.Parallel() - - ctx := context.Background() - - chat := &db_models.Chat{ - ID: uuid.New(), - ChatID: "1", - Settings: &db_models.ChatSettings{ - ChatLanguage: db_models.ChatLanguageEn, - }, - } - - sessionMock := tg_types.NewMockedSessionManager() - sessionMock.On("Get", ctx).Return(&tg_types.Session{ - Chat: chat, - }) - - i18nMock := i18nmocks.NewI18nMock() - i18nMock. - On("Translate", "language.changed", "ru", map[string]string(nil)). - Return("Now russian") - - chatService := &mocks.DbChatMock{} - chatService. - On("Update", ctx, "1", db_models.ChatServiceTelegram, mock.IsType(&db.ChatUpdateQuery{})). - Return((*db_models.Chat)(nil), nil) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal( - t, - fmt.Sprintf("/bot%s/answerCallbackQuery", test_utils.TelegramClientToken), - r.URL.Path, - ) - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - })) - - cmd := &LanguagePicker{ - CommandOpts: &tg_types.CommandOpts{ - SessionManager: sessionMock, - Services: &types.Services{ - I18N: i18nMock, - Chat: chatService, - }, - }, - } - - err := cmd.handleSetLanguage(ctx, &tgb.CallbackQueryUpdate{ - Client: test_utils.NewTelegramClient(server), - CallbackQuery: &tg.CallbackQuery{ - Message: &tg.Message{ - ID: 1, - Chat: tg.Chat{ - ID: tg.ChatID(1), - }, - }, - Data: "language_picker_set_ru", - }, - }) - assert.NoError(t, err) - - assert.Equal(t, db_models.ChatLanguageRu, chat.Settings.ChatLanguage) - - sessionMock.AssertExpectations(t) - chatService.AssertExpectations(t) -} diff --git a/internal/telegram/commands/live.go b/internal/telegram/commands/live.go deleted file mode 100644 index 5de5cf2a..00000000 --- a/internal/telegram/commands/live.go +++ /dev/null @@ -1,152 +0,0 @@ -package commands - -import ( - "context" - "fmt" - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/samber/lo" - "github.com/satont/twitch-notifier/internal/db/db_models" - tgtypes "github.com/satont/twitch-notifier/internal/telegram/types" - "go.uber.org/zap" - "strings" - "time" -) - -type LiveCommand struct { - *tgtypes.CommandOpts -} - -type liveChannel struct { - Name string - Login string - StartedAt time.Time - Title string - Category string - Viewers int -} - -func (c *LiveCommand) getList(ctx context.Context) ([]*liveChannel, error) { - chat := c.SessionManager.Get(ctx).Chat - - follows, err := c.Services.Follow.GetByChatID(ctx, chat.ID, 0, 0) - if err != nil { - return nil, err - } - - if len(follows) == 0 { - return nil, nil - } - - channelsIds := lo.Map(follows, func(follow *db_models.Follow, _ int) string { - return follow.Channel.ChannelID - }) - - streams, err := c.Services.Twitch.GetStreamsByUserIds(channelsIds) - if err != nil { - return nil, err - } - - if len(streams) == 0 { - return nil, nil - } - - result := make([]*liveChannel, 0, len(streams)) - - for _, stream := range streams { - result = append(result, &liveChannel{ - Name: stream.UserName, - Login: stream.UserLogin, - StartedAt: stream.StartedAt, - Title: stream.Title, - Category: stream.GameName, - Viewers: stream.ViewerCount, - }) - } - - return result, nil -} - -func (c *LiveCommand) HandleCommand(ctx context.Context, msg *tgb.MessageUpdate) error { - list, err := c.getList(ctx) - if err != nil { - zap.S().Error(err) - return msg.Answer("internal error").DoVoid(ctx) - } - - if len(list) == 0 { - return msg.Answer("No one online").DoVoid(ctx) - } - - message := make([]string, 0, len(list)) - - for _, channel := range list { - channelMessage := make([]string, 0) - - channelMessage = append( - channelMessage, - fmt.Sprintf( - "🟢 %s - %v 👁️️", - tg.MD.Link( - channel.Name, - fmt.Sprintf("https://twitch.tv/%s", channel.Login), - ), - channel.Viewers, - ), - ) - - if channel.Category != "" { - channelMessage = append(channelMessage, fmt.Sprintf("🎮 %s", channel.Category)) - } - - if channel.Title != "" { - channelMessage = append(channelMessage, fmt.Sprintf("📝 %s", channel.Title)) - } - - since := time.Since(channel.StartedAt) - hour := int(since.Seconds() / 3600) - minute := int(since.Seconds()/60) % 60 - second := int(since.Seconds()) % 60 - - uptime := "⌛ " - if hour > 0 { - uptime += fmt.Sprintf("%vh ", hour) - } - - if minute > 0 { - uptime += fmt.Sprintf("%vm ", minute) - } - - if second > 0 { - uptime += fmt.Sprintf("%vs ", second) - } - - channelMessage = append(channelMessage, uptime) - - message = append( - message, - strings.Join(channelMessage, "\n"), - ) - } - - return msg. - Answer(strings.Join(message, "\n\n")). - ParseMode(tg.MD). - DisableWebPagePreview(true). - DoVoid(ctx) -} - -var liveCommandFilter = tgb.Command("live") - -func NewLiveCommand(opts *tgtypes.CommandOpts) { - cmd := &LiveCommand{ - CommandOpts: opts, - } - - messageFilter := []tgb.Filter{ - channelsAdminFilter, - liveCommandFilter, - } - - opts.Router.Message(cmd.HandleCommand, messageFilter...) -} diff --git a/internal/telegram/commands/live_test.go b/internal/telegram/commands/live_test.go deleted file mode 100644 index 15355695..00000000 --- a/internal/telegram/commands/live_test.go +++ /dev/null @@ -1,273 +0,0 @@ -package commands - -import ( - "context" - "fmt" - db_models2 "github.com/satont/twitch-notifier/internal/db/db_models" - tg_types2 "github.com/satont/twitch-notifier/internal/telegram/types" - "github.com/satont/twitch-notifier/internal/types" - "io" - "net/http" - "net/http/httptest" - "net/url" - "testing" - "time" - - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/satont/twitch-notifier/internal/test_utils" - "github.com/satont/twitch-notifier/internal/test_utils/mocks" - - "github.com/google/uuid" - "github.com/nicklaw5/helix/v2" - "github.com/stretchr/testify/assert" -) - -func TestLiveCommand_GetList(t *testing.T) { - t.Parallel() - - chat := &db_models2.Chat{ - ID: uuid.New(), - ChatID: "1", - } - - ctx := context.Background() - - sessionManager := tg_types2.NewMockedSessionManager() - sessionManager.On("Get", ctx).Return(&tg_types2.Session{ - Chat: chat, - }) - - followMock := &mocks.DbFollowMock{} - twitchMock := &mocks.TwitchApiMock{} - - var now = func() time.Time { - return time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) - } - - follows := []*db_models2.Follow{ - { - ID: uuid.UUID{}, - ChannelID: uuid.UUID{}, - ChatID: uuid.UUID{}, - Channel: &db_models2.Channel{ - ChannelID: "1", - }, - Chat: nil, - }, - { - ID: uuid.UUID{}, - ChannelID: uuid.UUID{}, - ChatID: uuid.UUID{}, - Channel: &db_models2.Channel{ - ChannelID: "2", - }, - Chat: nil, - }, - } - - table := []struct { - name string - setupMocks func() - wantErr bool - wants any - }{ - { - name: "Should return empty list if no follows", - setupMocks: func() { - followMock.On("GetByChatID", ctx, chat.ID, 0, 0).Return([]*db_models2.Follow{}, nil) - }, - wantErr: false, - wants: []*liveChannel(nil), - }, - { - name: "Should return empty list if no channels online", - setupMocks: func() { - followMock.On("GetByChatID", ctx, chat.ID, 0, 0). - Return(follows, nil) - twitchMock. - On( - "GetStreamsByUserIds", - []string{"1", "2"}, - ).Return([]helix.Stream{}, nil) - }, - wantErr: false, - wants: []*liveChannel(nil), - }, - { - name: "Should return one channel", - setupMocks: func() { - followMock.On("GetByChatID", ctx, chat.ID, 0, 0). - Return(follows, nil) - twitchMock. - On( - "GetStreamsByUserIds", - []string{"1", "2"}, - ).Return([]helix.Stream{ - { - UserID: "1", - UserLogin: "satont", - UserName: "Satont", - GameName: "Dota 2", - Title: "Playing dota", - StartedAt: now(), - }, - }, nil) - }, - wantErr: false, - wants: []*liveChannel{ - { - Name: "Satont", - Login: "satont", - StartedAt: now(), - Title: "Playing dota", - Category: "Dota 2", - }, - }, - }, - } - - for _, tt := range table { - tt := tt - t.Run(tt.name, func(t *testing.T) { - tt.setupMocks() - - command := &LiveCommand{ - CommandOpts: &tg_types2.CommandOpts{ - SessionManager: sessionManager, - Services: &types.Services{ - Follow: followMock, - Twitch: twitchMock, - }, - }, - } - - list, err := command.getList(ctx) - assert.NoError(t, err) - assert.Equal(t, tt.wants, list) - - followMock.AssertExpectations(t) - twitchMock.AssertExpectations(t) - - followMock.ExpectedCalls = nil - twitchMock.ExpectedCalls = nil - }) - } -} - -func TestLiveCommand_HandleCommand(t *testing.T) { - t.Parallel() - - chat := &db_models2.Chat{ - ID: uuid.New(), - ChatID: "1", - } - - ctx := context.Background() - - sessionMock := tg_types2.NewMockedSessionManager() - followMock := &mocks.DbFollowMock{} - twitchMock := &mocks.TwitchApiMock{} - - var now = func() time.Time { - return time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) - } - - follows := []*db_models2.Follow{ - { - ID: uuid.UUID{}, - ChannelID: uuid.UUID{}, - ChatID: uuid.UUID{}, - Channel: &db_models2.Channel{ - ChannelID: "1", - }, - Chat: nil, - }, - { - ID: uuid.UUID{}, - ChannelID: uuid.UUID{}, - ChatID: uuid.UUID{}, - Channel: &db_models2.Channel{ - ChannelID: "2", - }, - Chat: nil, - }, - } - - sessionMock.On("Get", ctx).Return(&tg_types2.Session{ - Chat: chat, - }) - followMock.On("GetByChatID", ctx, chat.ID, 0, 0). - Return(follows, nil) - twitchMock. - On( - "GetStreamsByUserIds", - []string{"1", "2"}, - ).Return([]helix.Stream{ - { - UserID: "1", - UserLogin: "satont", - UserName: "Satont", - GameName: "Dota 2", - Title: "Playing dota", - StartedAt: now(), - }, - { - UserID: "2", - UserLogin: "sadisnamenya", - UserName: "SadisNaMenya", - GameName: "Dota 2", - Title: "Dotka", - StartedAt: now(), - }, - }, nil) - - expectedString1 := "🟢 [Satont](https://twitch.tv/satont) - 0 👁️️\n🎮 Dota 2\n📝 Playing dota\n⌛" - expectedString2 := "🟢 [SadisNaMenya](https://twitch.tv/sadisnamenya) - 0 👁️️\n🎮 Dota 2\n📝 Dotka\n" - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - query, err := url.ParseQuery(string(body)) - assert.NoError(t, err) - - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal( - t, - fmt.Sprintf("/bot%s/sendMessage", test_utils.TelegramClientToken), - r.URL.Path, - ) - assert.Contains(t, query.Get("text"), expectedString1) - assert.Contains(t, query.Get("text"), expectedString2) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - })) - defer server.Close() - - telegramClient := test_utils.NewTelegramClient(server) - - cmd := &LiveCommand{ - CommandOpts: &tg_types2.CommandOpts{ - SessionManager: sessionMock, - Services: &types.Services{ - Follow: followMock, - Twitch: twitchMock, - }, - }, - } - - err := cmd.HandleCommand(ctx, &tgb.MessageUpdate{ - Client: telegramClient, - Message: &tg.Message{ - Chat: tg.Chat{ - ID: 1, - }, - }, - }) - assert.NoError(t, err) - - sessionMock.AssertExpectations(t) - followMock.AssertExpectations(t) - twitchMock.AssertExpectations(t) -} diff --git a/internal/telegram/commands/start.go b/internal/telegram/commands/start.go deleted file mode 100644 index b14714e5..00000000 --- a/internal/telegram/commands/start.go +++ /dev/null @@ -1,346 +0,0 @@ -package commands - -import ( - "context" - "fmt" - - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/satont/twitch-notifier/internal/db" - "github.com/satont/twitch-notifier/internal/db/db_models" - tg_types "github.com/satont/twitch-notifier/internal/telegram/types" - "go.uber.org/zap" -) - -type StartCommand struct { - *tg_types.CommandOpts -} - -func (c *StartCommand) createCheckMark(value bool) string { - if value { - return "✅" - } - - return "❌" -} - -func (c *StartCommand) buildKeyboard(ctx context.Context) *tg.InlineKeyboardMarkup { - chat := c.SessionManager.Get(ctx).Chat - - layout := tg.NewButtonLayout[tg.InlineKeyboardButton](1) - - gameChangeNotificationsButton := c.Services.I18N.Translate( - "commands.start.game_change_notification_setting.button", - chat.Settings.ChatLanguage.String(), - nil, - ) - offlineNotificationsButton := c.Services.I18N.Translate( - "commands.start.offline_notification.button", - chat.Settings.ChatLanguage.String(), - nil, - ) - - titleChangeNotificationsButton := c.Services.I18N.Translate( - "commands.start.title_change_notification_setting.button", - chat.Settings.ChatLanguage.String(), - nil, - ) - - gameAndTitleChangeNotificationsButton := c.Services.I18N.Translate( - "commands.start.game_and_title_change_notification_setting.button", - chat.Settings.ChatLanguage.String(), - nil, - ) - - imageInNotificationButton := c.Services.I18N.Translate( - "commands.start.image_in_notification_setting.button", - chat.Settings.ChatLanguage.String(), - nil, - ) - - layout.Add( - tg.NewInlineKeyboardButtonCallback( - fmt.Sprintf( - "%s %s", - c.createCheckMark(chat.Settings.GameChangeNotification), - gameChangeNotificationsButton, - ), - "start_game_change_notification_setting", - ), - tg.NewInlineKeyboardButtonCallback( - fmt.Sprintf( - "%s %s", - c.createCheckMark(chat.Settings.OfflineNotification), - offlineNotificationsButton, - ), - "start_offline_notification", - ), - tg.NewInlineKeyboardButtonCallback( - fmt.Sprintf( - "%s %s", - c.createCheckMark(chat.Settings.TitleChangeNotification), - titleChangeNotificationsButton, - ), - "start_title_change_notification_setting", - ), - tg.NewInlineKeyboardButtonCallback( - fmt.Sprintf( - "%s %s", - c.createCheckMark(chat.Settings.GameAndTitleChangeNotification), - gameAndTitleChangeNotificationsButton, - ), - "start_game_and_title_change_notification_setting", - ), - tg.NewInlineKeyboardButtonCallback( - fmt.Sprintf( - "%s %s", - c.createCheckMark(chat.Settings.ImageInNotification), - imageInNotificationButton, - ), - "image_in_notification_setting", - ), - tg.NewInlineKeyboardButtonCallback( - c.Services.I18N.Translate( - "commands.start.language.button", - chat.Settings.ChatLanguage.String(), - nil, - ), - "language_picker", - ), - tg.NewInlineKeyboardButtonURL("Github", "https://github.com/Satont/twitch-notifier"), - ) - - markup := tg.NewInlineKeyboardMarkup(layout.Keyboard()...) - - return &markup -} - -func (c *StartCommand) HandleCommand(ctx context.Context, msg *tgb.MessageUpdate) error { - session := c.SessionManager.Get(ctx) - - keyBoard := c.buildKeyboard(ctx) - - description := c.Services.I18N.Translate( - "bot.description", - session.Chat.Settings.ChatLanguage.String(), - nil, - ) - - return msg.Answer(description).ReplyMarkup(keyBoard).DoVoid(ctx) -} - -func (c *StartCommand) handleCallback(ctx context.Context, msg *tgb.CallbackQueryUpdate) error { - keyboard := c.buildKeyboard(ctx) - - return msg.Client. - EditMessageReplyMarkup(msg.Message.Chat.ID, msg.Message.ID). - ReplyMarkup(*keyboard). - DoVoid(ctx) -} - -func (c *StartCommand) handleImageInNotificationSettings( - ctx context.Context, - msg *tgb.CallbackQueryUpdate, -) error { - chat := c.SessionManager.Get(ctx).Chat - chat.Settings.ImageInNotification = !chat.Settings.ImageInNotification - - _, err := c.Services.Chat.Update( - ctx, - chat.ChatID, - db_models.ChatServiceTelegram, - &db.ChatUpdateQuery{ - Settings: &db.ChatUpdateSettingsQuery{ - ImageInNotification: &chat.Settings.ImageInNotification, - }, - }) - if err != nil { - zap.S().Error(err) - return msg.Answer().Text("internal error").DoVoid(ctx) - } - - keyboard := c.buildKeyboard(ctx) - - return msg.Client. - EditMessageReplyMarkup(msg.Message.Chat.ID, msg.Message.ID). - ReplyMarkup(*keyboard). - DoVoid(ctx) -} - -func (c *StartCommand) handleTitleNotificationSettings( - ctx context.Context, - msg *tgb.CallbackQueryUpdate, -) error { - chat := c.SessionManager.Get(ctx).Chat - chat.Settings.TitleChangeNotification = !chat.Settings.TitleChangeNotification - - _, err := c.Services.Chat.Update( - ctx, - chat.ChatID, - db_models.ChatServiceTelegram, - &db.ChatUpdateQuery{ - Settings: &db.ChatUpdateSettingsQuery{ - TitleChangeNotification: &chat.Settings.TitleChangeNotification, - }, - }, - ) - - if err != nil { - zap.S().Error(err) - return msg.Answer().Text("internal error").DoVoid(ctx) - } - - keyboard := c.buildKeyboard(ctx) - - return msg.Client. - EditMessageReplyMarkup(msg.Message.Chat.ID, msg.Message.ID). - ReplyMarkup(*keyboard). - DoVoid(ctx) -} - -func (c *StartCommand) handleGameNotificationSettings( - ctx context.Context, - msg *tgb.CallbackQueryUpdate, -) error { - chat := c.SessionManager.Get(ctx).Chat - - chat.Settings.GameChangeNotification = !chat.Settings.GameChangeNotification - - _, err := c.Services.Chat.Update( - ctx, - chat.ChatID, - db_models.ChatServiceTelegram, - &db.ChatUpdateQuery{ - Settings: &db.ChatUpdateSettingsQuery{ - GameChangeNotification: &chat.Settings.GameChangeNotification, - }, - }, - ) - if err != nil { - zap.S().Error(err) - return msg.Answer().Text("internal error").DoVoid(ctx) - } - - keyboard := c.buildKeyboard(ctx) - - return msg.Client. - EditMessageReplyMarkup(msg.Message.Chat.ID, msg.Message.ID). - ReplyMarkup(*keyboard). - DoVoid(ctx) -} - -func (c *StartCommand) handleGameAndTitleNotificationSettings( - ctx context.Context, - msg *tgb.CallbackQueryUpdate, -) error { - chat := c.SessionManager.Get(ctx).Chat - chat.Settings.GameAndTitleChangeNotification = !chat.Settings.GameAndTitleChangeNotification - - _, err := c.Services.Chat.Update( - ctx, - chat.ChatID, - db_models.ChatServiceTelegram, - &db.ChatUpdateQuery{ - Settings: &db.ChatUpdateSettingsQuery{ - GameAndTitleChangeNotification: &chat.Settings.GameAndTitleChangeNotification, - }, - }, - ) - if err != nil { - zap.S().Error(err) - return msg.Answer().Text("internal error").DoVoid(ctx) - } - - keyboard := c.buildKeyboard(ctx) - - return msg.Client. - EditMessageReplyMarkup(msg.Message.Chat.ID, msg.Message.ID). - ReplyMarkup(*keyboard). - DoVoid(ctx) -} - -func (c *StartCommand) handleOfflineNotificationSettings( - ctx context.Context, - msg *tgb.CallbackQueryUpdate, -) error { - chat := c.SessionManager.Get(ctx).Chat - - chat.Settings.OfflineNotification = !chat.Settings.OfflineNotification - - _, err := c.Services.Chat.Update( - ctx, - chat.ChatID, - db_models.ChatServiceTelegram, - &db.ChatUpdateQuery{ - Settings: &db.ChatUpdateSettingsQuery{ - OfflineNotification: &chat.Settings.OfflineNotification, - }, - }, - ) - if err != nil { - zap.S().Error(err) - return msg.Answer().Text("internal error").DoVoid(ctx) - } - - keyboard := c.buildKeyboard(ctx) - - return msg.Client. - EditMessageReplyMarkup(msg.Message.Chat.ID, msg.Message.ID). - ReplyMarkup(*keyboard). - DoVoid(ctx) -} - -var ( - startCommandFilter = tgb.Command("start", - tgb.WithCommandAlias("help"), - tgb.WithCommandAlias("info"), - tgb.WithCommandAlias("settings"), - ) - startMenuFilter = tgb.TextEqual("start_command_menu") - gameChangeNotificationSettingFilter = tgb.TextEqual("start_game_change_notification_setting") - offlineNotificationSettingFilter = tgb.TextEqual("start_offline_notification") - titleNotificationSettingFilter = tgb.TextEqual("start_title_change_notification_setting") - gameAndTitleSettingFilter = tgb.TextEqual("start_game_and_title_change_notification_setting") - imageInNotificationSettingFilter = tgb.TextEqual("image_in_notification_setting") -) - -func NewStartCommand(opts *tg_types.CommandOpts) { - cmd := &StartCommand{ - CommandOpts: opts, - } - - messageFilter := []tgb.Filter{ - channelsAdminFilter, - startCommandFilter, - } - - opts.Router.Message(cmd.HandleCommand, messageFilter...) - opts.Router.ChannelPost(cmd.HandleCommand, messageFilter...) - - opts.Router.CallbackQuery(cmd.handleCallback, channelsAdminFilter, startMenuFilter) - opts.Router.CallbackQuery( - cmd.handleGameNotificationSettings, - channelsAdminFilter, - gameChangeNotificationSettingFilter, - ) - opts.Router.CallbackQuery( - cmd.handleOfflineNotificationSettings, - channelsAdminFilter, - offlineNotificationSettingFilter, - ) - opts.Router.CallbackQuery( - cmd.handleTitleNotificationSettings, - channelsAdminFilter, - titleNotificationSettingFilter, - ) - opts.Router.CallbackQuery( - cmd.handleGameAndTitleNotificationSettings, - channelsAdminFilter, - gameAndTitleSettingFilter, - ) - opts.Router.CallbackQuery( - cmd.handleImageInNotificationSettings, - channelsAdminFilter, - imageInNotificationSettingFilter, - ) -} diff --git a/internal/telegram/commands/start_test.go b/internal/telegram/commands/start_test.go deleted file mode 100644 index bf3a1f9a..00000000 --- a/internal/telegram/commands/start_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package commands - -import ( - "context" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/satont/twitch-notifier/internal/db/db_models" - tg_types "github.com/satont/twitch-notifier/internal/telegram/types" - "github.com/satont/twitch-notifier/internal/types" - - "github.com/google/uuid" - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/satont/twitch-notifier/internal/test_utils" - i18nmocks "github.com/satont/twitch-notifier/pkg/i18n/mocks" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" -) - -func TestStartCommand_buildKeyboard(t *testing.T) { - t.Parallel() - - ctx := context.Background() - chat := &db_models.Chat{ - ID: uuid.New(), - ChatID: "1", - Settings: &db_models.ChatSettings{ - ChatLanguage: db_models.ChatLanguageEn, - }, - } - - i18 := i18nmocks.NewI18nMock() - i18. - On("Translate", mock.Anything, mock.Anything, mock.Anything). - Return("") - - sessionManager := tg_types.NewMockedSessionManager() - sessionManager.On("Get", ctx).Return(&tg_types.Session{ - Chat: chat, - }) - - cmd := &StartCommand{ - CommandOpts: &tg_types.CommandOpts{ - SessionManager: sessionManager, - Services: &types.Services{ - I18N: i18, - }, - }, - } - - keyboard := cmd.buildKeyboard(ctx) - - const buttons = 7 - assert.Equal(t, buttons, len(keyboard.InlineKeyboard)) - - assert.Equal( - t, - "start_game_change_notification_setting", - keyboard.InlineKeyboard[0][0].CallbackData, - ) - - assert.Equal( - t, - "start_offline_notification", - keyboard.InlineKeyboard[1][0].CallbackData, - ) - - assert.Equal( - t, - "start_title_change_notification_setting", - keyboard.InlineKeyboard[2][0].CallbackData, - ) - - assert.Equal( - t, - "start_game_and_title_change_notification_setting", - keyboard.InlineKeyboard[3][0].CallbackData, - ) - - assert.Equal( - t, - "image_in_notification_setting", - keyboard.InlineKeyboard[4][0].CallbackData, - ) - - assert.Equal( - t, - "language_picker", - keyboard.InlineKeyboard[5][0].CallbackData, - ) - - assert.Equal(t, "Github", keyboard.InlineKeyboard[6][0].Text) - assert.Equal(t, "https://github.com/Satont/twitch-notifier", keyboard.InlineKeyboard[6][0].URL) - - sessionManager.AssertExpectations(t) - i18.AssertNumberOfCalls(t, "Translate", buttons-1) -} - -func TestStartCommand_HandleCommand(t *testing.T) { - t.Parallel() - - ctx := context.Background() - chat := &db_models.Chat{ - ID: uuid.New(), - Settings: &db_models.ChatSettings{ - ChatLanguage: db_models.ChatLanguageEn, - }, - } - - i18 := i18nmocks.NewI18nMock() - i18. - On("Translate", mock.Anything, mock.Anything, mock.Anything). - Return("start command") - - sessionManager := tg_types.NewMockedSessionManager() - sessionManager.On("Get", ctx).Return(&tg_types.Session{ - Chat: chat, - }) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - query, err := url.ParseQuery(string(body)) - assert.NoError(t, err) - - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal( - t, - fmt.Sprintf("/bot%s/sendMessage", test_utils.TelegramClientToken), - r.URL.Path, - ) - assert.Equal(t, "start command", query.Get("text")) - assert.NotEmpty(t, query.Get("reply_markup")) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(test_utils.TelegramOkResponse)) - })) - - cmd := &StartCommand{ - CommandOpts: &tg_types.CommandOpts{ - SessionManager: sessionManager, - Services: &types.Services{ - I18N: i18, - }, - }, - } - - err := cmd.HandleCommand(ctx, &tgb.MessageUpdate{ - Client: test_utils.NewTelegramClient(server), - Message: &tg.Message{ - Text: "/start", - }, - }) - assert.NoError(t, err) -} - -func TestStartCommand_createCheckMark(t *testing.T) { - t.Parallel() - - cmd := &StartCommand{} - - assert.Equal(t, "✅", cmd.createCheckMark(true)) - assert.Equal(t, "❌", cmd.createCheckMark(false)) -} diff --git a/internal/telegram/middlewares/chat.go b/internal/telegram/middlewares/chat.go deleted file mode 100644 index b6852527..00000000 --- a/internal/telegram/middlewares/chat.go +++ /dev/null @@ -1,37 +0,0 @@ -package middlewares - -import ( - "context" - "fmt" - "github.com/mr-linch/go-tg/tgb" - "github.com/satont/twitch-notifier/internal/db/db_models" - "github.com/satont/twitch-notifier/internal/telegram/types" - "go.uber.org/zap" -) - -type ChatMiddleware struct { - *tg_types.MiddlewareOpts -} - -func (c *ChatMiddleware) Wrap(next tgb.Handler) tgb.Handler { - return tgb.HandlerFunc(func(ctx context.Context, update *tgb.Update) error { - chatId := fmt.Sprintf("%v", update.Chat().ID) - user, err := c.Services.Chat.GetByID(ctx, chatId, db_models.ChatServiceTelegram) - if err != nil { - zap.L().Error("failed to get chat", zap.Error(err)) - return nil - } - - if user == nil { - user, err = c.Services.Chat.Create(ctx, chatId, db_models.ChatServiceTelegram) - if err != nil { - zap.L().Error("failed to create chat", zap.Error(err)) - return nil - } - } - - c.SessionManager.Get(ctx).Chat = user - - return next.Handle(ctx, update) - }) -} diff --git a/internal/telegram/middlewares/logg.go b/internal/telegram/middlewares/logg.go deleted file mode 100644 index a525d52f..00000000 --- a/internal/telegram/middlewares/logg.go +++ /dev/null @@ -1,24 +0,0 @@ -package middlewares - -import ( - "context" - "github.com/mr-linch/go-tg/tgb" - "github.com/satont/twitch-notifier/internal/types" - "go.uber.org/zap" - "time" -) - -type LoggMiddleware struct { - Services *types.Services -} - -func (c *LoggMiddleware) Wrap(next tgb.Handler) tgb.Handler { - return tgb.HandlerFunc(func(ctx context.Context, update *tgb.Update) error { - defer func(started time.Time) { - zap.L(). - Info("update handled", zap.Duration("duration", time.Since(started))) - }(time.Now()) - - return next.Handle(ctx, update) - }) -} diff --git a/internal/telegram/set_commands.go b/internal/telegram/set_commands.go deleted file mode 100644 index 93c4544f..00000000 --- a/internal/telegram/set_commands.go +++ /dev/null @@ -1,58 +0,0 @@ -package telegram - -import ( - "context" - "github.com/mr-linch/go-tg" - "go.uber.org/zap" - "strconv" -) - -var defaultCommands = []tg.BotCommand{ - { - Command: "follow", - Description: "Follow to notifications of some streamer", - }, - { - Command: "follows", - Description: "Show list of followed streamers", - }, - { - Command: "live", - Description: "Show list of live streamers", - }, - { - Command: "start", - Description: "Bot settings", - }, -} - -func (c *TelegramService) setMyCommands(ctx context.Context) { - err := c.Client. - SetMyCommands(defaultCommands). - Scope(tg.BotCommandScopeDefault{}). - DoVoid(ctx) - if err != nil { - zap.S().Fatalln("Can't set default commands", err) - } - - for _, admin := range c.services.Config.TelegramBotAdmins { - newCommands := append(defaultCommands, tg.BotCommand{ - Command: "broadcast", - Description: "Send message to all users", - }) - - chatID, err := strconv.Atoi(admin) - if err != nil { - zap.S().Errorw("Can't parse chat id", "chatID", admin) - return - } - - err = c.Client. - SetMyCommands(newCommands). - Scope(tg.BotCommandScopeChat{ChatID: tg.ChatID(chatID)}). - DoVoid(ctx) - if err != nil { - zap.S().Fatalln("Can't set admin commands", err) - } - } -} diff --git a/internal/telegram/telegram.go b/internal/telegram/telegram.go deleted file mode 100644 index 042fd3e6..00000000 --- a/internal/telegram/telegram.go +++ /dev/null @@ -1,92 +0,0 @@ -package telegram - -import ( - "context" - "github.com/hashicorp/go-retryablehttp" - "github.com/satont/twitch-notifier/internal/telegram/commands" - "github.com/satont/twitch-notifier/internal/telegram/middlewares" - "github.com/satont/twitch-notifier/internal/telegram/types" - "github.com/satont/twitch-notifier/internal/types" - "time" - - "github.com/mr-linch/go-tg" - "github.com/mr-linch/go-tg/tgb" - "github.com/mr-linch/go-tg/tgb/session" - "go.uber.org/zap" -) - -type TelegramService struct { - services *types.Services - poller *tgb.Poller - Client *tg.Client -} - -func NewTelegram(ctx context.Context, token string, services *types.Services) *TelegramService { - retryClient := retryablehttp.NewClient() - retryClient.RetryMax = 3 - retryClient.RetryWaitMax = 3600 * time.Second - retryClient.RetryWaitMin = 50 * time.Millisecond - retryClient.Logger = nil - - httpClient := retryClient.StandardClient() - - client := tg.New(token, tg.WithClientDoer(httpClient)) - - var sessionManager = session.NewManager(tg_types.Session{ - FollowsMenu: &tg_types.Menu{}, - Scene: "", - }) - - router := tgb.NewRouter(). - Use(sessionManager). - //Use(&middlewares.LoggMiddleware{ - // Services: services, - //}). - Use(&middlewares.ChatMiddleware{ - MiddlewareOpts: &tg_types.MiddlewareOpts{ - Services: services, - SessionManager: sessionManager, - }}) - - commandOpts := &tg_types.CommandOpts{ - Services: services, - Router: router, - SessionManager: sessionManager, - } - - router.Message(func(ctx context.Context, update *tgb.MessageUpdate) error { - sessionManager.Get(ctx).Scene = "" - return nil - }, tgb.Command("cancel")) - - commands.NewStartCommand(commandOpts) - commands.NewFollowCommand(commandOpts) - commands.NewFollowsCommand(commandOpts) - commands.NewLiveCommand(commandOpts) - commands.NewBroadcastCommand(commandOpts) - commands.NewLanguagePicker(commandOpts) - commands.NewChangeChannelId(commandOpts) - - poller := tgb.NewPoller(router, client) - - me, err := client.GetMe().Do(ctx) - if err != nil { - zap.S().Fatalw("failed to get bot info", "err", err) - } - - service := &TelegramService{ - poller: poller, - services: services, - Client: client, - } - - service.setMyCommands(ctx) - - zap.S().Infow("Telegram bot started", "id", me.ID, "username", me.Username) - - return service -} - -func (c *TelegramService) StartPolling(ctx context.Context) { - go c.poller.Run(ctx) -} diff --git a/internal/telegram/types/mocked_session.go b/internal/telegram/types/mocked_session.go deleted file mode 100644 index 1d5b07d8..00000000 --- a/internal/telegram/types/mocked_session.go +++ /dev/null @@ -1,48 +0,0 @@ -package tg_types - -import ( - "context" - - "github.com/mr-linch/go-tg/tgb" - "github.com/mr-linch/go-tg/tgb/session" - "github.com/stretchr/testify/mock" -) - -type MockedSessionManager[T Session] struct { - mock.Mock -} - -func (m *MockedSessionManager[T]) SetEqualFunc(fn func(t T, t2 T) bool) { - //TODO implement me - panic("implement me") -} - -func (m *MockedSessionManager[T]) Setup(opt session.ManagerOption, opts ...session.ManagerOption) { - //TODO implement me - panic("implement me") -} - -func (m *MockedSessionManager[T]) Get(ctx context.Context) *T { - args := m.Called(ctx) - - return args.Get(0).(*T) -} - -func (m *MockedSessionManager[T]) Reset(session *T) { - //TODO implement me - panic("implement me") -} - -func (m *MockedSessionManager[T]) Filter(fn func(t *T) bool) tgb.Filter { - //TODO implement me - panic("implement me") -} - -func (m *MockedSessionManager[T]) Wrap(next tgb.Handler) tgb.Handler { - //TODO implement me - panic("implement me") -} - -func NewMockedSessionManager() *MockedSessionManager[Session] { - return &MockedSessionManager[Session]{} -} diff --git a/internal/telegram/types/router.go b/internal/telegram/types/router.go deleted file mode 100644 index 6e57a30d..00000000 --- a/internal/telegram/types/router.go +++ /dev/null @@ -1,141 +0,0 @@ -package tg_types - -import ( - "context" - - "github.com/mr-linch/go-tg/tgb" - "github.com/stretchr/testify/mock" -) - -type Router interface { - Use(mws ...tgb.Middleware) *tgb.Router - Message(handler tgb.MessageHandler, filters ...tgb.Filter) *tgb.Router - EditedMessage(handler tgb.MessageHandler, filters ...tgb.Filter) *tgb.Router - ChannelPost(handler tgb.MessageHandler, filters ...tgb.Filter) *tgb.Router - EditedChannelPost(handler tgb.MessageHandler, filters ...tgb.Filter) *tgb.Router - InlineQuery(handler tgb.InlineQueryHandler, filters ...tgb.Filter) *tgb.Router - ChosenInlineResult(handler tgb.ChosenInlineResultHandler, filters ...tgb.Filter) *tgb.Router - CallbackQuery(handler tgb.CallbackQueryHandler, filters ...tgb.Filter) *tgb.Router - ShippingQuery(handler tgb.ShippingQueryHandler, filters ...tgb.Filter) *tgb.Router - PreCheckoutQuery(handler tgb.PreCheckoutQueryHandler, filters ...tgb.Filter) *tgb.Router - Poll(handler tgb.PollHandler, filters ...tgb.Filter) *tgb.Router - PollAnswer(handler tgb.PollAnswerHandler, filters ...tgb.Filter) *tgb.Router - MyChatMember(handler tgb.ChatMemberUpdatedHandler, filters ...tgb.Filter) *tgb.Router - ChatMember(handler tgb.ChatMemberUpdatedHandler, filters ...tgb.Filter) *tgb.Router - ChatJoinRequest(handler tgb.ChatJoinRequestHandler, filters ...tgb.Filter) *tgb.Router - Error(handler tgb.ErrorHandler) *tgb.Router - Update(handler tgb.HandlerFunc, filters ...tgb.Filter) *tgb.Router - Handle(ctx context.Context, update *tgb.Update) error -} - -type MockedRouter struct { - mock.Mock -} - -func (m *MockedRouter) Use(mws ...tgb.Middleware) *tgb.Router { - args := m.Called(mws) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) Message(handler tgb.MessageHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) EditedMessage(handler tgb.MessageHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) ChannelPost(handler tgb.MessageHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) EditedChannelPost(handler tgb.MessageHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) InlineQuery(handler tgb.InlineQueryHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) ChosenInlineResult(handler tgb.ChosenInlineResultHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) CallbackQuery(handler tgb.CallbackQueryHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) ShippingQuery(handler tgb.ShippingQueryHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) PreCheckoutQuery(handler tgb.PreCheckoutQueryHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) Poll(handler tgb.PollHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) PollAnswer(handler tgb.PollAnswerHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) MyChatMember(handler tgb.ChatMemberUpdatedHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) ChatMember(handler tgb.ChatMemberUpdatedHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) ChatJoinRequest(handler tgb.ChatJoinRequestHandler, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) Error(handler tgb.ErrorHandler) *tgb.Router { - args := m.Called(handler) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) Update(handler tgb.HandlerFunc, filters ...tgb.Filter) *tgb.Router { - args := m.Called(handler, filters) - - return args.Get(0).(*tgb.Router) -} - -func (m *MockedRouter) Handle(ctx context.Context, update *tgb.Update) error { - args := m.Called(ctx, update) - - return args.Error(0) -} diff --git a/internal/telegram/types/session.go b/internal/telegram/types/session.go deleted file mode 100644 index bd8f3463..00000000 --- a/internal/telegram/types/session.go +++ /dev/null @@ -1,42 +0,0 @@ -package tg_types - -import ( - "context" - "github.com/satont/twitch-notifier/internal/db/db_models" - "github.com/satont/twitch-notifier/internal/types" - - "github.com/mr-linch/go-tg/tgb" - "github.com/mr-linch/go-tg/tgb/session" -) - -type SessionManager[T comparable] interface { - SetEqualFunc(fn func(t T, t2 T) bool) - Setup(opt session.ManagerOption, opts ...session.ManagerOption) - Get(ctx context.Context) *T - Reset(session *T) - Filter(fn func(t *T) bool) tgb.Filter - Wrap(next tgb.Handler) tgb.Handler -} - -type Menu struct { - CurrentPage int - TotalPages int -} - -type Session struct { - Chat *db_models.Chat - Scene string - - FollowsMenu *Menu -} - -type CommandOpts struct { - Services *types.Services - Router Router - SessionManager SessionManager[Session] -} - -type MiddlewareOpts struct { - Services *types.Services - SessionManager SessionManager[Session] -} diff --git a/internal/test_utils/mocks/db_channel.go b/internal/test_utils/mocks/db_channel.go deleted file mode 100644 index ea2c15cb..00000000 --- a/internal/test_utils/mocks/db_channel.go +++ /dev/null @@ -1,81 +0,0 @@ -package mocks - -import ( - "context" - - "github.com/satont/twitch-notifier/internal/db" - db_models2 "github.com/satont/twitch-notifier/internal/db/db_models" - - "github.com/stretchr/testify/mock" -) - -type DbChannelMock struct { - mock.Mock -} - -func (c *DbChannelMock) GetByID( - ctx context.Context, - id string, - service db_models2.ChannelService, -) (*db_models2.Channel, error) { - args := c.Called(ctx, id, service) - - return args.Get(0).(*db_models2.Channel), args.Error(1) -} - -func (c *DbChannelMock) GetByChannelID( - ctx context.Context, - channelID string, - service db_models2.ChannelService, -) (*db_models2.Channel, error) { - args := c.Called(ctx, channelID, service) - - return args.Get(0).(*db_models2.Channel), args.Error(1) -} - -func (c *DbChannelMock) GetFollowsByID( - ctx context.Context, - channelID string, - service db_models2.ChannelService, -) ([]*db_models2.Follow, error) { - args := c.Called(ctx, channelID, service) - - return args.Get(0).([]*db_models2.Follow), args.Error(1) -} - -func (c *DbChannelMock) Create( - ctx context.Context, - channelID string, - service db_models2.ChannelService, -) (*db_models2.Channel, error) { - args := c.Called(ctx, channelID, service) - - return args.Get(0).(*db_models2.Channel), args.Error(1) -} - -func (c *DbChannelMock) Update( - ctx context.Context, - channelID string, - service db_models2.ChannelService, - updateQuery *db.ChannelUpdateQuery, -) (*db_models2.Channel, error) { - args := c.Called(ctx, channelID, service, updateQuery) - - return args.Get(0).(*db_models2.Channel), args.Error(1) -} - -func (c *DbChannelMock) GetByIdOrCreate( - ctx context.Context, - channelID string, - service db_models2.ChannelService, -) (*db_models2.Channel, error) { - args := c.Called(ctx, channelID, service) - - return args.Get(0).(*db_models2.Channel), args.Error(1) -} - -func (c *DbChannelMock) GetAll(ctx context.Context) ([]*db_models2.Channel, error) { - args := c.Called(ctx) - - return args.Get(0).([]*db_models2.Channel), args.Error(1) -} diff --git a/internal/test_utils/mocks/db_chat.go b/internal/test_utils/mocks/db_chat.go deleted file mode 100644 index 933f6d9f..00000000 --- a/internal/test_utils/mocks/db_chat.go +++ /dev/null @@ -1,53 +0,0 @@ -package mocks - -import ( - "context" - "github.com/satont/twitch-notifier/internal/db" - "github.com/satont/twitch-notifier/internal/db/db_models" - - "github.com/stretchr/testify/mock" -) - -type DbChatMock struct { - mock.Mock -} - -func (c *DbChatMock) GetByID( - ctx context.Context, - chatId string, - service db_models.ChatService, -) (*db_models.Chat, error) { - args := c.Called(ctx, chatId, service) - - return args.Get(0).(*db_models.Chat), args.Error(1) -} - -func (c *DbChatMock) Create( - ctx context.Context, - chatId string, - service db_models.ChatService, -) (*db_models.Chat, error) { - args := c.Called(ctx, chatId, service) - - return args.Get(0).(*db_models.Chat), args.Error(1) -} - -func (c *DbChatMock) Update( - ctx context.Context, - chatId string, - service db_models.ChatService, - query *db.ChatUpdateQuery, -) (*db_models.Chat, error) { - args := c.Called(ctx, chatId, service, query) - - return args.Get(0).(*db_models.Chat), args.Error(1) -} - -func (c *DbChatMock) GetAllByService( - ctx context.Context, - service db_models.ChatService, -) ([]*db_models.Chat, error) { - args := c.Called(ctx, service) - - return args.Get(0).([]*db_models.Chat), args.Error(1) -} diff --git a/internal/test_utils/mocks/db_follow.go b/internal/test_utils/mocks/db_follow.go deleted file mode 100644 index 67ee25c9..00000000 --- a/internal/test_utils/mocks/db_follow.go +++ /dev/null @@ -1,57 +0,0 @@ -package mocks - -import ( - "context" - "github.com/satont/twitch-notifier/internal/db/db_models" - - "github.com/google/uuid" - "github.com/stretchr/testify/mock" -) - -type DbFollowMock struct { - mock.Mock -} - -func (f *DbFollowMock) Create( - ctx context.Context, - channelID uuid.UUID, - chatID uuid.UUID, -) (*db_models.Follow, error) { - args := f.Called(ctx, channelID, chatID) - - return args.Get(0).(*db_models.Follow), args.Error(1) -} - -func (f *DbFollowMock) Delete(ctx context.Context, id uuid.UUID) error { - args := f.Called(ctx, id) - - return args.Error(0) -} - -func (f *DbFollowMock) GetByChatAndChannel( - ctx context.Context, - channelId uuid.UUID, - chatId uuid.UUID, -) (*db_models.Follow, error) { - args := f.Called(ctx, channelId, chatId) - - return args.Get(0).(*db_models.Follow), args.Error(1) -} - -func (f *DbFollowMock) GetByChannelID(ctx context.Context, channelId uuid.UUID) ([]*db_models.Follow, error) { - args := f.Called(ctx, channelId) - - return args.Get(0).([]*db_models.Follow), args.Error(1) -} - -func (f *DbFollowMock) GetByChatID(ctx context.Context, chatID uuid.UUID, limit, offset int) ([]*db_models.Follow, error) { - args := f.Called(ctx, chatID, limit, offset) - - return args.Get(0).([]*db_models.Follow), args.Error(1) -} - -func (f *DbFollowMock) CountByChatID(ctx context.Context, chatID uuid.UUID) (int, error) { - args := f.Called(ctx, chatID) - - return args.Int(0), args.Error(1) -} diff --git a/internal/test_utils/mocks/db_stream.go b/internal/test_utils/mocks/db_stream.go deleted file mode 100644 index f35a7b8e..00000000 --- a/internal/test_utils/mocks/db_stream.go +++ /dev/null @@ -1,52 +0,0 @@ -package mocks - -import ( - "context" - "github.com/satont/twitch-notifier/internal/db" - "github.com/satont/twitch-notifier/internal/db/db_models" - - "github.com/google/uuid" - "github.com/stretchr/testify/mock" -) - -type DbStreamMock struct { - mock.Mock -} - -func (s *DbStreamMock) GetByID(ctx context.Context, streamId string) (*db_models.Stream, error) { - args := s.Called(ctx, streamId) - - return args.Get(0).(*db_models.Stream), args.Error(1) -} - -func (s *DbStreamMock) GetLatestByChannelID(ctx context.Context, channelEntityID uuid.UUID) (*db_models.Stream, error) { - args := s.Called(ctx, channelEntityID) - - return args.Get(0).(*db_models.Stream), args.Error(1) -} - -func (s *DbStreamMock) GetManyByChannelID(ctx context.Context, channelEntityID uuid.UUID, limit int) ([]*db_models.Stream, error) { - args := s.Called(ctx, channelEntityID, limit) - - return args.Get(0).([]*db_models.Stream), args.Error(1) -} - -func (s *DbStreamMock) UpdateOneByStreamID( - ctx context.Context, - streamID string, - updateQuery *db.StreamUpdateQuery, -) (*db_models.Stream, error) { - args := s.Called(ctx, streamID, updateQuery) - - return args.Get(0).(*db_models.Stream), args.Error(1) -} - -func (s *DbStreamMock) CreateOneByChannelID( - ctx context.Context, - channelEntityID uuid.UUID, - updateQuery *db.StreamUpdateQuery, -) (*db_models.Stream, error) { - args := s.Called(ctx, channelEntityID, updateQuery) - - return args.Get(0).(*db_models.Stream), args.Error(1) -} diff --git a/internal/test_utils/mocks/message_sender.go b/internal/test_utils/mocks/message_sender.go deleted file mode 100644 index 137256ad..00000000 --- a/internal/test_utils/mocks/message_sender.go +++ /dev/null @@ -1,19 +0,0 @@ -package mocks - -import ( - "context" - "github.com/satont/twitch-notifier/internal/db/db_models" - "github.com/satont/twitch-notifier/internal/message_sender" - - "github.com/stretchr/testify/mock" -) - -type MessageSenderMock struct { - mock.Mock -} - -func (m *MessageSenderMock) SendMessage(ctx context.Context, chat *db_models.Chat, opts *message_sender.MessageOpts) error { - args := m.Called(ctx, chat, opts) - - return args.Error(0) -} diff --git a/internal/test_utils/mocks/twitch_api_client.go b/internal/test_utils/mocks/twitch_api_client.go deleted file mode 100644 index 6128706f..00000000 --- a/internal/test_utils/mocks/twitch_api_client.go +++ /dev/null @@ -1,43 +0,0 @@ -package mocks - -import ( - "strings" - - "github.com/nicklaw5/helix/v2" - "github.com/stretchr/testify/mock" -) - -type TwitchApiMock struct { - mock.Mock -} - -func (m *TwitchApiMock) GetUser(id, login string) (*helix.User, error) { - args := m.Called(id, login) - return args.Get(0).(*helix.User), args.Error(1) -} - -func (m *TwitchApiMock) GetUsers(ids, logins []string) ([]helix.User, error) { - args := m.Called(ids, logins) - return args.Get(0).([]helix.User), args.Error(1) -} - -func (m *TwitchApiMock) GetStreamByUserId(id string) (*helix.Stream, error) { - args := m.Called(id) - return args.Get(0).(*helix.Stream), args.Error(1) -} - -func (m *TwitchApiMock) GetStreamsByUserIds(ids []string) ([]helix.Stream, error) { - args := m.Called(ids) - return args.Get(0).([]helix.Stream), args.Error(1) -} - -func (m *TwitchApiMock) GetChannelByUserId(id string) (*helix.ChannelInformation, error) { - strings.ReplaceAll(id, " ", "") - args := m.Called(id) - return args.Get(0).(*helix.ChannelInformation), args.Error(1) -} - -func (m *TwitchApiMock) GetChannelsByUserIds(ids []string) ([]helix.ChannelInformation, error) { - args := m.Called(ids) - return args.Get(0).([]helix.ChannelInformation), args.Error(1) -} diff --git a/internal/test_utils/telegram_client.go b/internal/test_utils/telegram_client.go deleted file mode 100644 index 0268445f..00000000 --- a/internal/test_utils/telegram_client.go +++ /dev/null @@ -1,21 +0,0 @@ -package test_utils - -import ( - "github.com/mr-linch/go-tg" - "net/http" - "net/http/httptest" -) - -const ( - TelegramClientToken = "1234:secret" - TelegramOkResponse = `{"ok":true}` -) - -func NewTelegramClient(server *httptest.Server) *tg.Client { - client := tg.New(TelegramClientToken, - tg.WithClientServerURL(server.URL), - tg.WithClientDoer(&http.Client{}), - ) - - return client -} diff --git a/internal/thumbnailchecker/mocks/mock.go b/internal/thumbnailchecker/mocks/mock.go new file mode 100644 index 00000000..70618f8b --- /dev/null +++ b/internal/thumbnailchecker/mocks/mock.go @@ -0,0 +1,68 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: thumbnail_checker.go +// +// Generated by this command: +// +// mockgen -source=thumbnail_checker.go -destination=mocks/mock.go +// + +// Package mock_thumbnailchecker is a generated GoMock package. +package mock_thumbnailchecker + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockThumbnailChecker is a mock of ThumbnailChecker interface. +type MockThumbnailChecker struct { + ctrl *gomock.Controller + recorder *MockThumbnailCheckerMockRecorder +} + +// MockThumbnailCheckerMockRecorder is the mock recorder for MockThumbnailChecker. +type MockThumbnailCheckerMockRecorder struct { + mock *MockThumbnailChecker +} + +// NewMockThumbnailChecker creates a new mock instance. +func NewMockThumbnailChecker(ctrl *gomock.Controller) *MockThumbnailChecker { + mock := &MockThumbnailChecker{ctrl: ctrl} + mock.recorder = &MockThumbnailCheckerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockThumbnailChecker) EXPECT() *MockThumbnailCheckerMockRecorder { + return m.recorder +} + +// TransformSizes mocks base method. +func (m *MockThumbnailChecker) TransformSizes(url string, width, height int) string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TransformSizes", url, width, height) + ret0, _ := ret[0].(string) + return ret0 +} + +// TransformSizes indicates an expected call of TransformSizes. +func (mr *MockThumbnailCheckerMockRecorder) TransformSizes(url, width, height any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TransformSizes", reflect.TypeOf((*MockThumbnailChecker)(nil).TransformSizes), url, width, height) +} + +// ValidateThumbnail mocks base method. +func (m *MockThumbnailChecker) ValidateThumbnail(ctx context.Context, url string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ValidateThumbnail", ctx, url) + ret0, _ := ret[0].(error) + return ret0 +} + +// ValidateThumbnail indicates an expected call of ValidateThumbnail. +func (mr *MockThumbnailCheckerMockRecorder) ValidateThumbnail(ctx, url any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateThumbnail", reflect.TypeOf((*MockThumbnailChecker)(nil).ValidateThumbnail), ctx, url) +} diff --git a/internal/thumbnailchecker/temporal/activity.go b/internal/thumbnailchecker/temporal/activity.go new file mode 100644 index 00000000..99951d04 --- /dev/null +++ b/internal/thumbnailchecker/temporal/activity.go @@ -0,0 +1,57 @@ +package temporal + +import ( + "context" + "errors" + "net/http" + "net/url" +) + +func NewActivity() *Activity { + cl := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + return &Activity{ + client: cl, + } +} + +type Activity struct { + client *http.Client +} + +var ErrInvalidThumbnail = errors.New("invalid thumbnail") + +func (c *Activity) ThumbnailCheckerTemporalActivity( + ctx context.Context, + thumbnailUrl string, +) error { + u, err := url.Parse(thumbnailUrl) + if err != nil { + return err + } + + request := &http.Request{ + URL: u, + } + request = request.WithContext(ctx) + + res, err := c.client.Do(request) + if err != nil { + return err + } + + contentType := res.Header.Get("Content-Type") + isImage := contentType == "image/png" || contentType == "image/jpeg" + + isNotRedirect := res.StatusCode >= 200 && res.StatusCode < 300 + + if isImage && isNotRedirect { + return nil + } + + return ErrInvalidThumbnail +} diff --git a/internal/thumbnailchecker/temporal/activity_test.go b/internal/thumbnailchecker/temporal/activity_test.go new file mode 100644 index 00000000..6a0ad0d5 --- /dev/null +++ b/internal/thumbnailchecker/temporal/activity_test.go @@ -0,0 +1,73 @@ +package temporal + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestActivity_ThumbnailCheckerTemporalActivityCorrect(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + }, + ), + ) + defer ts.Close() + + activity := NewActivity() + err := activity.ThumbnailCheckerTemporalActivity( + context.TODO(), + ts.URL, + ) + + assert.NoError(t, err) +} + +func TestActivity_ThumbnailCheckerTemporalActivityRedirect(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "https://google.com", http.StatusFound) + }, + ), + ) + defer ts.Close() + + activity := NewActivity() + err := activity.ThumbnailCheckerTemporalActivity( + context.TODO(), + ts.URL, + ) + + assert.ErrorIs(t, err, ErrInvalidThumbnail) +} + +func TestActivity_ThumbnailCheckerTemporalActivityNotImage(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer( + http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + }, + ), + ) + defer ts.Close() + + activity := NewActivity() + err := activity.ThumbnailCheckerTemporalActivity( + context.TODO(), + ts.URL, + ) + + assert.ErrorIs(t, err, ErrInvalidThumbnail) +} diff --git a/internal/thumbnailchecker/temporal/fx.go b/internal/thumbnailchecker/temporal/fx.go new file mode 100644 index 00000000..fbfd0a39 --- /dev/null +++ b/internal/thumbnailchecker/temporal/fx.go @@ -0,0 +1,15 @@ +package temporal + +import ( + "github.com/satont/twitch-notifier/internal/thumbnailchecker" + "go.uber.org/fx" +) + +var Module = fx.Options( + fx.Provide( + NewActivity, + NewWorkflow, + fx.Annotate(NewImpl, fx.As(new(thumbnailchecker.ThumbnailChecker))), + ), + fx.Invoke(NewWorker), +) diff --git a/internal/thumbnailchecker/temporal/impl.go b/internal/thumbnailchecker/temporal/impl.go new file mode 100644 index 00000000..100d256a --- /dev/null +++ b/internal/thumbnailchecker/temporal/impl.go @@ -0,0 +1,78 @@ +package temporal + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/google/uuid" + "github.com/satont/twitch-notifier/internal/thumbnailchecker" + "github.com/satont/twitch-notifier/pkg/logger" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/log" + "go.temporal.io/sdk/temporal" + "go.uber.org/fx" +) + +const queueName = "thumbnail_checker" + +type ImplOpts struct { + fx.In + + Logger logger.Logger + Workflow *Workflow +} + +func NewImpl(opts ImplOpts) (*Temporal, error) { + cl, err := client.Dial( + client.Options{ + Logger: log.NewStructuredLogger(opts.Logger.GetSlog()), + }, + ) + if err != nil { + return nil, err + } + + return &Temporal{ + client: cl, + workflow: opts.Workflow, + }, nil +} + +var _ thumbnailchecker.ThumbnailChecker = (*Temporal)(nil) + +type Temporal struct { + client client.Client + workflow *Workflow +} + +func (c *Temporal) ValidateThumbnail(ctx context.Context, thumbnailUrl string) error { + workflowOptions := client.StartWorkflowOptions{ + ID: fmt.Sprintf("Thumnail check %s", uuid.NewString()), + TaskQueue: queueName, + RetryPolicy: &temporal.RetryPolicy{ + MaximumAttempts: 100, + }, + } + + we, err := c.client.ExecuteWorkflow(ctx, workflowOptions, c.workflow, thumbnailUrl) + if err != nil { + return err + } + + err = we.Get(ctx, nil) + if err != nil { + return err + } + + return nil +} + +func (c *Temporal) TransformSizes(url string, width int, height int) string { + thumbNail := url + thumbNail = strings.Replace(thumbNail, "{width}", strconv.Itoa(width), 1) + thumbNail = strings.Replace(thumbNail, "{height}", strconv.Itoa(height), 1) + + return thumbNail +} diff --git a/internal/thumbnailchecker/temporal/worker.go b/internal/thumbnailchecker/temporal/worker.go new file mode 100644 index 00000000..04fb2070 --- /dev/null +++ b/internal/thumbnailchecker/temporal/worker.go @@ -0,0 +1,54 @@ +package temporal + +import ( + "context" + + "github.com/satont/twitch-notifier/pkg/config" + "github.com/satont/twitch-notifier/pkg/logger" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/log" + "go.temporal.io/sdk/worker" + "go.uber.org/fx" +) + +type WorkerOpts struct { + fx.In + LC fx.Lifecycle + + Workflow *Workflow + Activity *Activity + Logger logger.Logger + Config config.Config +} + +func NewWorker(opts WorkerOpts) error { + temporalClient, err := client.Dial( + client.Options{ + Logger: log.NewStructuredLogger(opts.Logger.GetSlog()), + HostPort: opts.Config.TemporalUrl, + }, + ) + if err != nil { + return err + } + + w := worker.New(temporalClient, queueName, worker.Options{}) + + w.RegisterWorkflow(opts.Workflow.Workflow) + w.RegisterActivity(opts.Activity.ThumbnailCheckerTemporalActivity) + + opts.LC.Append( + fx.Hook{ + OnStart: func(ctx context.Context) error { + return w.Start() + }, + OnStop: func(ctx context.Context) error { + w.Stop() + temporalClient.Close() + return nil + }, + }, + ) + + return nil +} diff --git a/internal/thumbnailchecker/temporal/workflow.go b/internal/thumbnailchecker/temporal/workflow.go new file mode 100644 index 00000000..5c65c578 --- /dev/null +++ b/internal/thumbnailchecker/temporal/workflow.go @@ -0,0 +1,59 @@ +package temporal + +import ( + "time" + + "go.temporal.io/sdk/temporal" + "go.temporal.io/sdk/workflow" + "go.uber.org/fx" +) + +type WorkflowOpts struct { + fx.In + + Activity *Activity +} + +func NewWorkflow(opts WorkflowOpts) *Workflow { + return &Workflow{ + activity: opts.Activity, + } +} + +type Workflow struct { + activity *Activity +} + +const activityMaximumAttempts = 50 + +func (c *Workflow) Workflow(ctx workflow.Context, thumbNailUrl string) error { + ao := workflow.ActivityOptions{ + TaskQueue: queueName, + RetryPolicy: &temporal.RetryPolicy{ + MaximumInterval: 15 * time.Second, + MaximumAttempts: activityMaximumAttempts, + NonRetryableErrorTypes: nil, + }, + } + ctx = workflow.WithActivityOptions(ctx, ao) + + logger := workflow.GetLogger(ctx) + logger.Info("Starting check thumbnail validation", "thumbNailUrl", thumbNailUrl) + + err := workflow.ExecuteActivity( + ctx, + c.activity.ThumbnailCheckerTemporalActivity, + thumbNailUrl, + ).Get( + ctx, + nil, + ) + if err != nil { + logger.Error("Validation failed", "Error", err) + return err + } + + logger.Info("Thumbnail is validated") + + return nil +} diff --git a/internal/thumbnailchecker/temporal/workflow_test.go b/internal/thumbnailchecker/temporal/workflow_test.go new file mode 100644 index 00000000..80fcea34 --- /dev/null +++ b/internal/thumbnailchecker/temporal/workflow_test.go @@ -0,0 +1,60 @@ +package temporal + +import ( + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.temporal.io/sdk/testsuite" +) + +func Test_Workflow(t *testing.T) { + t.Parallel() + + activity := &Activity{} + workflow := &Workflow{ + activity: activity, + } + + testSuite := &testsuite.WorkflowTestSuite{} + env := testSuite.NewTestWorkflowEnvironment() + + // Mock activity implementation + env.OnActivity( + activity.ThumbnailCheckerTemporalActivity, + mock.Anything, + "https://twitch.tv/thumbNail", + ).Return(nil) + + env.ExecuteWorkflow(workflow.Workflow, "https://twitch.tv/thumbNail") + + require.True(t, env.IsWorkflowCompleted()) + require.NoError(t, env.GetWorkflowError()) +} + +func Test_WorkflowError(t *testing.T) { + t.Parallel() + + activity := &Activity{} + workflow := &Workflow{ + activity: activity, + } + + testSuite := &testsuite.WorkflowTestSuite{} + env := testSuite.NewTestWorkflowEnvironment() + + // Mock activity implementation + env. + OnActivity( + activity.ThumbnailCheckerTemporalActivity, + mock.Anything, + "https://twitch.tv/thumbNail", + ). + Times(activityMaximumAttempts). + Return(ErrInvalidThumbnail) + + env.ExecuteWorkflow(workflow.Workflow, "https://twitch.tv/thumbNail") + + require.True(t, env.IsWorkflowCompleted()) + require.Error(t, env.GetWorkflowError()) +} diff --git a/internal/thumbnailchecker/thumbnail_checker.go b/internal/thumbnailchecker/thumbnail_checker.go new file mode 100644 index 00000000..508e9e09 --- /dev/null +++ b/internal/thumbnailchecker/thumbnail_checker.go @@ -0,0 +1,12 @@ +package thumbnailchecker + +import ( + "context" +) + +//go:generate go run go.uber.org/mock/mockgen -source=thumbnail_checker.go -destination=mocks/mock.go + +type ThumbnailChecker interface { + ValidateThumbnail(ctx context.Context, url string) error + TransformSizes(url string, width int, height int) string +} diff --git a/internal/twitch/chunked_req.go b/internal/twitch/chunked_req.go deleted file mode 100644 index be48d3f0..00000000 --- a/internal/twitch/chunked_req.go +++ /dev/null @@ -1,63 +0,0 @@ -package twitch - -import ( - "errors" - "github.com/samber/lo" - "reflect" - "sync" -) - -type chunkedRequestData[Request any, Response any] struct { - ids []string - requestFn func(Request) (Response, error) - responseSelectorFn func(Response) interface{} - paramFn func(chunk []string) Request -} - -func getDataChunked[T, Req, Res any](req *chunkedRequestData[Req, Res]) ([]T, error) { - results := make([]T, 0, len(req.ids)) - - chunkedIds := lo.Chunk(req.ids, 100) - - wg := &sync.WaitGroup{} - mu := &sync.Mutex{} - errChan := make(chan error, len(chunkedIds)) - - for _, chunk := range chunkedIds { - wg.Add(1) - go func(chunk []string) { - defer wg.Done() - - data, err := req.requestFn(req.paramFn(chunk)) - - if err != nil { - errChan <- err - return - } - - resultValue := reflect.ValueOf(data) - - if reflect.Indirect(resultValue).FieldByName("ErrorMessage").String() != "" { - errChan <- errors.New(reflect.Indirect(resultValue).FieldByName("ErrorMessage").String()) - return - } - - selectedField := req.responseSelectorFn(data) - - mu.Lock() - results = append( - results, - selectedField.([]T)..., - ) - mu.Unlock() - }(chunk) - } - - wg.Wait() - - if len(errChan) > 0 { - return nil, <-errChan - } - - return results, nil -} diff --git a/internal/twitch/implementation.go b/internal/twitch/implementation.go deleted file mode 100644 index c33f16d4..00000000 --- a/internal/twitch/implementation.go +++ /dev/null @@ -1,165 +0,0 @@ -package twitch - -import ( - "github.com/nicklaw5/helix/v2" - "github.com/satont/twitch-notifier/internal/twitch/helpers" - "time" -) - -type twitchService struct { - apiClient *helix.Client -} - -func NewTwitchService(clientId string, clientSecret string) (Interface, error) { - apiClient, err := helix.NewClient(&helix.Options{ - ClientID: clientId, - ClientSecret: clientSecret, - RateLimitFunc: helpers.RateLimitCallback, - }) - - if err != nil { - return nil, err - } - - token, err := apiClient.RequestAppAccessToken([]string{}) - if err != nil { - panic(err) - } - apiClient.SetAppAccessToken(token.Data.AccessToken) - - go func() { - for { - newToken, tokenErr := apiClient.RequestAppAccessToken([]string{}) - if tokenErr != nil { - panic(tokenErr) - } - apiClient.SetAppAccessToken(newToken.Data.AccessToken) - time.Sleep(1 * time.Hour) - } - }() - - return &twitchService{ - apiClient: apiClient, - }, nil -} - -func (t *twitchService) GetUser(id, login string) (*helix.User, error) { - users, err := t.GetUsers([]string{id}, []string{login}) - if err != nil { - return nil, err - } - - if len(users) == 0 { - return nil, nil - } - - return &users[0], nil -} - -func (t *twitchService) GetUsers(ids, logins []string) ([]helix.User, error) { - var data []string - - isById := len(ids) > 0 && ids[0] != "" - - if isById { - data = ids - } else { - data = logins - } - - reqData := &chunkedRequestData[*helix.UsersParams, *helix.UsersResponse]{ - ids: data, - requestFn: t.apiClient.GetUsers, - responseSelectorFn: func(response *helix.UsersResponse) interface{} { - return response.Data.Users - }, - paramFn: func(chunk []string) *helix.UsersParams { - if isById { - return &helix.UsersParams{ - IDs: chunk, - } - } else { - return &helix.UsersParams{ - Logins: chunk, - } - } - }, - } - - users, err := getDataChunked[helix.User](reqData) - if err != nil { - return nil, err - } - - return users, nil -} - -func (t *twitchService) GetStreamByUserId(id string) (*helix.Stream, error) { - streams, err := t.GetStreamsByUserIds([]string{id}) - if err != nil { - return nil, err - } - - if len(streams) == 0 { - return nil, nil - } - - return &streams[0], nil -} - -func (t *twitchService) GetStreamsByUserIds(ids []string) ([]helix.Stream, error) { - reqData := &chunkedRequestData[*helix.StreamsParams, *helix.StreamsResponse]{ - ids: ids, - requestFn: t.apiClient.GetStreams, - responseSelectorFn: func(response *helix.StreamsResponse) interface{} { - return response.Data.Streams - }, - paramFn: func(chunk []string) *helix.StreamsParams { - return &helix.StreamsParams{ - UserIDs: chunk, - } - }, - } - - streams, err := getDataChunked[helix.Stream](reqData) - if err != nil { - return nil, err - } - - return streams, nil -} - -func (t *twitchService) GetChannelByUserId(id string) (*helix.ChannelInformation, error) { - channels, err := t.GetChannelsByUserIds([]string{id}) - if err != nil { - return nil, err - } - - if len(channels) == 0 { - return nil, nil - } - - return &channels[0], nil -} - -func (t *twitchService) GetChannelsByUserIds(ids []string) ([]helix.ChannelInformation, error) { - reqData := &chunkedRequestData[*helix.GetChannelInformationParams, *helix.GetChannelInformationResponse]{ - ids: ids, - requestFn: t.apiClient.GetChannelInformation, - responseSelectorFn: func(response *helix.GetChannelInformationResponse) interface{} { - return response.Data.Channels - }, - paramFn: func(chunk []string) *helix.GetChannelInformationParams { - return &helix.GetChannelInformationParams{ - BroadcasterIDs: chunk, - } - }, - } - - channels, err := getDataChunked[helix.ChannelInformation](reqData) - if err != nil { - return nil, err - } - - return channels, nil -} diff --git a/internal/twitch/implementation_test.go b/internal/twitch/implementation_test.go deleted file mode 100644 index 5e87d5ad..00000000 --- a/internal/twitch/implementation_test.go +++ /dev/null @@ -1,212 +0,0 @@ -package twitch - -import ( - "github.com/nicklaw5/helix/v2" - "github.com/stretchr/testify/assert" - "net/http" - "net/http/httptest" - "testing" -) - -func newMockedApi(server *httptest.Server) (*twitchService, error) { - apiClient, err := helix.NewClient(&helix.Options{ - ClientID: "test", - APIBaseURL: server.URL, - }) - if err != nil { - return nil, err - } - - apiClient.SetAppAccessToken("test") - - return &twitchService{ - apiClient: apiClient, - }, nil -} - -func TestTwitchService_GetUser(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"data":[{"id":"1","login":"test"}]}`)) - })) - defer server.Close() - - twitchService, err := newMockedApi(server) - assert.NoError(t, err) - - user, err := twitchService.GetUser("1", "") - assert.NoError(t, err) - - assert.Equal(t, "1", user.ID) - assert.Equal(t, "test", user.Login) -} - -func TestTwitchService_GetUsers(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"data":[{"id":"1","login":"test"},{"id":"2","login":"test2"}]}`)) - })) - defer server.Close() - - twitchService, err := newMockedApi(server) - assert.NoError(t, err) - - expectedUsers := []helix.User{ - {ID: "1", Login: "test"}, - {ID: "2", Login: "test2"}, - } - - table := []struct { - name string - ids []string - logins []string - }{ - { - name: "ids", - ids: []string{"1", "2"}, - logins: []string{}, - }, - { - name: "logins", - ids: []string{}, - logins: []string{"test", "test2"}, - }, - } - - for _, tt := range table { - t.Run(tt.name, func(t *testing.T) { - users, err := twitchService.GetUsers(tt.ids, tt.logins) - assert.NoError(t, err) - - assert.Equal(t, expectedUsers, users) - }) - } -} - -func TestTwitchService_GetStreamByUserId(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"data":[{"id":"1","user_name":"test","game_name": "Dota 2"}]}`)) - })) - defer server.Close() - - twitchService, err := newMockedApi(server) - assert.NoError(t, err) - - stream, err := twitchService.GetStreamByUserId("1") - assert.NoError(t, err) - - assert.Equal(t, "1", stream.ID) - assert.Equal(t, "test", stream.UserName) - assert.Equal(t, "Dota 2", stream.GameName) -} - -func TestTwitchService_GetStreamsByUserId(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"data":[{"id":"1","user_name":"test","game_name": "Dota 2"}, {"id":"2","user_name":"test2","game_name": "Dota 3"}]}`)) - })) - defer server.Close() - - twitchService, err := newMockedApi(server) - assert.NoError(t, err) - - streams, err := twitchService.GetStreamsByUserIds([]string{"1", "2"}) - assert.NoError(t, err) - - assert.Equal(t, []helix.Stream{ - {ID: "1", UserName: "test", GameName: "Dota 2"}, - {ID: "2", UserName: "test2", GameName: "Dota 3"}, - }, streams) -} - -func TestTwitchService_GetChannelByUserId(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"data":[ - {"broadcaster_id":"1","broadcaster_name":"test","game_name": "Dota 2", "title": "tiitle"} - ]}`)) - })) - defer server.Close() - - twitchService, err := newMockedApi(server) - assert.NoError(t, err) - - channel, err := twitchService.GetChannelByUserId("1") - assert.NoError(t, err) - - assert.Equal(t, "1", channel.BroadcasterID) - assert.Equal(t, "test", channel.BroadcasterName) - assert.Equal(t, "Dota 2", channel.GameName) - assert.Equal(t, "tiitle", channel.Title) -} - -func TestTwitchService_GetChannelsByUserId(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"data":[ - {"broadcaster_id":"1","broadcaster_name":"test","game_name": "Dota 2", "title": "tiitle"}, - {"broadcaster_id":"2","broadcaster_name":"test2","game_name": "Dota 3", "title": "tiitle2"} - ]}`)) - })) - defer server.Close() - - twitchService, err := newMockedApi(server) - assert.NoError(t, err) - - channels, err := twitchService.GetChannelsByUserIds([]string{"1", "2"}) - assert.NoError(t, err) - - assert.Equal(t, []helix.ChannelInformation{ - {BroadcasterID: "1", BroadcasterName: "test", GameName: "Dota 2", Title: "tiitle"}, - {BroadcasterID: "2", BroadcasterName: "test2", GameName: "Dota 3", Title: "tiitle2"}, - }, channels) -} - -func TestTwitchService_GetChunkerError(t *testing.T) { - t.Parallel() - - table := []struct { - name string - server *httptest.Server - expectedErrorMessage string - }{ - { - name: "fail because twitch returns error code", - server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusForbidden) - data := `{ - "error": "Forbidden", - "status": 403, - "message": "test" - }` - _, _ = w.Write([]byte(data)) - })), - expectedErrorMessage: "test", - }, - } - - for _, tt := range table { - t.Run(tt.name, func(t *testing.T) { - twitchService, err := newMockedApi(tt.server) - assert.NoError(t, err) - - _, err = twitchService.GetChannelByUserId("1") - assert.Error(t, err) - assert.Equal(t, tt.expectedErrorMessage, err.Error()) - }) - } - -} diff --git a/internal/twitch/interface.go b/internal/twitch/interface.go deleted file mode 100644 index 289f1f76..00000000 --- a/internal/twitch/interface.go +++ /dev/null @@ -1,16 +0,0 @@ -package twitch - -import ( - "github.com/nicklaw5/helix/v2" -) - -type Interface interface { - GetUser(id, login string) (*helix.User, error) - GetUsers(ids, logins []string) ([]helix.User, error) - - GetStreamByUserId(id string) (*helix.Stream, error) - GetStreamsByUserIds(ids []string) ([]helix.Stream, error) - - GetChannelByUserId(id string) (*helix.ChannelInformation, error) - GetChannelsByUserIds(ids []string) ([]helix.ChannelInformation, error) -} diff --git a/internal/twitch_streams_cheker/thumbnail_builder.go b/internal/twitch_streams_cheker/thumbnail_builder.go deleted file mode 100644 index 53619739..00000000 --- a/internal/twitch_streams_cheker/thumbnail_builder.go +++ /dev/null @@ -1,60 +0,0 @@ -package twitch_streams_cheker - -import ( - "fmt" - "net/http" - "strings" - "time" -) - -type thumbNailBuilder struct { -} - -func newThumbNailBuilder() *thumbNailBuilder { - return &thumbNailBuilder{} -} - -func (c *thumbNailBuilder) checkValidity(url string, n int) (bool, error) { - client := &http.Client{ - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } - - req, err := client.Get(url) - if err != nil { - return false, err - } - - if req.StatusCode != 200 && n == 5 { - return false, fmt.Errorf("url %s is not valid", url) - } else if req.StatusCode != 200 { - time.Sleep(5 * time.Second) - return c.checkValidity(url, n+1) - } else { - return true, nil - } -} - -func (c *thumbNailBuilder) Build(thumbNailUrl string, checkValidity bool) (string, error) { - thumbNail := thumbNailUrl - thumbNail = strings.Replace(thumbNail, "{width}", "1920", 1) - thumbNail = strings.Replace(thumbNail, "{height}", "1080", 1) - - if !checkValidity { - return thumbNail, nil - } - - valid, err := c.checkValidity(thumbNail, 0) - - if !valid || err != nil { - thumbNail = strings.Replace(thumbNail, "1920", "1280", 1) - thumbNail = strings.Replace(thumbNail, "1080", "720", 1) - } - - if err != nil { - return thumbNail, err - } - - return thumbNail, nil -} diff --git a/internal/twitch_streams_cheker/twitch_streams_cheker.go b/internal/twitch_streams_cheker/twitch_streams_cheker.go deleted file mode 100644 index 5de41198..00000000 --- a/internal/twitch_streams_cheker/twitch_streams_cheker.go +++ /dev/null @@ -1,461 +0,0 @@ -package twitch_streams_cheker - -import ( - "context" - "fmt" - "strings" - "sync" - "time" - - "github.com/mr-linch/go-tg" - "github.com/nicklaw5/helix/v2" - "github.com/samber/lo" - "github.com/satont/twitch-notifier/internal/db" - "github.com/satont/twitch-notifier/internal/db/db_models" - "github.com/satont/twitch-notifier/internal/message_sender" - "github.com/satont/twitch-notifier/internal/types" - "go.uber.org/zap" -) - -type TwitchStreamChecker struct { - services *types.Services - ticks int - tickTime *time.Duration - sender message_sender.MessageSenderInterface - thumbNailBuilder *thumbNailBuilder -} - -func NewTwitchStreamChecker( - services *types.Services, - sender message_sender.MessageSenderInterface, - tickTime *time.Duration, -) *TwitchStreamChecker { - checker := &TwitchStreamChecker{ - services: services, - tickTime: tickTime, - sender: sender, - thumbNailBuilder: newThumbNailBuilder(), - } - - return checker -} - -func (t *TwitchStreamChecker) check(ctx context.Context) { - channels, err := t.services.Channel.GetAll(ctx) - if err != nil { - zap.S().Error(err) - return - } - - channelsIDs := make([]string, 0, len(channels)) - for _, channel := range channels { - channelsIDs = append(channelsIDs, channel.ChannelID) - } - - twitchChannels, err := t.services.Twitch.GetChannelsByUserIds(channelsIDs) - if err != nil { - zap.S().Error(err) - return - } - - currentTwitchStreams, err := t.services.Twitch.GetStreamsByUserIds(channelsIDs) - if err != nil { - zap.S().Error(err) - return - } - - wg := &sync.WaitGroup{} - for _, channel := range channels { - wg.Add(1) - - go func(channel *db_models.Channel) { - defer wg.Done() - twitchChannel, twitchChannelOk := lo.Find( - twitchChannels, - func(item helix.ChannelInformation) bool { - return item.BroadcasterID == channel.ChannelID - }, - ) - if !twitchChannelOk { - return - } - - currentDBStream, err := t.services.Stream.GetLatestByChannelID(ctx, channel.ID) - if err != nil { - zap.S().Error(err) - return - } - - followers, err := t.services.Follow.GetByChannelID(ctx, channel.ID) - if err != nil { - zap.S().Error(err) - return - } - - twitchCurrentStream, twitchCurrentStreamOk := lo.Find( - currentTwitchStreams, - func(stream helix.Stream) bool { - return stream.UserID == channel.ChannelID - }, - ) - - if twitchCurrentStreamOk && twitchCurrentStream.Type != "live" { - return - } - - // if stream becomes offline - if !twitchCurrentStreamOk && currentDBStream != nil && currentDBStream.EndedAt == nil { - _, err = t.services.Stream.UpdateOneByStreamID( - ctx, - currentDBStream.ID, - &db.StreamUpdateQuery{ - IsLive: lo.ToPtr(false), - }, - ) - if err != nil { - zap.S().Error(err) - return - } - - // send message to all followers - for _, follower := range followers { - if !follower.Chat.Settings.OfflineNotification { - continue - } - - message := t.services.I18N.Translate( - "notifications.streams.nowOffline", - follower.Chat.Settings.ChatLanguage.String(), - map[string]string{ - "channelLink": tg.MD.Link( - twitchChannel.BroadcasterName, - fmt.Sprintf("https://twitch.tv/%s", twitchChannel.BroadcasterName), - ), - "categories": strings.Join(currentDBStream.Categories, " -> "), - "duration": time.Now().UTC().Sub(currentDBStream.StartedAt). - Truncate(1 * time.Second). - String(), - }, - ) - unfollowButton := message_sender.KeyboardButton{ - Text: t.services.I18N.Translate( - "commands.unfollow.callbackButton", - follower.Chat.Settings.ChatLanguage.String(), - map[string]string{ - "streamer": twitchChannel.BroadcasterName, - }, - ), - CallbackData: fmt.Sprintf("channels_unfollow_%v", channel.ID), - SkipInGroup: true, - } - - err = t.sender.SendMessage( - ctx, follower.Chat, &message_sender.MessageOpts{ - Text: message, - ParseMode: &tg.MD, - Buttons: [][]message_sender.KeyboardButton{{unfollowButton}}, - }, - ) - if err != nil { - zap.S().Error(err) - continue - } - } - } - - // if stream becomes online - if twitchCurrentStreamOk && currentDBStream == nil { - _, err = t.services.Stream.CreateOneByChannelID( - ctx, - channel.ID, - &db.StreamUpdateQuery{ - StreamID: twitchCurrentStream.ID, - IsLive: lo.ToPtr(true), - Category: lo.ToPtr(twitchCurrentStream.GameName), - Title: lo.ToPtr(twitchCurrentStream.Title), - }, - ) - if err != nil { - zap.S().Error(err) - return - } - - for _, follower := range followers { - message := t.services.I18N.Translate( - "notifications.streams.nowOnline", - follower.Chat.Settings.ChatLanguage.String(), - map[string]string{ - "channelLink": tg.MD.Link( - twitchChannel.BroadcasterName, - fmt.Sprintf("https://twitch.tv/%s", twitchChannel.BroadcasterName), - ), - "category": twitchCurrentStream.GameName, - "title": twitchCurrentStream.Title, - }, - ) - - unfollowButton := message_sender.KeyboardButton{ - Text: t.services.I18N.Translate( - "commands.unfollow.callbackButton", - follower.Chat.Settings.ChatLanguage.String(), - map[string]string{ - "streamer": twitchChannel.BroadcasterName, - }, - ), - CallbackData: fmt.Sprintf("channels_unfollow_%v", channel.ID), - SkipInGroup: true, - } - - thumbNail, err := t.thumbNailBuilder.Build(twitchCurrentStream.ThumbnailURL, true) - if err != nil { - zap.S().Error(err) - } - - err = t.sender.SendMessage( - ctx, follower.Chat, &message_sender.MessageOpts{ - Text: message, - ImageURL: lo.If( - follower.Chat.Settings.ImageInNotification, - fmt.Sprintf("%s?%d", thumbNail, time.Now().Unix()), - ).Else(""), - ParseMode: &tg.MD, - Buttons: [][]message_sender.KeyboardButton{{unfollowButton}}, - }, - ) - if err != nil { - zap.S().Error(err) - continue - } - } - } - - // stream is still online, need to check do we need to update title or category - if twitchCurrentStreamOk && currentDBStream != nil && - currentDBStream.ID == twitchCurrentStream.ID { - latestTitle := "" - if len(currentDBStream.Titles) > 0 { - latestTitle = currentDBStream.Titles[len(currentDBStream.Titles)-1] - } - latestCategory := "" - if len(currentDBStream.Categories) > 0 { - latestCategory = currentDBStream.Categories[len(currentDBStream.Categories)-1] - } - - // stream is online, and both title and category changed, so we need to send a complex notification - if twitchCurrentStream.GameName != latestCategory && - twitchCurrentStream.Title != latestTitle { - _, err = t.services.Stream.UpdateOneByStreamID( - ctx, - currentDBStream.ID, - &db.StreamUpdateQuery{ - Category: lo.ToPtr(twitchCurrentStream.GameName), - Title: lo.ToPtr(twitchCurrentStream.Title), - }, - ) - if err != nil { - zap.S().Error(err) - return - } - - thumbNail, err := t.thumbNailBuilder.Build(twitchCurrentStream.ThumbnailURL, false) - if err != nil { - zap.S().Error(err) - } - - for _, follower := range followers { - if !follower.Chat.Settings.GameAndTitleChangeNotification { - continue - } - - unfollowButton := message_sender.KeyboardButton{ - Text: t.services.I18N.Translate( - "commands.unfollow.callbackButton", - follower.Chat.Settings.ChatLanguage.String(), - map[string]string{ - "streamer": twitchChannel.BroadcasterName, - }, - ), - CallbackData: fmt.Sprintf("channels_unfollow_%v", channel.ID), - SkipInGroup: true, - } - - err = t.sender.SendMessage( - ctx, follower.Chat, &message_sender.MessageOpts{ - Text: t.services.I18N.Translate( - "notifications.streams.titleAndCategoryChanged", - follower.Chat.Settings.ChatLanguage.String(), - map[string]string{ - "channelLink": tg.MD.Link( - twitchChannel.BroadcasterName, - fmt.Sprintf( - "https://twitch.tv/%s", - twitchChannel.BroadcasterName, - ), - ), - "category": tg.MD.Bold(twitchCurrentStream.GameName), - "oldCategory": tg.MD.Bold(latestCategory), - "title": tg.MD.Bold(twitchCurrentStream.Title), - "oldTitle": tg.MD.Bold(latestTitle), - }, - ), - ParseMode: &tg.MD, - ImageURL: lo.If( - follower.Chat.Settings.ImageInNotification, - fmt.Sprintf("%s?%d", thumbNail, time.Now().Unix()), - ).Else(""), - Buttons: [][]message_sender.KeyboardButton{{unfollowButton}}, - }, - ) - if err != nil { - zap.S().Error(err) - continue - } - } - return - } - - if twitchCurrentStream.GameName != latestCategory { - _, err = t.services.Stream.UpdateOneByStreamID( - ctx, - currentDBStream.ID, - &db.StreamUpdateQuery{ - Category: lo.ToPtr(twitchCurrentStream.GameName), - }, - ) - if err != nil { - zap.S().Error(err) - return - } - - for _, follower := range followers { - if !follower.Chat.Settings.GameChangeNotification { - continue - } - - thumbNail, err := t.thumbNailBuilder.Build(twitchCurrentStream.ThumbnailURL, true) - if err != nil { - zap.S().Error(err) - } - - err = t.sender.SendMessage( - ctx, follower.Chat, &message_sender.MessageOpts{ - Text: t.services.I18N.Translate( - "notifications.streams.newCategory", - follower.Chat.Settings.ChatLanguage.String(), - map[string]string{ - "channelLink": tg.MD.Link( - twitchChannel.BroadcasterName, - fmt.Sprintf( - "https://twitch.tv/%s", - twitchChannel.BroadcasterName, - ), - ), - "category": tg.MD.Bold(twitchCurrentStream.GameName), - "oldCategory": tg.MD.Bold(latestCategory), - }, - ), - ParseMode: &tg.MD, - ImageURL: lo.If( - follower.Chat.Settings.ImageInNotification, - fmt.Sprintf("%s?%d", thumbNail, time.Now().Unix()), - ).Else(""), - }, - ) - if err != nil { - zap.S().Error(err) - continue - } - } - return - } - - if twitchCurrentStream.Title != latestTitle { - _, err = t.services.Stream.UpdateOneByStreamID( - ctx, - currentDBStream.ID, - &db.StreamUpdateQuery{ - Title: lo.ToPtr(twitchCurrentStream.Title), - }, - ) - if err != nil { - zap.S().Error(err) - return - } - - for _, follower := range followers { - if !follower.Chat.Settings.TitleChangeNotification { - continue - } - - thumbNail, err := t.thumbNailBuilder.Build(twitchCurrentStream.ThumbnailURL, true) - if err != nil { - zap.S().Error(err) - } - - err = t.sender.SendMessage( - ctx, follower.Chat, &message_sender.MessageOpts{ - Text: t.services.I18N.Translate( - "notifications.streams.titleChanged", - follower.Chat.Settings.ChatLanguage.String(), - map[string]string{ - "channelLink": tg.MD.Link( - twitchChannel.BroadcasterName, - fmt.Sprintf( - "https://twitch.tv/%s", - twitchChannel.BroadcasterName, - ), - ), - "category": twitchCurrentStream.GameName, - "title": tg.MD.Bold(twitchCurrentStream.Title), - "oldTitle": tg.MD.Bold(latestTitle), - }, - ), - ParseMode: &tg.MD, - ImageURL: lo.If( - follower.Chat.Settings.ImageInNotification, - fmt.Sprintf("%s?%d", thumbNail, time.Now().Unix()), - ).Else(""), - }, - ) - if err != nil { - zap.S().Error(err) - continue - } - } - } - - } - }(channel) - } - wg.Wait() -} - -func (t *TwitchStreamChecker) StartPolling(ctx context.Context) { - tickTime := lo. - IfF( - t.tickTime != nil, func() time.Duration { - return *t.tickTime - }, - ). - Else( - lo. - If(t.services.Config.AppEnv == "development", 10*time.Second). - Else(1 * time.Minute), - ) - ticker := time.NewTicker(tickTime) - - t.check(ctx) - - go func() { - for { - select { - case <-ticker.C: - t.ticks++ - t.check(ctx) - case <-ctx.Done(): - ticker.Stop() - return - } - } - }() -} diff --git a/internal/twitch_streams_cheker/twitch_streams_cheker_test.go b/internal/twitch_streams_cheker/twitch_streams_cheker_test.go deleted file mode 100644 index dc927af8..00000000 --- a/internal/twitch_streams_cheker/twitch_streams_cheker_test.go +++ /dev/null @@ -1,323 +0,0 @@ -package twitch_streams_cheker - -import ( - "context" - "testing" - - "github.com/google/uuid" - "github.com/nicklaw5/helix/v2" - "github.com/samber/lo" - "github.com/satont/twitch-notifier/internal/db" - "github.com/satont/twitch-notifier/internal/db/db_models" - "github.com/satont/twitch-notifier/internal/test_utils/mocks" - "github.com/satont/twitch-notifier/internal/types" - i18nmocks "github.com/satont/twitch-notifier/pkg/i18n/mocks" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" -) - -func TestNewTwitchStreamChecker(t *testing.T) { - t.Parallel() - - services := &types.Services{} - - checker := NewTwitchStreamChecker(services, &mocks.MessageSenderMock{}, nil) - assert.IsType(t, &TwitchStreamChecker{}, checker) -} - -func TestTwitchStreamChecker_check(t *testing.T) { - t.Parallel() - - channelsMock := &mocks.DbChannelMock{} - twitchMock := &mocks.TwitchApiMock{} - senderMock := &mocks.MessageSenderMock{} - streamMock := &mocks.DbStreamMock{} - followMock := &mocks.DbFollowMock{} - i18nMock := i18nmocks.NewI18nMock() - - i18nMock. - On("Translate", mock.Anything, mock.Anything, mock.Anything). - Return("translated") - - ctx := context.Background() - - dbChannel := &db_models.Channel{ID: uuid.New(), ChannelID: "1"} - dbStream := &db_models.Stream{ - ID: "123", - Titles: []string{"title"}, - Categories: []string{"Dota 2"}, - } - dbChat := &db_models.Chat{ - ID: uuid.New(), - ChatID: "1", - Settings: &db_models.ChatSettings{ - ChatLanguage: db_models.ChatLanguageEn, - GameChangeNotification: true, - OfflineNotification: true, - ImageInNotification: true, - GameAndTitleChangeNotification: false, - }, - } - dbFollow := &db_models.Follow{ - ID: uuid.New(), - ChatID: dbChat.ID, - Chat: dbChat, - Channel: dbChannel, - ChannelID: dbChannel.ID, - } - twitchChannelInfo := &helix.ChannelInformation{BroadcasterID: "1", BroadcasterName: "Satont"} - twitchStream := &helix.Stream{ - ID: "123", - GameName: "Dota 2", - Title: "title", - UserID: "1", - Type: "live", - } - - table := []struct { - name string - setupMocks func() - }{ - { - name: "stream becomes offline, should call UpdateOneByStreamID with correct args", - setupMocks: func() { - twitchMock.On("GetChannelsByUserIds", []string{"1"}). - Return([]helix.ChannelInformation{ - *twitchChannelInfo, - }, nil) - channelsMock.On("GetAll", ctx).Return([]*db_models.Channel{ - dbChannel, - }, nil) - followMock.On("GetByChannelID", ctx, dbChannel.ID). - Return([]*db_models.Follow{dbFollow}, nil) - twitchMock.On("GetStreamsByUserIds", []string{"1"}).Return([]helix.Stream{}, nil) - streamMock.On("GetLatestByChannelID", ctx, dbChannel.ID).Return(dbStream, nil) - followMock.On("GetByChannelID", ctx, dbChannel.ID). - Return([]*db_models.Follow{dbFollow}, nil) - streamMock.On("UpdateOneByStreamID", ctx, dbStream.ID, &db.StreamUpdateQuery{ - IsLive: lo.ToPtr(false), - }).Return((*db_models.Stream)(nil), nil) - senderMock. - On("SendMessage", - ctx, - dbChat, - mock.Anything, - ). - Return(nil) - }, - }, - { - name: "stream becomes online, should call CreateOneByChannelID with correct args", - setupMocks: func() { - twitchMock.On("GetChannelsByUserIds", []string{"1"}). - Return([]helix.ChannelInformation{ - *twitchChannelInfo, - }, nil) - channelsMock.On("GetAll", ctx).Return([]*db_models.Channel{ - dbChannel, - }, nil) - followMock.On("GetByChannelID", ctx, dbChannel.ID). - Return([]*db_models.Follow{dbFollow}, nil) - twitchMock.On("GetStreamsByUserIds", []string{"1"}).Return([]helix.Stream{ - *twitchStream, - }, nil) - streamMock.On("GetLatestByChannelID", ctx, dbChannel.ID). - Return((*db_models.Stream)(nil), nil) - streamMock.On("CreateOneByChannelID", ctx, dbChannel.ID, &db.StreamUpdateQuery{ - StreamID: "123", - IsLive: lo.ToPtr(true), - Category: lo.ToPtr("Dota 2"), - Title: lo.ToPtr("title"), - }).Return((*db_models.Stream)(nil), nil) - senderMock. - On("SendMessage", - ctx, - dbChat, - mock.Anything, - ). - Return(nil) - }, - }, - { - name: "stream is still online, we should update category", - setupMocks: func() { - newHelixStream := &helix.Stream{ - ID: "123", - GameName: "Just Chatting", - Title: "title", - UserID: "1", - Type: "live", - } - - twitchMock.On("GetChannelsByUserIds", []string{"1"}). - Return([]helix.ChannelInformation{ - *twitchChannelInfo, - }, nil) - channelsMock.On("GetAll", ctx).Return([]*db_models.Channel{ - dbChannel, - }, nil) - followMock.On("GetByChannelID", ctx, dbChannel.ID). - Return([]*db_models.Follow{dbFollow}, nil) - twitchMock.On("GetStreamsByUserIds", []string{"1"}).Return([]helix.Stream{ - *newHelixStream, - }, nil) - streamMock.On("GetLatestByChannelID", ctx, dbChannel.ID).Return(dbStream, nil) - streamMock.On("UpdateOneByStreamID", ctx, dbStream.ID, &db.StreamUpdateQuery{ - Category: lo.ToPtr("Just Chatting"), - }).Return((*db_models.Stream)(nil), nil) - senderMock. - On("SendMessage", - ctx, - dbChat, - mock.Anything, - ). - Return(nil) - }, - }, - { - name: "stream is still online, we should update title", - setupMocks: func() { - newHelixStream := &helix.Stream{ - ID: "123", - GameName: "Dota 2", - Title: "title1", - UserID: "1", - Type: "live", - } - - twitchMock.On("GetChannelsByUserIds", []string{"1"}). - Return([]helix.ChannelInformation{ - *twitchChannelInfo, - }, nil) - channelsMock.On("GetAll", ctx).Return([]*db_models.Channel{ - dbChannel, - }, nil) - followMock.On("GetByChannelID", ctx, dbChannel.ID). - Return([]*db_models.Follow{dbFollow}, nil) - twitchMock.On("GetStreamsByUserIds", []string{"1"}).Return([]helix.Stream{ - *newHelixStream, - }, nil) - streamMock.On("GetLatestByChannelID", ctx, dbChannel.ID).Return(dbStream, nil) - streamMock.On("UpdateOneByStreamID", ctx, dbStream.ID, &db.StreamUpdateQuery{ - Title: lo.ToPtr("title1"), - }).Return((*db_models.Stream)(nil), nil) - senderMock. - On("SendMessage", - ctx, - dbChat, - mock.Anything, - ). - Return(nil) - }, - }, - { - name: "stream is still online, we should update title and category", - setupMocks: func() { - newHelixStream := &helix.Stream{ - ID: "123", - GameName: "Dota 3", - Title: "title1", - UserID: "1", - Type: "live", - } - - twitchMock.On("GetChannelsByUserIds", []string{"1"}). - Return([]helix.ChannelInformation{ - *twitchChannelInfo, - }, nil) - channelsMock.On("GetAll", ctx).Return([]*db_models.Channel{ - dbChannel, - }, nil) - followMock.On("GetByChannelID", ctx, dbChannel.ID). - Return([]*db_models.Follow{dbFollow}, nil) - twitchMock.On("GetStreamsByUserIds", []string{"1"}).Return([]helix.Stream{ - *newHelixStream, - }, nil) - streamMock.On("GetLatestByChannelID", ctx, dbChannel.ID).Return(dbStream, nil) - streamMock.On("UpdateOneByStreamID", ctx, dbStream.ID, &db.StreamUpdateQuery{ - Title: lo.ToPtr("title1"), - Category: lo.ToPtr("Dota 3"), - }).Return((*db_models.Stream)(nil), nil) - senderMock. - On("SendMessage", - ctx, - dbChat, - mock.Anything, - ). - Return(nil) - }, - }, - { - name: "we have record in database with some stream, and got new one. We should call send message", - setupMocks: func() { - newHelixStream := &helix.Stream{ - ID: "123456", - GameName: "Dota 2", - Title: "title1", - UserID: "1", - Type: "live", - } - - twitchMock.On("GetChannelsByUserIds", []string{"1"}). - Return([]helix.ChannelInformation{ - *twitchChannelInfo, - }, nil) - channelsMock.On("GetAll", ctx).Return([]*db_models.Channel{ - dbChannel, - }, nil) - followMock.On("GetByChannelID", ctx, dbChannel.ID). - Return([]*db_models.Follow{dbFollow}, nil) - twitchMock.On("GetStreamsByUserIds", []string{"1"}).Return([]helix.Stream{ - *newHelixStream, - }, nil) - streamMock.On("GetLatestByChannelID", ctx, dbChannel.ID). - Return((*db_models.Stream)(nil), nil) - streamMock.On("CreateOneByChannelID", ctx, dbChannel.ID, &db.StreamUpdateQuery{ - StreamID: newHelixStream.ID, - IsLive: lo.ToPtr(true), - Category: lo.ToPtr("Dota 2"), - Title: lo.ToPtr("title1"), - }).Return((*db_models.Stream)(nil), nil) - senderMock. - On("SendMessage", - ctx, - dbChat, - mock.Anything, - ). - Return(nil) - }, - }, - } - - for _, tt := range table { - tt := tt - t.Run(tt.name, func(t *testing.T) { - tt.setupMocks() - - checker := &TwitchStreamChecker{ - services: &types.Services{ - Channel: channelsMock, - Twitch: twitchMock, - Stream: streamMock, - Follow: followMock, - I18N: i18nMock, - }, - sender: senderMock, - } - - checker.check(ctx) - - channelsMock.AssertExpectations(t) - twitchMock.AssertExpectations(t) - senderMock.AssertExpectations(t) - streamMock.AssertExpectations(t) - followMock.AssertExpectations(t) - - channelsMock.ExpectedCalls = nil - twitchMock.ExpectedCalls = nil - streamMock.ExpectedCalls = nil - senderMock.ExpectedCalls = nil - followMock.ExpectedCalls = nil - }) - } -} diff --git a/internal/twitchclient/twitchclient.go b/internal/twitchclient/twitchclient.go new file mode 100644 index 00000000..f94d7174 --- /dev/null +++ b/internal/twitchclient/twitchclient.go @@ -0,0 +1,30 @@ +package twitchclient + +import ( + "time" + + "github.com/satont/twitch-notifier/internal/domain" +) + +type TwitchClient interface { + GetChannelInformation(channelID string) (*domain.PlatformChannelInformation, error) + GetLiveStream(channelID string) (*Stream, error) +} + +type Stream struct { + ID string `json:"id"` + UserID string `json:"user_id"` + UserLogin string `json:"user_login"` + UserName string `json:"user_name"` + GameID string `json:"game_id"` + GameName string `json:"game_name"` + TagIDs []string `json:"tag_ids"` //nolint:tagliatelle + Tags []string `json:"tags"` + IsMature bool `json:"is_mature"` + Type string `json:"type"` + Title string `json:"title"` + ViewerCount int `json:"viewer_count"` + StartedAt time.Time `json:"started_at"` + Language string `json:"language"` + ThumbnailURL string `json:"thumbnail_url"` +} diff --git a/internal/twitchclient/twitchclientimpl/channel.go b/internal/twitchclient/twitchclientimpl/channel.go new file mode 100644 index 00000000..4e24404e --- /dev/null +++ b/internal/twitchclient/twitchclientimpl/channel.go @@ -0,0 +1,39 @@ +package twitchclientimpl + +import ( + "errors" + "fmt" + + "github.com/nicklaw5/helix/v2" + "github.com/satont/twitch-notifier/internal/domain" +) + +func (c *TwitchHelixImpl) GetChannelInformation(channelID string) ( + *domain.PlatformChannelInformation, + error, +) { + channelsReq, err := c.client.GetChannelInformation( + &helix.GetChannelInformationParams{ + BroadcasterIDs: []string{channelID}, + }, + ) + if err != nil { + return nil, err + } + if channelsReq.ErrorMessage != "" { + return nil, fmt.Errorf("twitch api error: %s", channelsReq.ErrorMessage) + } + + if len(channelsReq.Data.Channels) == 0 { + return nil, fmt.Errorf("channel not found: %w", errors.New("not found")) + } + + channel := channelsReq.Data.Channels[0] + return &domain.PlatformChannelInformation{ + BroadcasterID: channel.BroadcasterID, + BroadcasterName: channel.BroadcasterName, + GameName: channel.GameName, + Title: channel.Title, + ChannelLink: fmt.Sprintf("https://twitch.tv/%s", channel.BroadcasterName), + }, nil +} diff --git a/internal/twitch/helpers/rate_limiter.go b/internal/twitchclient/twitchclientimpl/helpers.go similarity index 55% rename from internal/twitch/helpers/rate_limiter.go rename to internal/twitchclient/twitchclientimpl/helpers.go index 0d4ac277..45233a0e 100644 --- a/internal/twitch/helpers/rate_limiter.go +++ b/internal/twitchclient/twitchclientimpl/helpers.go @@ -1,25 +1,28 @@ -package helpers +package twitchclientimpl import ( "fmt" - "github.com/nicklaw5/helix/v2" "time" + + "github.com/nicklaw5/helix/v2" ) -func RateLimitCallback(lastResponse *helix.Response) error { +func helixRateLimitCallback(lastResponse *helix.Response) error { if lastResponse.GetRateLimitRemaining() > 0 { return nil } - var reset64 int64 - reset64 = int64(lastResponse.GetRateLimitReset()) + reset64 := int64(lastResponse.GetRateLimitReset()) currentTime := time.Now().Unix() if currentTime < reset64 { timeDiff := time.Duration(reset64 - currentTime) if timeDiff > 0 { - fmt.Printf("Waiting on rate limit to pass before sending next request (%d seconds)\n", timeDiff) + fmt.Printf( + "Waiting on rate limit to pass before sending next request (%d seconds)\n", + timeDiff, + ) time.Sleep(timeDiff * time.Second) } } diff --git a/internal/twitchclient/twitchclientimpl/impl.go b/internal/twitchclient/twitchclientimpl/impl.go new file mode 100644 index 00000000..e22fb25f --- /dev/null +++ b/internal/twitchclient/twitchclientimpl/impl.go @@ -0,0 +1,37 @@ +package twitchclientimpl + +import ( + "github.com/nicklaw5/helix/v2" + "github.com/satont/twitch-notifier/internal/twitchclient" + "github.com/satont/twitch-notifier/pkg/config" + "go.uber.org/fx" +) + +type TwitchHelixImplOpts struct { + fx.In + + Config config.Config +} + +func New(opts TwitchHelixImplOpts) (*TwitchHelixImpl, error) { + client, err := helix.NewClient( + &helix.Options{ + ClientID: opts.Config.TwitchClientID, + ClientSecret: opts.Config.TwitchClientSecret, + RateLimitFunc: helixRateLimitCallback, + }, + ) + if err != nil { + return nil, err + } + + return &TwitchHelixImpl{ + client: client, + }, nil +} + +type TwitchHelixImpl struct { + client *helix.Client +} + +var _ twitchclient.TwitchClient = (*TwitchHelixImpl)(nil) diff --git a/internal/twitchclient/twitchclientimpl/stream.go b/internal/twitchclient/twitchclientimpl/stream.go new file mode 100644 index 00000000..f712ad4e --- /dev/null +++ b/internal/twitchclient/twitchclientimpl/stream.go @@ -0,0 +1,45 @@ +package twitchclientimpl + +import ( + "fmt" + + "github.com/nicklaw5/helix/v2" + "github.com/satont/twitch-notifier/internal/twitchclient" +) + +func (c *TwitchHelixImpl) GetLiveStream(channelID string) (*twitchclient.Stream, error) { + streamsReq, err := c.client.GetStreams( + &helix.StreamsParams{ + UserIDs: []string{channelID}, + }, + ) + if err != nil { + return nil, err + } + if streamsReq.ErrorMessage != "" { + return nil, fmt.Errorf("failed to get streams: %s", streamsReq.ErrorMessage) + } + + if len(streamsReq.Data.Streams) == 0 { + return nil, nil + } + + stream := streamsReq.Data.Streams[0] + return &twitchclient.Stream{ + ID: stream.ID, + UserID: stream.UserID, + UserLogin: stream.UserLogin, + UserName: stream.UserName, + GameID: stream.GameID, + GameName: stream.GameName, + TagIDs: stream.TagIDs, + Tags: stream.Tags, + IsMature: stream.IsMature, + Type: stream.Type, + Title: stream.Title, + ViewerCount: stream.ViewerCount, + StartedAt: stream.StartedAt, + Language: stream.Language, + ThumbnailURL: stream.ThumbnailURL, + }, nil +} diff --git a/internal/types/types.go b/internal/types/types.go deleted file mode 100644 index 451a3bd3..00000000 --- a/internal/types/types.go +++ /dev/null @@ -1,20 +0,0 @@ -package types - -import ( - "github.com/satont/twitch-notifier/internal/config" - db2 "github.com/satont/twitch-notifier/internal/db" - "github.com/satont/twitch-notifier/internal/message_sender" - "github.com/satont/twitch-notifier/internal/twitch" - "github.com/satont/twitch-notifier/pkg/i18n" -) - -type Services struct { - Config *config.Config - Twitch twitch.Interface - Chat db2.ChatInterface - Channel db2.ChannelInterface - Follow db2.FollowInterface - Stream db2.StreamInterface - I18N i18n.Interface - MessageSender message_sender.MessageSenderInterface -} diff --git a/locales/uk.json b/locales/ua.json similarity index 100% rename from locales/uk.json rename to locales/ua.json diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 00000000..52cfa09b --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,18 @@ +package config + +type Config struct { + PostgresUrl string + AppEnv string + TwitchClientID string + TwitchClientSecret string + TemporalUrl string +} + +func New() (*Config, error) { + return &Config{ + PostgresUrl: "", + TwitchClientID: "your-client-id", + TwitchClientSecret: "your-client-secret", + TemporalUrl: "", + }, nil +} diff --git a/pkg/i18n/helpers.go b/pkg/i18n/helpers.go deleted file mode 100644 index c53fdfef..00000000 --- a/pkg/i18n/helpers.go +++ /dev/null @@ -1,15 +0,0 @@ -package i18n - -func GetNested[T any](v any, keys ...string) (T, bool) { - res := v - for _, key := range keys { - mp, ok := res.(map[string]any) - if !ok { - var e T - return e, false - } - res = mp[key] - } - a, ok := res.(T) - return a, ok -} diff --git a/pkg/i18n/helpers_test.go b/pkg/i18n/helpers_test.go deleted file mode 100644 index f4d0e4e1..00000000 --- a/pkg/i18n/helpers_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package i18n - -import ( - "github.com/stretchr/testify/assert" - "testing" -) - -func TestGetNested(t *testing.T) { - t.Parallel() - - data := map[string]any{ - "foo": "bar", - } - - res, ok := GetNested[string](data, "foo") - assert.True(t, ok, "expected to get a value") - assert.Equal(t, "bar", res, "expected to get a value") - - res, ok = GetNested[string](data, "bar") - assert.False(t, ok, "expected to be false") - assert.Equal(t, "", res, "expected to not get a value") - - res, ok = GetNested[string](nil, "foo") - assert.False(t, ok, "expected to be false") - assert.Equal(t, "", res, "expected to not get a value") -} diff --git a/pkg/i18n/i18n.go b/pkg/i18n/i18n.go deleted file mode 100644 index be5ec86f..00000000 --- a/pkg/i18n/i18n.go +++ /dev/null @@ -1,81 +0,0 @@ -package i18n - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "text/template" -) - -type Interface interface { - Translate(key, language string, data map[string]string) string - GetLanguagesCodes() []string -} - -type I18n struct { - translations map[string]map[string]any -} - -var readFile = os.ReadFile -var readDir = os.ReadDir - -func NewI18n(localesPath string) (Interface, error) { - entries, err := os.ReadDir(localesPath) - if err != nil { - return nil, err - } - - translations := make(map[string]map[string]any) - - for _, entry := range entries { - if entry.IsDir() { - continue - } - - name := strings.Replace(entry.Name(), ".json", "", 1) - - fileContent := make(map[string]any) - f, err := readFile(filepath.Join(localesPath, entry.Name())) - if err != nil { - return nil, err - } - err = json.Unmarshal(f, &fileContent) - translations[name] = fileContent - } - - return &I18n{ - translations: translations, - }, nil -} - -func (i *I18n) Translate(key, language string, data map[string]string) string { - if data == nil { - data = make(map[string]string) - } - - str, _ := GetNested[string](i.translations[language], strings.Split(key, ".")...) - if str == "" { - str, _ = GetNested[string](i.translations["en"], strings.Split(key, ".")...) - } - - str = strings.ReplaceAll(str, "{{ ", "{{.") - - tmpl, err := template.New("t").Parse(str) - if err != nil { - return str - } - - res := &strings.Builder{} - _ = tmpl.Execute(res, data) - - return res.String() -} - -func (i *I18n) GetLanguagesCodes() []string { - var codes []string - for code, _ := range i.translations { - codes = append(codes, code) - } - return codes -} diff --git a/pkg/i18n/i18n_test.go b/pkg/i18n/i18n_test.go deleted file mode 100644 index 243429f0..00000000 --- a/pkg/i18n/i18n_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package i18n - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestNewI18n(t *testing.T) { - t.Parallel() - - wd, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - - localesPath := filepath.Join(wd, "test_locales") - - table := []struct { - translation string - lang string - data map[string]string - expected string - expectErr bool - localesPath string - patchReadFile bool - patchReadDir bool - }{ - { - translation: "hello", - lang: "en", - data: nil, - expected: "world", - localesPath: localesPath, - }, - { - translation: "nested.templated", - lang: "en", - data: map[string]string{ - "who": "world", - }, - expected: "hello world", - localesPath: localesPath, - }, - { - translation: "templated", - lang: "en", - data: map[string]string{ - "hello": "templated", - }, - expected: "hello templated", - localesPath: localesPath, - }, - { - translation: "expectEmptyString", - lang: "en", - data: nil, - expected: "", - localesPath: localesPath, - }, - { - translation: "expect error", - expectErr: true, - localesPath: "/tmp/somefreakingstupidnotifierlocalespath", - }, - { - translation: "expect readFile error", - expectErr: true, - patchReadFile: true, - }, - { - translation: "expect readDir error", - expectErr: true, - patchReadDir: true, - }, - } - - for _, tt := range table { - t.Run( - tt.translation, func(t *testing.T) { - if tt.patchReadFile { - readFile = func(string) ([]byte, error) { - return nil, os.ErrNotExist - } - defer func() { readFile = os.ReadFile }() - } - - if tt.patchReadDir { - readDir = func(string) ([]os.DirEntry, error) { - return nil, os.ErrNotExist - } - defer func() { readDir = os.ReadDir }() - } - - i18, err := NewI18n(tt.localesPath) - if tt.expectErr { - assert.Error(t, err) - return - } - - assert.Equal( - t, - tt.expected, - i18.Translate(tt.translation, tt.lang, tt.data), - ) - }, - ) - } -} - -func TestGetLanguagesCodes(t *testing.T) { - t.Parallel() - - wd, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - i18, err := NewI18n(filepath.Join(wd, "test_locales")) - - assert.NoError(t, err) - assert.Equal(t, []string{"en"}, i18.GetLanguagesCodes()) -} diff --git a/pkg/i18n/mocks/i18_mock.go b/pkg/i18n/mocks/i18_mock.go deleted file mode 100644 index 2d90f8cd..00000000 --- a/pkg/i18n/mocks/i18_mock.go +++ /dev/null @@ -1,21 +0,0 @@ -package i18nmocks - -import "github.com/stretchr/testify/mock" - -type I18nMock struct { - mock.Mock -} - -func (m *I18nMock) Translate(key, language string, data map[string]string) string { - args := m.Called(key, language, data) - return args.String(0) -} - -func (m *I18nMock) GetLanguagesCodes() []string { - args := m.Called() - return args.Get(0).([]string) -} - -func NewI18nMock() *I18nMock { - return &I18nMock{} -} diff --git a/pkg/i18n/test_locales/en.json b/pkg/i18n/test_locales/en.json deleted file mode 100644 index 14a35c1d..00000000 --- a/pkg/i18n/test_locales/en.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "hello": "world", - "templated": "hello {{ hello }}", - "nested": { - "templated": "hello {{ who }}" - } -} diff --git a/pkg/logger/fx.go b/pkg/logger/fx.go new file mode 100644 index 00000000..961effcc --- /dev/null +++ b/pkg/logger/fx.go @@ -0,0 +1,17 @@ +package logger + +import ( + "github.com/satont/twitch-notifier/pkg/config" + "go.uber.org/fx" +) + +var FxOption = fx.Annotate( + func(cfg config.Config) *Impl { + return New( + Opts{ + Env: cfg.AppEnv, + }, + ) + }, + fx.As(new(Logger)), +) diff --git a/pkg/logger/impl.go b/pkg/logger/impl.go new file mode 100644 index 00000000..92dd99f0 --- /dev/null +++ b/pkg/logger/impl.go @@ -0,0 +1,105 @@ +package logger + +import ( + "context" + "io" + "log/slog" + "os" + "runtime" + "time" + + "github.com/getsentry/sentry-go" + "github.com/rs/zerolog" + "github.com/rs/zerolog/pkgerrors" + slogmulti "github.com/samber/slog-multi" + slogsentry "github.com/samber/slog-sentry/v2" + slogzerolog "github.com/samber/slog-zerolog/v2" +) + +type Impl struct { + log *slog.Logger + + service string + sentry *sentry.Client +} + +type Opts struct { + Env string + + Sentry *sentry.Client + Level slog.Level +} + +var _ Logger = (*Impl)(nil) + +func New(opts Opts) *Impl { + level := opts.Level + + var zeroLogWriter io.Writer + if opts.Env == "production" { + zeroLogWriter = os.Stderr + } else { + zeroLogWriter = zerolog.ConsoleWriter{Out: os.Stderr} + } + + zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack + slogzerolog.SourceKey = "source" + slogzerolog.ErrorKeys = []string{"error", "err"} + zerolog.ErrorStackFieldName = "stack" + + zeroLogLogger := zerolog.New(zeroLogWriter) + + log := slog.New( + slogmulti.Fanout( + slogzerolog.Option{ + Level: level, + Logger: &zeroLogLogger, + AddSource: false, + }.NewZerologHandler(), + slogsentry.Option{Level: slog.LevelError, AddSource: true}.NewSentryHandler(), + ), + ) + + return &Impl{ + log: log, + sentry: opts.Sentry, + } +} + +func (c *Impl) handle(level slog.Level, input string, fields ...any) { + var pcs [1]uintptr + runtime.Callers(3, pcs[:]) + r := slog.NewRecord(time.Now(), level, input, pcs[0]) + for _, f := range fields { + r.Add(f) + } + _ = c.log.Handler().Handle(context.Background(), r) +} + +func (c *Impl) Info(input string, fields ...any) { + c.log.Info(input, fields...) +} + +func (c *Impl) Warn(input string, fields ...any) { + c.log.Warn(input, fields...) +} + +func (c *Impl) Error(input string, fields ...any) { + c.log.Error(input, fields...) +} + +func (c *Impl) Debug(input string, fields ...any) { + c.log.Debug(input, fields...) +} + +func (c *Impl) WithComponent(name string) Logger { + return &Impl{ + log: c.log.With(slog.String("component", name)), + sentry: c.sentry, + service: c.service, + } +} + +func (c *Impl) GetSlog() *slog.Logger { + return c.log +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 00000000..8207d2e6 --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,14 @@ +package logger + +import ( + "log/slog" +) + +type Logger interface { + Info(input string, fields ...any) + Error(input string, fields ...any) + Debug(input string, fields ...any) + Warn(input string, fields ...any) + WithComponent(name string) Logger + GetSlog() *slog.Logger +} diff --git a/tools/tools.go b/tools/tools.go new file mode 100644 index 00000000..20969582 --- /dev/null +++ b/tools/tools.go @@ -0,0 +1,6 @@ +//go:build tools +// +build tools + +package tools + +import _ "go.uber.org/mock/mockgen"