diff --git a/.env.example b/.env.example index cb1c587..2de40d6 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,6 @@ ENVIRONMENT= BOT_TOKEN= SENTRY_DSN= REDIS_URL= -DATABASE_URL= +POSTGRES_URL= MONGO_URL= TZ= diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index df4d192..0bc2ebe 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -73,7 +73,7 @@ jobs: run: go test -v -coverprofile=coverage.out -covermode=atomic ./... env: ENVIRONMENT: development - DATABASE_URL: postgres://postgres:password@db:5432/captcha?sslmode=disable + POSTGRES_URL: postgres://postgres:password@db:5432/captcha?sslmode=disable REDIS_URL: redis://@cache:6379/ MONGO_URL: mongodb://root:password@mongo:27017/captcha?useNewUrlParser=true&useUnifiedTopology=true&authSource=admin MONGO_DBNAME: captcha diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ace443d..d9a2dd4 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -73,7 +73,7 @@ jobs: run: go test -v -coverprofile=coverage.out -covermode=atomic ./... env: ENVIRONMENT: development - DATABASE_URL: postgres://postgres:password@db:5432/captcha?sslmode=disable + POSTGRES_URL: postgres://postgres:password@db:5432/captcha?sslmode=disable REDIS_URL: redis://@cache:6379/ MONGO_URL: mongodb://root:password@mongo:27017/captcha?useNewUrlParser=true&useUnifiedTopology=true&authSource=admin MONGO_DBNAME: captcha diff --git a/analytics/analytics.go b/analytics/analytics.go index b9660a2..736b177 100644 --- a/analytics/analytics.go +++ b/analytics/analytics.go @@ -9,10 +9,10 @@ import ( // Dependency is the dependency injection struct // for the analytics package. type Dependency struct { - Memory *bigcache.BigCache - Bot *tb.Bot - DB *sqlx.DB - TeknumID string + Memory *bigcache.BigCache + Bot *tb.Bot + DB *sqlx.DB + HomeGroupID int64 } // HourMapper is meant to use for mapping a time.Hour() to a string diff --git a/analytics/analytics_test.go b/analytics/analytics_test.go index 474c199..c895253 100644 --- a/analytics/analytics_test.go +++ b/analytics/analytics_test.go @@ -20,7 +20,7 @@ import ( var dependency *analytics.Dependency func TestMain(m *testing.M) { - databaseUrl, ok := os.LookupEnv("DATABASE_URL") + databaseUrl, ok := os.LookupEnv("POSTGRES_URL") if !ok { databaseUrl = "postgresql://postgres:password@localhost:5432/captcha?sslmode=disable" } @@ -48,9 +48,9 @@ func TestMain(m *testing.M) { } dependency = &analytics.Dependency{ - DB: db, - Memory: memory, - TeknumID: "123456789", + DB: db, + Memory: memory, + HomeGroupID: 123456789, } setupCtx, setupCancel := context.WithTimeout(context.Background(), time.Second*30) diff --git a/analytics/join.go b/analytics/join.go index d48299c..348421e 100644 --- a/analytics/join.go +++ b/analytics/join.go @@ -3,7 +3,6 @@ package analytics import ( "context" "database/sql" - "strconv" "time" "github.com/teknologi-umum/captcha/shared" @@ -21,7 +20,7 @@ import ( // reason, their data should still be here. But, their joined date // will be updated to their newest join date. func (d *Dependency) NewUser(ctx context.Context, m *tb.Message, user *tb.User) { - if !m.FromGroup() || strconv.FormatInt(m.Chat.ID, 10) != d.TeknumID { + if !m.FromGroup() || m.Chat.ID != d.HomeGroupID { return } diff --git a/analytics/msg.go b/analytics/msg.go index 92e44b7..f6f5390 100644 --- a/analytics/msg.go +++ b/analytics/msg.go @@ -2,7 +2,6 @@ package analytics import ( "context" - "strconv" "time" tb "gopkg.in/telebot.v3" @@ -16,7 +15,7 @@ func (d *Dependency) NewMessage(m *tb.Message) error { return nil } - if strconv.FormatInt(m.Chat.ID, 10) != d.TeknumID { + if m.Chat.ID != d.HomeGroupID { return nil } diff --git a/analytics/server/server_test.go b/analytics/server/server_test.go index 1d8bc0a..1f47221 100644 --- a/analytics/server/server_test.go +++ b/analytics/server/server_test.go @@ -34,7 +34,7 @@ func TestMain(m *testing.M) { mongoDbName = "captcha" } - databaseUrl, ok := os.LookupEnv("DATABASE_URL") + databaseUrl, ok := os.LookupEnv("POSTGRES_URL") if !ok { databaseUrl = "postgresql://postgres:password@localhost:5432/captcha?sslmode=disable" } diff --git a/analytics/swarm.go b/analytics/swarm.go index 8b2c108..4e8a0a8 100644 --- a/analytics/swarm.go +++ b/analytics/swarm.go @@ -4,7 +4,6 @@ import ( "context" "database/sql" "fmt" - "strconv" "time" "github.com/teknologi-umum/captcha/shared" @@ -14,7 +13,7 @@ import ( ) func (d *Dependency) SwarmLog(user *tb.User, groupID int64, finishedCaptcha bool) { - if strconv.FormatInt(groupID, 10) != d.TeknumID { + if groupID != d.HomeGroupID { return } @@ -70,7 +69,7 @@ func (d *Dependency) SwarmLog(user *tb.User, groupID int64, finishedCaptcha bool } func (d *Dependency) UpdateSwarm(user *tb.User, groupID int64, finishedCaptcha bool) { - if strconv.FormatInt(groupID, 10) != d.TeknumID { + if groupID != d.HomeGroupID { return } diff --git a/captcha/captcha.go b/captcha/captcha.go index a1b590a..2956487 100644 --- a/captcha/captcha.go +++ b/captcha/captcha.go @@ -1,8 +1,6 @@ package captcha import ( - "github.com/teknologi-umum/captcha/analytics" - "github.com/allegro/bigcache/v3" tb "gopkg.in/telebot.v3" ) @@ -10,8 +8,7 @@ import ( // Dependencies contains the dependency injection struct for // methods in the captcha package. type Dependencies struct { - Memory *bigcache.BigCache - Bot *tb.Bot - Analytics *analytics.Dependency - TeknumID string + Memory *bigcache.BigCache + Bot *tb.Bot + TeknumGroupID int64 } diff --git a/captcha/welcome.go b/captcha/welcome.go index 3d201b2..118cad2 100644 --- a/captcha/welcome.go +++ b/captcha/welcome.go @@ -70,7 +70,7 @@ func (d *Dependencies) sendWelcomeMessage(ctx context.Context, m *tb.Message) er var msgToSend string = regularWelcomeMessage - if strconv.FormatInt(m.Chat.ID, 10) == d.TeknumID { + if m.Chat.ID == d.TeknumGroupID { msgToSend = currentWelcomeMessages[randomNum()] } diff --git a/cmd/captcha/captcha.go b/cmd/captcha/captcha.go index 49e9415..4bf2b4e 100644 --- a/cmd/captcha/captcha.go +++ b/cmd/captcha/captcha.go @@ -3,22 +3,18 @@ package main import ( "context" "fmt" + "github.com/teknologi-umum/captcha/setir" "strconv" "strings" "time" + "github.com/getsentry/sentry-go" "github.com/teknologi-umum/captcha/analytics" "github.com/teknologi-umum/captcha/ascii" "github.com/teknologi-umum/captcha/badwords" "github.com/teknologi-umum/captcha/captcha" "github.com/teknologi-umum/captcha/shared" "github.com/teknologi-umum/captcha/underattack" - "github.com/teknologi-umum/captcha/utils" - - "github.com/allegro/bigcache/v3" - "github.com/getsentry/sentry-go" - "github.com/jmoiron/sqlx" - "go.mongodb.org/mongo-driver/mongo" tb "gopkg.in/telebot.v3" ) @@ -28,71 +24,47 @@ import ( // It will spread and use the correct dependencies for // each packages on the captcha project. type Dependency struct { - Memory *bigcache.BigCache - Bot *tb.Bot - DB *sqlx.DB - Mongo *mongo.Client - MongoDBName string - TeknumID string - AdminIDs []string FeatureFlag FeatureFlag - captcha *captcha.Dependencies - ascii *ascii.Dependencies - analytics *analytics.Dependency - badwords *badwords.Dependency - underAttack *underattack.Dependency + Captcha *captcha.Dependencies + Ascii *ascii.Dependencies + Analytics *analytics.Dependency + Badwords *badwords.Dependency + UnderAttack *underattack.Dependency + Setir *setir.Dependency } // New returns a pointer struct of Dependency // which map the incoming dependencies provided // into what's needed by each domain. -func New(deps Dependency) *Dependency { - analyticsDeps := &analytics.Dependency{ - Memory: deps.Memory, - Bot: deps.Bot, - DB: deps.DB, - TeknumID: deps.TeknumID, +func New(deps Dependency) (*Dependency, error) { + // Validate dependencies + if deps.Captcha == nil { + return nil, fmt.Errorf("captcha dependency is nil") + } + + if deps.FeatureFlag.UnderAttack && deps.UnderAttack == nil { + return nil, fmt.Errorf("under attack feature is enabled, but underattack dependency is nil") + } + + if deps.FeatureFlag.Analytics && deps.Analytics == nil { + return nil, fmt.Errorf("analytics feature is enabled, but analytics dependency is nil") } - return &Dependency{ - Memory: deps.Memory, - Bot: deps.Bot, - DB: deps.DB, - Mongo: deps.Mongo, - MongoDBName: deps.MongoDBName, - TeknumID: deps.TeknumID, - AdminIDs: deps.AdminIDs, - FeatureFlag: deps.FeatureFlag, - captcha: &captcha.Dependencies{ - Memory: deps.Memory, - Bot: deps.Bot, - Analytics: analyticsDeps, - TeknumID: deps.TeknumID, - }, - ascii: &ascii.Dependencies{ - Bot: deps.Bot, - }, - analytics: analyticsDeps, - badwords: &badwords.Dependency{ - Mongo: deps.Mongo, - MongoDBName: deps.MongoDBName, - AdminIDs: deps.AdminIDs, - }, - underAttack: &underattack.Dependency{ - Memory: deps.Memory, - DB: deps.DB, - Bot: deps.Bot, - }, + + if deps.FeatureFlag.BadwordsInsertion && deps.Badwords == nil { + return nil, fmt.Errorf("badwords insertion feature is enabled, but badwords dependency is nil") } + + return &deps, nil } // OnTextHandler handle any incoming text from the group func (d *Dependency) OnTextHandler(c tb.Context) error { ctx := sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()) - d.captcha.WaitForAnswer(ctx, c.Message()) + d.Captcha.WaitForAnswer(ctx, c.Message()) if d.FeatureFlag.Analytics { - err := d.analytics.NewMessage(c.Message()) + err := d.Analytics.NewMessage(c.Message()) if err != nil { shared.HandleError(ctx, err) } @@ -117,15 +89,15 @@ func (d *Dependency) OnUserJoinHandler(c tb.Context) error { ctx = span.Context() if d.FeatureFlag.UnderAttack { - underAttack, err := d.underAttack.AreWe(ctx, c.Chat().ID) + underAttack, err := d.UnderAttack.AreWe(ctx, c.Chat().ID) if err != nil { shared.HandleError(ctx, err) } if underAttack { - err := d.underAttack.Kicker(ctx, c) + err := d.UnderAttack.Kicker(ctx, c) if err != nil { - shared.HandleBotError(ctx, err, d.Bot, c.Message()) + shared.HandleBotError(ctx, err, c.Bot(), c.Message()) } return nil } @@ -139,10 +111,10 @@ func (d *Dependency) OnUserJoinHandler(c tb.Context) error { } if d.FeatureFlag.Analytics { - go d.analytics.NewUser(ctx, c.Message(), tempSender) + go d.Analytics.NewUser(ctx, c.Message(), tempSender) } - d.captcha.CaptchaUserJoin(ctx, c.Message()) + d.Captcha.CaptchaUserJoin(ctx, c.Message()) return nil } @@ -152,10 +124,10 @@ func (d *Dependency) OnUserJoinHandler(c tb.Context) error { func (d *Dependency) OnNonTextHandler(c tb.Context) error { ctx := sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()) - d.captcha.NonTextListener(ctx, c.Message()) + d.Captcha.NonTextListener(ctx, c.Message()) if d.FeatureFlag.Analytics { - err := d.analytics.NewMessage(c.Message()) + err := d.Analytics.NewMessage(c.Message()) if err != nil { shared.HandleError(ctx, err) } @@ -169,7 +141,7 @@ func (d *Dependency) OnNonTextHandler(c tb.Context) error { func (d *Dependency) OnUserLeftHandler(c tb.Context) error { ctx := sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()) - d.captcha.CaptchaUserLeave(ctx, c.Message()) + d.Captcha.CaptchaUserLeave(ctx, c.Message()) return nil } @@ -184,7 +156,7 @@ func (d *Dependency) BadWordHandler(c tb.Context) error { if !c.Message().Private() { return nil } - ok := d.badwords.Authenticate(strconv.FormatInt(c.Sender().ID, 10)) + ok := d.Badwords.Authenticate(strconv.FormatInt(c.Sender().ID, 10)) if !ok { return nil } @@ -194,7 +166,7 @@ func (d *Dependency) BadWordHandler(c tb.Context) error { ctx = sentry.SetHubOnContext(ctx, sentry.CurrentHub().Clone()) - err := d.badwords.AddBadWord(ctx, strings.TrimPrefix(c.Message().Text, "/badwords ")) + err := d.Badwords.AddBadWord(ctx, strings.TrimPrefix(c.Message().Text, "/badwords ")) if err != nil && !strings.Contains(err.Error(), "duplicate key error collection") { shared.HandleBotError(ctx, err, c.Bot(), c.Message()) return nil @@ -216,7 +188,7 @@ func (d *Dependency) EnableUnderAttackModeHandler(c tb.Context) error { ctx := sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()) - return d.underAttack.EnableUnderAttackModeHandler(ctx, c) + return d.UnderAttack.EnableUnderAttackModeHandler(ctx, c) } // DisableUnderAttackModeHandler provides a handler for /disableunderattack command. @@ -227,102 +199,11 @@ func (d *Dependency) DisableUnderAttackModeHandler(c tb.Context) error { ctx := sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()) - return d.underAttack.DisableUnderAttackModeHandler(ctx, c) + return d.UnderAttack.DisableUnderAttackModeHandler(ctx, c) } func (d *Dependency) SetirHandler(c tb.Context) error { - admin := d.AdminIDs - if !utils.IsIn(admin, strconv.FormatInt(c.Sender().ID, 10)) || c.Chat().Type != tb.ChatPrivate { - return nil - } - - home, err := strconv.ParseInt(d.TeknumID, 10, 64) - if err != nil { - return fmt.Errorf("parsing teknum id: %w", err) - } - - if c.Message().IsReply() { - var replyToID int - - if strings.HasPrefix(c.Message().Payload, "https://t.me/") { - replyToID, err = strconv.Atoi(strings.Split(c.Message().Payload, "/")[4]) - if err != nil { - return err - } - } else { - replyToID, err = strconv.Atoi(c.Message().Payload) - if err != nil { - return err - } - } - - _, err = d.Bot.Send(tb.ChatID(home), c.Message().ReplyTo.Text, &tb.SendOptions{ - ParseMode: tb.ModeHTML, - AllowWithoutReply: true, - ReplyTo: &tb.Message{ - ID: replyToID, - Chat: &tb.Chat{ - ID: int64(home), - }, - }, - }) - if err != nil { - _, err = d.Bot.Send(c.Chat(), "Failed sending that message: "+err.Error()) - if err != nil { - return fmt.Errorf("failed sending that message: %w", err) - } - } else { - _, err = d.Bot.Send(c.Chat(), "Message sent") - if err != nil { - return fmt.Errorf("sending message: %w", err) - } - } - - return nil - } - - if strings.HasPrefix(c.Message().Payload, "https://") { - var toBeSent interface{} - if strings.HasSuffix(c.Message().Payload, ".jpg") || strings.HasSuffix(c.Message().Payload, ".png") || strings.HasSuffix(c.Message().Payload, ".jpeg") { - toBeSent = &tb.Photo{File: tb.FromURL(c.Message().Payload)} - } else if strings.HasSuffix(c.Message().Payload, ".gif") { - toBeSent = &tb.Animation{File: tb.FromURL(c.Message().Payload)} - } else { - return nil - } - - _, err = d.Bot.Send(tb.ChatID(home), toBeSent, &tb.SendOptions{AllowWithoutReply: true}) - if err != nil { - _, e := d.Bot.Send(c.Message().Chat, "Failed sending that photo: "+err.Error()) - if e != nil { - return fmt.Errorf("sending message: %w", e) - } - - return fmt.Errorf("sending photo: %w", err) - } - - _, err = d.Bot.Send(c.Chat(), "Photo sent") - if err != nil { - return fmt.Errorf("sending message that says 'photo sent': %w", err) - } - return nil - - } - - _, err = d.Bot.Send(tb.ChatID(home), c.Message().Payload, &tb.SendOptions{ParseMode: tb.ModeHTML, AllowWithoutReply: true}) - if err != nil { - _, e := d.Bot.Send(c.Chat(), "Failed sending that message: "+err.Error()) - if e != nil { - return fmt.Errorf("sending message: %w", e) - } - - return fmt.Errorf("sending message: %w", err) - } - - _, err = d.Bot.Send(c.Chat(), "Message sent") - if err != nil { - return fmt.Errorf("sending message: %w", err) - } + ctx := sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()) - return nil + return d.Setir.Handler(ctx, c) } diff --git a/cmd/captcha/configuration.go b/cmd/captcha/configuration.go index 537342f..d6e813a 100644 --- a/cmd/captcha/configuration.go +++ b/cmd/captcha/configuration.go @@ -18,7 +18,7 @@ type Configuration struct { Environment string `yaml:"environment" json:"environment" toml:"environment" env:"ENVIRONMENT" env-default:"production"` BotToken string `yaml:"bot_token" json:"bot_token" toml:"bot_token" env:"BOT_TOKEN" env-required:"true"` FeatureFlag FeatureFlag `yaml:"feature_flag" json:"feature_flag"` - TeknumId string `yaml:"teknum_id" json:"teknum_id" env:"TEKNUM_ID"` + HomeGroupID int64 `yaml:"home_group_id" json:"home_group_id" env:"HOME_GROUP_ID"` AdminIds []string `yaml:"admin_ids" json:"admin_ids" env:"ADMIN_IDS"` SentryDSN string `yaml:"sentry_dsn" json:"sentry_dsn" env:"SENTRY_DSN"` Database struct { @@ -29,6 +29,9 @@ type Configuration struct { ListeningHost string `yaml:"listening_host" json:"listening_host" env:"HTTP_HOST"` ListeningPort string `yaml:"listening_port" json:"listening_port" env:"HTTP_PORT" env-default:"8080"` } + UnderAttack struct { + DatastoreProvider string `yaml:"datastore_provider" json:"datastore_provider" env:"UNDER_ATTACK__DATASTORE_PROVIDER" env-default:"memory"` + } } func ParseConfiguration(configurationFilePath string) (Configuration, error) { diff --git a/cmd/captcha/main.go b/cmd/captcha/main.go index 7cd3a78..3528ab1 100644 --- a/cmd/captcha/main.go +++ b/cmd/captcha/main.go @@ -17,6 +17,12 @@ package main import ( "context" "fmt" + "github.com/teknologi-umum/captcha/ascii" + "github.com/teknologi-umum/captcha/badwords" + "github.com/teknologi-umum/captcha/captcha" + "github.com/teknologi-umum/captcha/setir" + "github.com/teknologi-umum/captcha/underattack" + "github.com/teknologi-umum/captcha/underattack/datastore" "log" "net" "net/http" @@ -30,8 +36,6 @@ import ( "github.com/teknologi-umum/captcha/analytics" "github.com/teknologi-umum/captcha/analytics/server" "github.com/teknologi-umum/captcha/shared" - "github.com/teknologi-umum/captcha/underattack" - // Database and cache "github.com/allegro/bigcache/v3" "github.com/jmoiron/sqlx" @@ -64,7 +68,7 @@ func main() { SampleRate: 1.0, EnableTracing: true, TracesSampleRate: 0.2, - ProfilesSampleRate: 0.1, + ProfilesSampleRate: 0.05, Release: version, }) if err != nil { @@ -73,7 +77,7 @@ func main() { defer sentry.Flush(30 * time.Second) var db *sqlx.DB - if configuration.FeatureFlag.Analytics || configuration.FeatureFlag.UnderAttack { + if configuration.FeatureFlag.Analytics || (configuration.FeatureFlag.UnderAttack && configuration.UnderAttack.DatastoreProvider == "postgres") { // Connect to PostgreSQL db, err = sqlx.Open("postgres", configuration.Database.PostgresUrl) if err != nil { @@ -137,7 +141,7 @@ func main() { MaxEntriesInWindow: 50, }) if err != nil { - log.Fatal("during creating a in memory cache:", errors.WithStack(err)) + log.Fatal("during creating a in memory cache: ", errors.WithStack(err)) } defer func(cache *bigcache.BigCache) { err := cache.Close() @@ -146,18 +150,6 @@ func main() { } }(cache) - if db != nil { - // Running migration on database first. - err = analytics.MustMigrate(db) - if err != nil { - log.Fatal("during initial database migration:", errors.WithStack(err)) - } - err = underattack.MustMigrate(db) - if err != nil { - log.Fatal("during initial database migration:", errors.WithStack(err)) - } - } - // Setup Telegram Bot b, err := tb.NewBot(tb.Settings{ Token: configuration.BotToken, @@ -208,14 +200,103 @@ func main() { } }() - deps := New(Dependency{ - Memory: cache, - Bot: b, - DB: db, - Mongo: mongoClient, - MongoDBName: mongoDBName, - TeknumID: configuration.TeknumId, + var analyticsDependency *analytics.Dependency + if configuration.FeatureFlag.Analytics { + // Check if database is initialized + if db == nil { + log.Println("To enable analytics, database must been set") + return + } + err = analytics.MustMigrate(db) + if err != nil { + sentry.CaptureException(err) + log.Fatal("during initial database migration: ", errors.WithStack(err)) + } + + analyticsDependency = &analytics.Dependency{ + Memory: cache, + Bot: b, + DB: db, + HomeGroupID: configuration.HomeGroupID, + } + } + + var badwordsDependency *badwords.Dependency + if configuration.FeatureFlag.BadwordsInsertion { + // Check if mongodb is initialized + if mongoClient == nil && mongoDBName == "" { + log.Println("To enable badwords insertion, mongodb mnust been set") + return + } + + badwordsDependency = &badwords.Dependency{ + Mongo: mongoClient, + MongoDBName: mongoDBName, + AdminIDs: configuration.AdminIds, + } + } + + var underAttackDependency *underattack.Dependency + if configuration.FeatureFlag.UnderAttack { + var underAttackDatastore underattack.Datastore + switch configuration.UnderAttack.DatastoreProvider { + case "postgres": + underAttackDatastore, err = datastore.NewPostgresDatastore(db.DB) + if err != nil { + log.Fatal(err) + return + } + case "memory": + fallthrough + default: + underAttackDatastore, err = datastore.NewInMemoryDatastore(cache) + if err != nil { + log.Fatal(err) + return + } + } + + // Migrate datastore while we're here + err = underAttackDatastore.Migrate(context.Background()) + if err != nil { + sentry.CaptureException(err) + log.Fatal(err) + return + } + + underAttackDependency = &underattack.Dependency{ + Datastore: underAttackDatastore, + Memory: cache, + Bot: b, + } + } + + var setirDependency *setir.Dependency + setirDependency, err = setir.New(b, configuration.AdminIds, configuration.HomeGroupID) + if err != nil { + sentry.CaptureException(err) + log.Fatal(err) + return + } + + program, err := New(Dependency{ + FeatureFlag: configuration.FeatureFlag, + Captcha: &captcha.Dependencies{ + Memory: cache, + Bot: b, + TeknumGroupID: configuration.HomeGroupID, + }, + Ascii: &ascii.Dependencies{Bot: b}, + Analytics: analyticsDependency, + Badwords: badwordsDependency, + UnderAttack: underAttackDependency, + Setir: setirDependency, }) + if err != nil { + sentry.CaptureException(err) + log.Fatal(err) + return + } httpServer := server.New(server.Config{ DB: db, @@ -239,26 +320,26 @@ func main() { }) // Captcha handlers - b.Handle(tb.OnUserJoined, deps.OnUserJoinHandler) - b.Handle(tb.OnText, deps.OnTextHandler) - b.Handle(tb.OnPhoto, deps.OnNonTextHandler) - b.Handle(tb.OnAnimation, deps.OnNonTextHandler) - b.Handle(tb.OnVideo, deps.OnNonTextHandler) - b.Handle(tb.OnDocument, deps.OnNonTextHandler) - b.Handle(tb.OnSticker, deps.OnNonTextHandler) - b.Handle(tb.OnVoice, deps.OnNonTextHandler) - b.Handle(tb.OnVideoNote, deps.OnNonTextHandler) - b.Handle(tb.OnUserLeft, deps.OnUserLeftHandler) + b.Handle(tb.OnUserJoined, program.OnUserJoinHandler) + b.Handle(tb.OnText, program.OnTextHandler) + b.Handle(tb.OnPhoto, program.OnNonTextHandler) + b.Handle(tb.OnAnimation, program.OnNonTextHandler) + b.Handle(tb.OnVideo, program.OnNonTextHandler) + b.Handle(tb.OnDocument, program.OnNonTextHandler) + b.Handle(tb.OnSticker, program.OnNonTextHandler) + b.Handle(tb.OnVoice, program.OnNonTextHandler) + b.Handle(tb.OnVideoNote, program.OnNonTextHandler) + b.Handle(tb.OnUserLeft, program.OnUserLeftHandler) // Under attack handlers - b.Handle("/underattack", deps.EnableUnderAttackModeHandler) - b.Handle("/disableunderattack", deps.DisableUnderAttackModeHandler) + b.Handle("/underattack", program.EnableUnderAttackModeHandler) + b.Handle("/disableunderattack", program.DisableUnderAttackModeHandler) // Bad word handlers - b.Handle("/badwords", deps.BadWordHandler) + b.Handle("/badwords", program.BadWordHandler) // - b.Handle("/setir", deps.SetirHandler) + b.Handle("/setir", program.SetirHandler) exitSignal := make(chan os.Signal, 1) signal.Notify(exitSignal, os.Interrupt) diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..ce46df7 --- /dev/null +++ b/doc.go @@ -0,0 +1,18 @@ +// Teknologi Umum Captcha Bot +// Copyright (C) 2023 Teknologi Umum +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +// Package captcha provides a captcha bot for Telegram messaging app +package captcha diff --git a/setir/setir.go b/setir/setir.go new file mode 100644 index 0000000..efd4ecc --- /dev/null +++ b/setir/setir.go @@ -0,0 +1,129 @@ +package setir + +import ( + "context" + "fmt" + "github.com/getsentry/sentry-go" + tb "gopkg.in/telebot.v3" + "strconv" + "strings" +) + +type Dependency struct { + AdminIDs []string + HomeID int64 + Bot *tb.Bot +} + +func New(bot *tb.Bot, adminIDs []string, homeID int64) (*Dependency, error) { + if bot == nil { + return nil, fmt.Errorf("bot is nil") + } + + if len(adminIDs) == 0 { + return nil, fmt.Errorf("invalid admin ids, empty value") + } + + return &Dependency{ + AdminIDs: adminIDs, + HomeID: homeID, + Bot: bot, + }, nil +} + +func (d *Dependency) Handler(ctx context.Context, c tb.Context) (err error) { + if c.Message().IsReply() { + var replyToID int + + if strings.HasPrefix(c.Message().Payload, "https://t.me/") { + replyToID, err = strconv.Atoi(strings.Split(c.Message().Payload, "/")[4]) + if err != nil { + sentry.GetHubFromContext(ctx).CaptureException(err) + return nil + } + } else { + replyToID, err = strconv.Atoi(c.Message().Payload) + if err != nil { + sentry.GetHubFromContext(ctx).CaptureException(err) + return nil + } + } + + _, err = d.Bot.Send(tb.ChatID(d.HomeID), c.Message().ReplyTo.Text, &tb.SendOptions{ + ParseMode: tb.ModeHTML, + AllowWithoutReply: true, + ReplyTo: &tb.Message{ + ID: replyToID, + Chat: &tb.Chat{ + ID: d.HomeID, + }, + }, + }) + if err != nil { + _, err = d.Bot.Send(c.Chat(), "Failed sending that message: "+err.Error()) + if err != nil { + sentry.GetHubFromContext(ctx).CaptureException(fmt.Errorf("failed sending that message: %w", err)) + return nil + } + } else { + _, err = d.Bot.Send(c.Chat(), "Message sent") + if err != nil { + sentry.GetHubFromContext(ctx).CaptureException(fmt.Errorf("sending message: %w", err)) + return nil + + } + } + + return nil + } + + if strings.HasPrefix(c.Message().Payload, "https://") { + var toBeSent interface{} + if strings.HasSuffix(c.Message().Payload, ".jpg") || strings.HasSuffix(c.Message().Payload, ".png") || strings.HasSuffix(c.Message().Payload, ".jpeg") { + toBeSent = &tb.Photo{File: tb.FromURL(c.Message().Payload)} + } else if strings.HasSuffix(c.Message().Payload, ".gif") { + toBeSent = &tb.Animation{File: tb.FromURL(c.Message().Payload)} + } else { + return nil + } + + _, err = d.Bot.Send(tb.ChatID(d.HomeID), toBeSent, &tb.SendOptions{AllowWithoutReply: true}) + if err != nil { + _, e := d.Bot.Send(c.Message().Chat, "Failed sending that photo: "+err.Error()) + if e != nil { + sentry.GetHubFromContext(ctx).CaptureException(fmt.Errorf("sending message: %w", e)) + return nil + } + + sentry.GetHubFromContext(ctx).CaptureException(fmt.Errorf("sending photo: %w", err)) + return nil + } + + _, err = d.Bot.Send(c.Chat(), "Photo sent") + if err != nil { + return fmt.Errorf("sending message that says 'photo sent': %w", err) + } + return nil + + } + + _, err = d.Bot.Send(tb.ChatID(d.HomeID), c.Message().Payload, &tb.SendOptions{ParseMode: tb.ModeHTML, AllowWithoutReply: true}) + if err != nil { + _, e := d.Bot.Send(c.Chat(), "Failed sending that message: "+err.Error()) + if e != nil { + sentry.GetHubFromContext(ctx).CaptureException(fmt.Errorf("sending message: %w", e)) + return nil + } + + sentry.GetHubFromContext(ctx).CaptureException(fmt.Errorf("sending message: %w", err)) + return nil + } + + _, err = d.Bot.Send(c.Chat(), "Message sent") + if err != nil { + sentry.GetHubFromContext(ctx).CaptureException(fmt.Errorf("sending message: %w", err)) + return nil + } + + return nil +} diff --git a/shared/error.go b/shared/error.go index 9d45754..fd4eeaa 100644 --- a/shared/error.go +++ b/shared/error.go @@ -44,12 +44,6 @@ func HandleBotError(ctx context.Context, e error, bot *tb.Bot, m *tb.Message) { log.Println(e) } - _, err := bot.Send( - m.Chat, - "Oh no, something went wrong with me! Can you guys help me to ping my masters?", - &tb.SendOptions{ParseMode: tb.ModeHTML}, - ) - hub := sentry.GetHubFromContext(ctx) if hub == nil { sentry.CaptureException(errors.WithStack(e)) @@ -68,12 +62,17 @@ func HandleBotError(ctx context.Context, e error, bot *tb.Bot, m *tb.Message) { "unix": m.Unixtime, }) + hub.CaptureException(errors.WithStack(e)) + + _, err := bot.Send( + m.Chat, + "Oh no, something went wrong with me! Can you guys help me to ping my masters?", + &tb.SendOptions{ParseMode: tb.ModeHTML}, + ) if err != nil { // Come on? Another error? hub.CaptureException(errors.WithStack(err)) } - - hub.CaptureException(errors.WithStack(e)) } // HandleHttpError handles error that has a http.Request struct instance diff --git a/underattack/are_we.go b/underattack/are_we.go index bdca8b3..d802e8d 100644 --- a/underattack/are_we.go +++ b/underattack/are_we.go @@ -13,17 +13,17 @@ import ( // AreWe ...on under attack mode? func (d *Dependency) AreWe(ctx context.Context, chatID int64) (bool, error) { - span := sentry.StartSpan(ctx, "underattack.are_we", sentry.WithTransactionName("Are we under attack?")) + span := sentry.StartSpan(ctx, "UnderAttack.are_we", sentry.WithTransactionName("Are we under attack?")) defer span.Finish() ctx = span.Context() - underAttackCache, err := d.Memory.Get("underattack:" + strconv.FormatInt(chatID, 10)) + underAttackCache, err := d.Memory.Get("UnderAttack:" + strconv.FormatInt(chatID, 10)) if err != nil && !errors.Is(err, bigcache.ErrEntryNotFound) { return false, err } if err == nil { - var entry underattack + var entry UnderAttack err := json.Unmarshal(underAttackCache, &entry) if err != nil { return false, err @@ -32,7 +32,7 @@ func (d *Dependency) AreWe(ctx context.Context, chatID int64) (bool, error) { return entry.IsUnderAttack && entry.ExpiresAt.After(time.Now()), nil } - underAttackEntry, err := d.GetUnderAttackEntry(ctx, chatID) + underAttackEntry, err := d.Datastore.GetUnderAttackEntry(ctx, chatID) if err != nil { return false, err } @@ -42,7 +42,7 @@ func (d *Dependency) AreWe(ctx context.Context, chatID int64) (bool, error) { return false, err } - err = d.Memory.Set("underattack:"+strconv.FormatInt(chatID, 10), marshaledEntry) + err = d.Memory.Set("UnderAttack:"+strconv.FormatInt(chatID, 10), marshaledEntry) if err != nil { return false, err } diff --git a/underattack/are_we_test.go b/underattack/are_we_test.go index b38741b..8ccbef4 100644 --- a/underattack/are_we_test.go +++ b/underattack/are_we_test.go @@ -10,6 +10,11 @@ func TestAreWe(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) defer cancel() + err := dependency.Datastore.SetUnderAttackStatus(ctx, 1, true, time.Now().Add(time.Hour), 0) + if err != nil { + t.Fatalf("setting under attack status: %s", err.Error()) + } + attacked, err := dependency.AreWe(ctx, 1) if err != nil { t.Errorf("unexpected error: %v", err) diff --git a/underattack/datastore.go b/underattack/datastore.go new file mode 100644 index 0000000..acbeb6c --- /dev/null +++ b/underattack/datastore.go @@ -0,0 +1,14 @@ +package underattack + +import ( + "context" + "time" +) + +type Datastore interface { + Migrate(ctx context.Context) error + GetUnderAttackEntry(ctx context.Context, groupID int64) (UnderAttack, error) + CreateNewEntry(ctx context.Context, groupID int64) error + SetUnderAttackStatus(ctx context.Context, groupID int64, underAttack bool, expiresAt time.Time, notificationMessageID int64) error + Close() error +} diff --git a/underattack/datastore/datastore_test.go b/underattack/datastore/datastore_test.go new file mode 100644 index 0000000..7c28474 --- /dev/null +++ b/underattack/datastore/datastore_test.go @@ -0,0 +1,13 @@ +package datastore_test + +import ( + "github.com/getsentry/sentry-go" + "os" + "testing" +) + +func TestMain(m *testing.M) { + _ = sentry.Init(sentry.ClientOptions{}) + + os.Exit(m.Run()) +} diff --git a/underattack/datastore/memory.go b/underattack/datastore/memory.go new file mode 100644 index 0000000..fd2fb33 --- /dev/null +++ b/underattack/datastore/memory.go @@ -0,0 +1,114 @@ +package datastore + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/getsentry/sentry-go" + "github.com/teknologi-umum/captcha/underattack" + "strconv" + "time" + + "github.com/allegro/bigcache/v3" +) + +type memoryDatastore struct { + db *bigcache.BigCache +} + +func NewInMemoryDatastore(db *bigcache.BigCache) (underattack.Datastore, error) { + if db == nil { + return nil, fmt.Errorf("nil db") + } + + return &memoryDatastore{db: db}, nil +} + +func (m *memoryDatastore) Migrate(ctx context.Context) error { + // Nothing to migrate + return nil +} + +func (m *memoryDatastore) GetUnderAttackEntry(ctx context.Context, groupID int64) (underattack.UnderAttack, error) { + span := sentry.StartSpan(ctx, "memory_datastore.get_under_attack_entry") + defer span.Finish() + ctx = span.Context() + + value, err := m.db.Get(strconv.FormatInt(groupID, 10)) + if err != nil { + if errors.Is(err, bigcache.ErrEntryNotFound) { + go func(groupID int64) { + ctx := sentry.SetHubOnContext(context.Background(), sentry.GetHubFromContext(ctx)) + + time.Sleep(time.Second * 5) + ctx, cancel := context.WithTimeout(ctx, time.Second*15) + defer cancel() + + err := m.CreateNewEntry(ctx, groupID) + if err != nil { + sentry.GetHubFromContext(ctx).CaptureException(err) + } + }(groupID) + + return underattack.UnderAttack{}, nil + } + + return underattack.UnderAttack{}, err + } + + var entry underattack.UnderAttack + err = json.Unmarshal(value, &entry) + if err != nil { + return underattack.UnderAttack{}, err + } + + return entry, nil +} + +func (m *memoryDatastore) CreateNewEntry(ctx context.Context, groupID int64) error { + span := sentry.StartSpan(ctx, "memory_datastore.create_new_entry") + defer span.Finish() + + if _, err := m.db.Get(strconv.FormatInt(groupID, 10)); err != nil { + // Do nothing if already exists + return nil + } + + // Set a new one if not exists + value, err := json.Marshal(underattack.UnderAttack{ + GroupID: groupID, + IsUnderAttack: false, + NotificationMessageID: 0, + ExpiresAt: time.Time{}, + UpdatedAt: time.Now(), + }) + if err != nil { + return err + } + + return m.db.Set(strconv.FormatInt(groupID, 10), value) +} + +func (m *memoryDatastore) SetUnderAttackStatus(ctx context.Context, groupID int64, underAttack bool, expiresAt time.Time, notificationMessageID int64) error { + span := sentry.StartSpan(ctx, "memory_datastore.set_under_attack_status") + defer span.Finish() + + // Set a new one if not exists + value, err := json.Marshal(underattack.UnderAttack{ + GroupID: groupID, + IsUnderAttack: underAttack, + NotificationMessageID: notificationMessageID, + ExpiresAt: expiresAt, + UpdatedAt: time.Now(), + }) + if err != nil { + return err + } + + return m.db.Set(strconv.FormatInt(groupID, 10), value) +} + +func (m *memoryDatastore) Close() error { + return m.db.Close() +} diff --git a/underattack/datastore/memory_test.go b/underattack/datastore/memory_test.go new file mode 100644 index 0000000..29d1d08 --- /dev/null +++ b/underattack/datastore/memory_test.go @@ -0,0 +1,133 @@ +package datastore_test + +import ( + "context" + "encoding/json" + "github.com/teknologi-umum/captcha/underattack" + "github.com/teknologi-umum/captcha/underattack/datastore" + "testing" + "time" + + "github.com/allegro/bigcache/v3" +) + +func TestNewInMemoryDatastore(t *testing.T) { + var dependency underattack.Datastore + + db, err := bigcache.New(context.Background(), bigcache.DefaultConfig(time.Hour)) + if err != nil { + t.Fatalf("Creating bigcache instance: %s", err.Error()) + } + + dependency, err = datastore.NewInMemoryDatastore(db) + if err != nil { + t.Fatalf("creating new postgres datastore: %s", err.Error()) + } + + setupCtx, setupCancel := context.WithTimeout(context.Background(), time.Second*30) + + err = dependency.Migrate(setupCtx) + if err != nil { + t.Fatalf("migrating tables: %s", err.Error()) + } + + err = SeedMemoryDatastore(setupCtx, db) + if err != nil { + t.Fatalf("seeding data: %s", err.Error()) + } + + t.Cleanup(func() { + setupCancel() + + err := dependency.Close() + if err != nil { + t.Logf("closing postgres database: %s", err.Error()) + } + }) + + t.Run("NewInMemoryDatastore", func(t *testing.T) { + t.Run("Nil DB", func(t *testing.T) { + _, err := datastore.NewInMemoryDatastore(nil) + if err.Error() != "nil db" { + t.Errorf("expecting an error of 'nil db', instead got %s", err.Error()) + } + }) + }) + + t.Run("Migrate", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + + err := dependency.Migrate(ctx) + if err != nil { + t.Errorf("migrating database: %s", err.Error()) + } + }) + + t.Run("GetUnderAttackEntry", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + + entry, err := dependency.GetUnderAttackEntry(ctx, 1) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if entry.IsUnderAttack == false { + t.Error("expecting IsUnderAttack to be true, got false") + } + + if entry.ExpiresAt.Before(time.Now()) { + t.Errorf("expecting ExpiresAt to be after now, got: %v", entry.ExpiresAt) + } + + if entry.NotificationMessageID != 1002 { + t.Errorf("expecting NotificationMessageID to be 1002, got: %v", entry.NotificationMessageID) + } + }) + + t.Run("GetUnderAttackEntry_NotExists", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + + _, err := dependency.GetUnderAttackEntry(ctx, 20) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("CreateNewEntry", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + + err := dependency.CreateNewEntry(ctx, 2) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("SetUnderAttackStatus", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + + err := dependency.SetUnderAttackStatus(ctx, 3, true, time.Now().Add(time.Minute*30), 1003) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) +} + +func SeedMemoryDatastore(ctx context.Context, db *bigcache.BigCache) error { + value, err := json.Marshal(underattack.UnderAttack{ + GroupID: 1, + IsUnderAttack: true, + NotificationMessageID: 1002, + ExpiresAt: time.Now().Add(time.Hour), + UpdatedAt: time.Now(), + }) + if err != nil { + return err + } + + return db.Set("1", value) +} diff --git a/underattack/datastore/postgres.go b/underattack/datastore/postgres.go new file mode 100644 index 0000000..1db7156 --- /dev/null +++ b/underattack/datastore/postgres.go @@ -0,0 +1,287 @@ +package datastore + +import ( + "context" + "database/sql" + "errors" + "fmt" + "github.com/getsentry/sentry-go" + "github.com/teknologi-umum/captcha/underattack" + "time" +) + +type postgresDatastore struct { + db *sql.DB +} + +func NewPostgresDatastore(db *sql.DB) (underattack.Datastore, error) { + if db == nil { + return nil, fmt.Errorf("nil db") + } + + return &postgresDatastore{db: db}, nil +} + +// Migrate will migrates database tables for under attack domain. +func (p *postgresDatastore) Migrate(ctx context.Context) error { + c, err := p.db.Conn(ctx) + if err != nil { + return err + } + defer func() { + err := c.Close() + if err != nil && !errors.Is(err, sql.ErrConnDone) { + sentry.GetHubFromContext(ctx).CaptureException(err) + } + }() + + tx, err := c.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return err + } + + _, err = tx.ExecContext( + ctx, + `CREATE TABLE IF NOT EXISTS under_attack ( + group_id BIGINT PRIMARY KEY, + is_under_attack BOOLEAN NOT NULL, + expires_at TIMESTAMP NOT NULL, + notification_message_id BIGINT NOT NULL, + updated_at TIMESTAMP NOT NULL + )`, + ) + if err != nil { + if e := tx.Rollback(); e != nil { + return err + } + + return err + } + _, err = tx.ExecContext( + ctx, + `CREATE INDEX IF NOT EXISTS idx_updated_at ON under_attack (updated_at)`, + ) + if err != nil { + if e := tx.Rollback(); e != nil { + return err + } + + return err + } + + err = tx.Commit() + if err != nil { + if e := tx.Rollback(); e != nil { + return err + } + + return err + } + + return nil +} + +// GetUnderAttackEntry will acquire under attack entry for specified groupID. +func (p *postgresDatastore) GetUnderAttackEntry(ctx context.Context, groupID int64) (underattack.UnderAttack, error) { + span := sentry.StartSpan(ctx, "postgres_datastore.get_under_attack_entry") + defer span.Finish() + + c, err := p.db.Conn(ctx) + if err != nil { + return underattack.UnderAttack{}, err + } + defer func() { + err := c.Close() + if err != nil && !errors.Is(err, sql.ErrConnDone) { + sentry.GetHubFromContext(ctx).CaptureException(err) + } + }() + + tx, err := c.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted, ReadOnly: true}) + if err != nil { + return underattack.UnderAttack{}, err + } + + var entry underattack.UnderAttack + + err = tx.QueryRowContext( + ctx, + `SELECT + group_id, + is_under_attack, + expires_at, + notification_message_id, + updated_at + FROM + under_attack + WHERE + group_id = $1 + ORDER BY + updated_at DESC`, + groupID, + ).Scan( + &entry.GroupID, + &entry.IsUnderAttack, + &entry.ExpiresAt, + &entry.NotificationMessageID, + &entry.UpdatedAt, + ) + if err != nil { + if e := tx.Rollback(); e != nil { + return underattack.UnderAttack{}, e + } + + if errors.Is(err, sql.ErrNoRows) { + go func(groupID int64) { + ctx := sentry.SetHubOnContext(context.Background(), sentry.GetHubFromContext(ctx)) + time.Sleep(time.Second * 5) + ctx, cancel := context.WithTimeout(ctx, time.Second*15) + defer cancel() + + err := p.CreateNewEntry(ctx, groupID) + if err != nil { + sentry.GetHubFromContext(ctx).CaptureException(err) + } + }(groupID) + + return underattack.UnderAttack{}, nil + } + + return underattack.UnderAttack{}, err + } + + err = tx.Commit() + if err != nil { + if e := tx.Rollback(); e != nil { + return underattack.UnderAttack{}, e + } + + return underattack.UnderAttack{}, err + } + + return entry, nil +} + +// CreateNewEntry will create a new entry for given groupID. +// This should only be executed if the group entry does not exists on the database. +// If it already exists, it will do nothing. +func (p *postgresDatastore) CreateNewEntry(ctx context.Context, groupID int64) error { + span := sentry.StartSpan(ctx, "postgres_datastore.create_new_entry") + defer span.Finish() + + c, err := p.db.Conn(ctx) + if err != nil { + return err + } + defer func() { + err := c.Close() + if err != nil && !errors.Is(err, sql.ErrConnDone) { + sentry.GetHubFromContext(ctx).CaptureException(err) + } + }() + + tx, err := c.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + return err + } + + _, err = tx.ExecContext( + ctx, + `INSERT INTO + under_attack + (group_id, is_under_attack, expires_at, notification_message_id, updated_at) + VALUES + ($1, $2, $3, $4, $5) + ON CONFLICT (group_id) + DO NOTHING`, + groupID, + false, + time.Time{}, + 0, + time.Now(), + ) + if err != nil { + if e := tx.Rollback(); e != nil { + return e + } + + return err + } + + err = tx.Commit() + if err != nil { + if e := tx.Rollback(); e != nil { + return e + } + + return err + } + + return nil +} + +// SetUnderAttackStatus will update the given groupID entry to the given parameters. +// If the groupID entry does not exists, it will create a new one. +func (p *postgresDatastore) SetUnderAttackStatus(ctx context.Context, groupID int64, underAttack bool, expiresAt time.Time, notificationMessageID int64) error { + span := sentry.StartSpan(ctx, "postgres_datastore.set_under_attack_status") + defer span.Finish() + + c, err := p.db.Conn(ctx) + if err != nil { + return err + } + defer func() { + err := c.Close() + if err != nil && !errors.Is(err, sql.ErrConnDone) { + sentry.GetHubFromContext(ctx).CaptureException(err) + } + }() + + tx, err := c.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + return err + } + + _, err = tx.ExecContext( + ctx, + `INSERT INTO + under_attack + (group_id, is_under_attack, expires_at, notification_message_id, updated_at) + VALUES + ($1, $2, $3, $4, $5) + ON CONFLICT (group_id) + DO UPDATE + SET + is_under_attack = $2, + expires_at = $3, + notification_message_id = $4, + updated_at = $5`, + groupID, + underAttack, + expiresAt, + notificationMessageID, + time.Now(), + ) + if err != nil { + if e := tx.Rollback(); e != nil { + return e + } + + return err + } + + err = tx.Commit() + if err != nil { + if e := tx.Rollback(); e != nil { + return e + } + + return err + } + + return nil +} + +func (p *postgresDatastore) Close() error { + return p.db.Close() +} diff --git a/underattack/datastore/postgres_test.go b/underattack/datastore/postgres_test.go new file mode 100644 index 0000000..25138dc --- /dev/null +++ b/underattack/datastore/postgres_test.go @@ -0,0 +1,174 @@ +package datastore_test + +import ( + "context" + "database/sql" + "github.com/teknologi-umum/captcha/underattack" + "github.com/teknologi-umum/captcha/underattack/datastore" + "log" + "os" + "testing" + "time" + + _ "github.com/lib/pq" +) + +func TestPostgresDatastore(t *testing.T) { + var dependency underattack.Datastore + postgresUrl, ok := os.LookupEnv("POSTGRES_URL") + if !ok { + postgresUrl = "postgres://captcha:password@localhost:5432/captcha?sslmode=disable" + } + + db, err := sql.Open("postgres", postgresUrl) + if err != nil { + t.Fatalf("opening postgres: %s", err.Error()) + } + + dependency, err = datastore.NewPostgresDatastore(db) + if err != nil { + t.Fatalf("creating new postgres datastore: %s", err.Error()) + } + + setupCtx, setupCancel := context.WithTimeout(context.Background(), time.Second*30) + + err = dependency.Migrate(setupCtx) + if err != nil { + t.Fatalf("migrating tables: %s", err.Error()) + } + + err = SeedPostgres(setupCtx, db) + if err != nil { + t.Fatalf("seeding data: %s", err.Error()) + } + + t.Cleanup(func() { + setupCancel() + + err := dependency.Close() + if err != nil { + t.Logf("closing postgres database: %s", err.Error()) + } + }) + + t.Run("NewPostgresDatastore", func(t *testing.T) { + t.Run("Nil DB", func(t *testing.T) { + _, err := datastore.NewPostgresDatastore(nil) + if err.Error() != "nil db" { + t.Errorf("expecting an error of 'nil db', instead got %s", err.Error()) + } + }) + }) + + t.Run("Migrate", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + + err := dependency.Migrate(ctx) + if err != nil { + t.Errorf("migrating database: %s", err.Error()) + } + }) + + t.Run("GetUnderAttackEntry", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + + entry, err := dependency.GetUnderAttackEntry(ctx, 1) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if entry.IsUnderAttack == false { + t.Error("expecting IsUnderAttack to be true, got false") + } + + if entry.ExpiresAt.Before(time.Now()) { + t.Errorf("expecting ExpiresAt to be after now, got: %v", entry.ExpiresAt) + } + + if entry.NotificationMessageID != 1002 { + t.Errorf("expecting NotificationMessageID to be 1002, got: %v", entry.NotificationMessageID) + } + }) + + t.Run("GetUnderAttackEntry_NotExists", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + + _, err := dependency.GetUnderAttackEntry(ctx, 20) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("CreateNewEntry", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + + err := dependency.CreateNewEntry(ctx, 2) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("SetUnderAttackStatus", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + + err := dependency.SetUnderAttackStatus(ctx, 3, true, time.Now().Add(time.Minute*30), 1003) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) +} + +func SeedPostgres(ctx context.Context, db *sql.DB) error { + c, err := db.Conn(ctx) + if err != nil { + return err + } + defer func() { + err := c.Close() + if err != nil { + log.Print(err) + } + }() + + tx, err := c.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) + if err != nil { + return err + } + + _, err = tx.ExecContext( + ctx, + `INSERT INTO + under_attack + (group_id, is_under_attack, expires_at, notification_message_id, updated_at) + VALUES + ($1, $2, $3, $4, $5)`, + 1, + true, + time.Now().Add(time.Hour*1), + 1002, + time.Now(), + ) + if err != nil { + if e := tx.Rollback(); e != nil { + return e + } + + return err + } + + err = tx.Commit() + if err != nil { + if e := tx.Rollback(); e != nil { + return e + } + + return err + } + + return nil +} diff --git a/underattack/handler.go b/underattack/handler.go index ab19df4..edbd4fe 100644 --- a/underattack/handler.go +++ b/underattack/handler.go @@ -13,7 +13,7 @@ import ( tb "gopkg.in/telebot.v3" ) -// EnableUnderAttackModeHandler provides a handler for /underattack command. +// EnableUnderAttackModeHandler provides a handler for /UnderAttack command. func (d *Dependency) EnableUnderAttackModeHandler(ctx context.Context, c tb.Context) error { if c.Message().Private() || c.Sender().IsBot { return nil @@ -27,7 +27,7 @@ func (d *Dependency) EnableUnderAttackModeHandler(ctx context.Context, c tb.Cont sentry.GetHubFromContext(ctx).AddBreadcrumb(&sentry.Breadcrumb{ Type: "user", Category: "command.triggered", - Message: "/underattack", + Message: "/UnderAttack", Data: map[string]interface{}{ "user": c.Sender(), "chat": c.Chat(), @@ -183,13 +183,13 @@ func (d *Dependency) EnableUnderAttackModeHandler(ctx context.Context, c tb.Cont break } - err = d.SetUnderAttackStatus(ctx, c.Chat().ID, true, time.Now().Add(time.Minute*30), int64(notificationMessage.ID)) + err = d.Datastore.SetUnderAttackStatus(ctx, c.Chat().ID, true, time.Now().Add(time.Minute*30), int64(notificationMessage.ID)) if err != nil { shared.HandleBotError(ctx, err, d.Bot, c.Message()) return nil } - err = d.Memory.Delete("underattack:" + strconv.FormatInt(c.Chat().ID, 10)) + err = d.Memory.Delete("UnderAttack:" + strconv.FormatInt(c.Chat().ID, 10)) if err != nil { shared.HandleBotError(ctx, err, d.Bot, c.Message()) return nil @@ -203,7 +203,7 @@ func (d *Dependency) EnableUnderAttackModeHandler(ctx context.Context, c tb.Cont sentry.GetHubFromContext(ctx).AddBreadcrumb(&sentry.Breadcrumb{ Type: "debug", - Category: "underattack.state", + Category: "UnderAttack.state", Message: "Under attack mode is enabled", Data: map[string]interface{}{ "user": c.Sender(), @@ -219,7 +219,7 @@ func (d *Dependency) EnableUnderAttackModeHandler(ctx context.Context, c tb.Cont sentry.GetHubFromContext(ctx).AddBreadcrumb(&sentry.Breadcrumb{ Type: "debug", - Category: "underattack.state", + Category: "UnderAttack.state", Message: "Under attack mode ends", Data: map[string]interface{}{ "user": c.Sender(), @@ -293,19 +293,19 @@ func (d *Dependency) DisableUnderAttackModeHandler(ctx context.Context, c tb.Con return nil } - underAttackEntry, err := d.GetUnderAttackEntry(ctx, c.Chat().ID) + underAttackEntry, err := d.Datastore.GetUnderAttackEntry(ctx, c.Chat().ID) if err != nil { shared.HandleBotError(ctx, err, d.Bot, c.Message()) return nil } - err = d.SetUnderAttackStatus(ctx, c.Chat().ID, false, time.Now(), 0) + err = d.Datastore.SetUnderAttackStatus(ctx, c.Chat().ID, false, time.Now(), 0) if err != nil { shared.HandleBotError(ctx, err, d.Bot, c.Message()) return nil } - err = d.Memory.Delete("underattack:" + strconv.FormatInt(c.Chat().ID, 10)) + err = d.Memory.Delete("UnderAttack:" + strconv.FormatInt(c.Chat().ID, 10)) if err != nil { shared.HandleBotError(ctx, err, d.Bot, c.Message()) return nil @@ -319,7 +319,7 @@ func (d *Dependency) DisableUnderAttackModeHandler(ctx context.Context, c tb.Con sentry.GetHubFromContext(ctx).AddBreadcrumb(&sentry.Breadcrumb{ Type: "debug", - Category: "underattack.state", + Category: "UnderAttack.state", Message: "Under attack mode is disabled", Data: map[string]interface{}{ "user": c.Sender(), diff --git a/underattack/kicker.go b/underattack/kicker.go index fbcc33d..b7f20a7 100644 --- a/underattack/kicker.go +++ b/underattack/kicker.go @@ -12,9 +12,9 @@ import ( ) func (d *Dependency) Kicker(ctx context.Context, c tb.Context) error { - span := sentry.StartSpan(ctx, "underattack.kicker") + span := sentry.StartSpan(ctx, "UnderAttack.kicker") defer span.Finish() - + for { err := c.Bot().Ban(c.Chat(), &tb.ChatMember{User: c.Sender(), RestrictedUntil: tb.Forever()}) if err != nil { diff --git a/underattack/migration.go b/underattack/migration.go deleted file mode 100644 index ac66642..0000000 --- a/underattack/migration.go +++ /dev/null @@ -1,81 +0,0 @@ -package underattack - -import ( - "context" - "database/sql" - "errors" - "time" - - "github.com/teknologi-umum/captcha/shared" - - "github.com/jmoiron/sqlx" -) - -// MustMigrate creates a dependency struct and a context that will execute the Migrate() function -func MustMigrate(db *sqlx.DB) error { - d := &Dependency{DB: db} - - ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) - defer cancel() - - return d.Migrate(ctx) -} - -// Migrate will migrates database tables for under attack domain. -func (d *Dependency) Migrate(ctx context.Context) error { - c, err := d.DB.Conn(ctx) - if err != nil { - return err - } - defer func() { - err := c.Close() - if err != nil && !errors.Is(err, sql.ErrConnDone) { - shared.HandleError(ctx, err) - } - }() - - tx, err := c.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) - if err != nil { - return err - } - - _, err = tx.ExecContext( - ctx, - `CREATE TABLE IF NOT EXISTS under_attack ( - group_id BIGINT PRIMARY KEY, - is_under_attack BOOLEAN NOT NULL, - expires_at TIMESTAMP NOT NULL, - notification_message_id BIGINT NOT NULL, - updated_at TIMESTAMP NOT NULL - )`, - ) - if err != nil { - if e := tx.Rollback(); e != nil { - return err - } - - return err - } - _, err = tx.ExecContext( - ctx, - `CREATE INDEX IF NOT EXISTS idx_updated_at ON under_attack (updated_at)`, - ) - if err != nil { - if e := tx.Rollback(); e != nil { - return err - } - - return err - } - - err = tx.Commit() - if err != nil { - if e := tx.Rollback(); e != nil { - return err - } - - return err - } - - return nil -} diff --git a/underattack/repo.go b/underattack/repo.go deleted file mode 100644 index 5f3161f..0000000 --- a/underattack/repo.go +++ /dev/null @@ -1,191 +0,0 @@ -package underattack - -import ( - "context" - "database/sql" - "errors" - "time" - - "github.com/getsentry/sentry-go" - "github.com/teknologi-umum/captcha/shared" -) - -// GetUnderAttackEntry will acquire under attack entry for specified groupID. -func (d *Dependency) GetUnderAttackEntry(ctx context.Context, groupID int64) (underattack, error) { - span := sentry.StartSpan(ctx, "underattack.get_under_attack_entry") - defer span.Finish() - - c, err := d.DB.Connx(ctx) - if err != nil { - return underattack{}, err - } - defer func() { - err := c.Close() - if err != nil && !errors.Is(err, sql.ErrConnDone) { - shared.HandleError(ctx, err) - } - }() - - tx, err := c.BeginTxx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted, ReadOnly: true}) - if err != nil { - return underattack{}, err - } - - var entry underattack - - err = tx.QueryRowxContext( - ctx, - "SELECT * FROM under_attack WHERE group_id = $1 ORDER BY updated_at DESC", - groupID, - ).StructScan(&entry) - if err != nil { - if e := tx.Rollback(); e != nil { - return underattack{}, e - } - - if errors.Is(err, sql.ErrNoRows) { - go func(groupID int64) { - time.Sleep(time.Second * 5) - ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) - defer cancel() - - err := d.CreateNewEntry(ctx, groupID) - if err != nil { - shared.HandleError(ctx, err) - } - }(groupID) - - return underattack{}, nil - } - - return underattack{}, err - } - - err = tx.Commit() - if err != nil { - if e := tx.Rollback(); e != nil { - return underattack{}, e - } - - return underattack{}, err - } - - return entry, nil -} - -// CreateNewEntry will create a new entry for given groupID. -// This should only be executed if the group entry does not exists on the database. -// If it already exists, it will do nothing. -func (d *Dependency) CreateNewEntry(ctx context.Context, groupID int64) error { - c, err := d.DB.Connx(ctx) - if err != nil { - return err - } - defer func() { - err := c.Close() - if err != nil && !errors.Is(err, sql.ErrConnDone) { - shared.HandleError(ctx, err) - } - }() - - tx, err := c.BeginTxx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) - if err != nil { - return err - } - - _, err = tx.ExecContext( - ctx, - `INSERT INTO - under_attack - (group_id, is_under_attack, expires_at, notification_message_id, updated_at) - VALUES - ($1, $2, $3, $4, $5) - ON CONFLICT (group_id) - DO NOTHING`, - groupID, - false, - time.Time{}, - 0, - time.Now(), - ) - if err != nil { - if e := tx.Rollback(); e != nil { - return e - } - - return err - } - - err = tx.Commit() - if err != nil { - if e := tx.Rollback(); e != nil { - return e - } - - return err - } - - return nil -} - -// SetUnderAttackStatus will update the given groupID entry to the given parameters. -// If the groupID entry does not exists, it will create a new one. -func (d *Dependency) SetUnderAttackStatus(ctx context.Context, groupID int64, underAttack bool, expiresAt time.Time, notificationMessageID int64) error { - span := sentry.StartSpan(ctx, "underattack.set_under_attack_status") - defer span.Finish() - - c, err := d.DB.Connx(ctx) - if err != nil { - return err - } - defer func() { - err := c.Close() - if err != nil && !errors.Is(err, sql.ErrConnDone) { - shared.HandleError(ctx, err) - } - }() - - tx, err := c.BeginTxx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) - if err != nil { - return err - } - - _, err = tx.ExecContext( - ctx, - `INSERT INTO - under_attack - (group_id, is_under_attack, expires_at, notification_message_id, updated_at) - VALUES - ($1, $2, $3, $4, $5) - ON CONFLICT (group_id) - DO UPDATE - SET - is_under_attack = $2, - expires_at = $3, - notification_message_id = $4, - updated_at = $5`, - groupID, - underAttack, - expiresAt, - notificationMessageID, - time.Now(), - ) - if err != nil { - if e := tx.Rollback(); e != nil { - return e - } - - return err - } - - err = tx.Commit() - if err != nil { - if e := tx.Rollback(); e != nil { - return e - } - - return err - } - - return nil -} diff --git a/underattack/repo_test.go b/underattack/repo_test.go deleted file mode 100644 index 7262d25..0000000 --- a/underattack/repo_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package underattack_test - -import ( - "context" - "testing" - "time" -) - -func TestGetUnderAttackEntry(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) - defer cancel() - - entry, err := dependency.GetUnderAttackEntry(ctx, 1) - if err != nil { - t.Errorf("unexpected error: %v", err) - } - - if entry.IsUnderAttack == false { - t.Error("expecting IsUnderAttack to be true, got false") - } - - if entry.ExpiresAt.Before(time.Now()) { - t.Errorf("expecting ExpiresAt to be after now, got: %v", entry.ExpiresAt) - } - - if entry.NotificationMessageID != 1002 { - t.Errorf("expecting NotificationMessageID to be 1002, got: %v", entry.NotificationMessageID) - } -} - -func TestGetUnderAttackEntry_NotExists(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) - defer cancel() - - _, err := dependency.GetUnderAttackEntry(ctx, 20) - if err != nil { - t.Errorf("unexpected error: %v", err) - } -} - -func TestCreateNewEntry(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) - defer cancel() - - err := dependency.CreateNewEntry(ctx, 2) - if err != nil { - t.Errorf("unexpected error: %v", err) - } -} - -func TestSetUnderAttackStatus(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) - defer cancel() - - err := dependency.SetUnderAttackStatus(ctx, 3, true, time.Now().Add(time.Minute*30), 1003) - if err != nil { - t.Errorf("unexpected error: %v", err) - } -} diff --git a/underattack/underattack.go b/underattack/underattack.go index b77d112..49e5f3e 100644 --- a/underattack/underattack.go +++ b/underattack/underattack.go @@ -4,21 +4,20 @@ import ( "time" "github.com/allegro/bigcache/v3" - "github.com/jmoiron/sqlx" tb "gopkg.in/telebot.v3" ) // Dependency contains the dependency injection struct -// for methods in the underattack package +// for methods in the UnderAttack package type Dependency struct { - Memory *bigcache.BigCache - DB *sqlx.DB - Bot *tb.Bot + Datastore Datastore + Memory *bigcache.BigCache + Bot *tb.Bot } -// underattack provides a data struct to interact with +// UnderAttack provides a data struct to interact with // the database table. -type underattack struct { +type UnderAttack struct { GroupID int64 `db:"group_id"` IsUnderAttack bool `db:"is_under_attack"` NotificationMessageID int64 `db:"notification_message_id"` diff --git a/underattack/underattack_test.go b/underattack/underattack_test.go index 00e073c..13bebc9 100644 --- a/underattack/underattack_test.go +++ b/underattack/underattack_test.go @@ -2,8 +2,8 @@ package underattack_test import ( "context" - "database/sql" "github.com/getsentry/sentry-go" + "github.com/teknologi-umum/captcha/underattack/datastore" "log" "os" "testing" @@ -19,7 +19,7 @@ import ( var dependency *underattack.Dependency func TestMain(m *testing.M) { - databaseUrl, ok := os.LookupEnv("DATABASE_URL") + databaseUrl, ok := os.LookupEnv("POSTGRES_URL") if !ok { databaseUrl = "postgresql://postgres:password@localhost:5432/captcha?sslmode=disable" } @@ -39,23 +39,23 @@ func TestMain(m *testing.M) { log.Fatal(err) } - err = underattack.MustMigrate(db) + _ = sentry.Init(sentry.ClientOptions{}) + + memoryDatastore, err := datastore.NewInMemoryDatastore(memory) if err != nil { log.Fatal(err) } - _ = sentry.Init(sentry.ClientOptions{}) - dependency = &underattack.Dependency{ - Memory: memory, - DB: db, - Bot: nil, + Memory: memory, + Datastore: memoryDatastore, + Bot: nil, } setupCtx, setupCancel := context.WithTimeout(context.Background(), time.Second*30) defer setupCancel() - err = Seed(setupCtx) + err = dependency.Datastore.Migrate(setupCtx) if err != nil { log.Fatal(err) } @@ -74,53 +74,3 @@ func TestMain(m *testing.M) { os.Exit(exitCode) } - -func Seed(ctx context.Context) error { - c, err := dependency.DB.Conn(ctx) - if err != nil { - return err - } - defer func() { - err := c.Close() - if err != nil { - log.Print(err) - } - }() - - tx, err := c.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) - if err != nil { - return err - } - - _, err = tx.ExecContext( - ctx, - `INSERT INTO - under_attack - (group_id, is_under_attack, expires_at, notification_message_id, updated_at) - VALUES - ($1, $2, $3, $4, $5)`, - 1, - true, - time.Now().Add(time.Hour*1), - 1002, - time.Now(), - ) - if err != nil { - if e := tx.Rollback(); e != nil { - return e - } - - return err - } - - err = tx.Commit() - if err != nil { - if e := tx.Rollback(); e != nil { - return e - } - - return err - } - - return nil -}