From a84108f701672f4afe58993ca6d4b3dbdf7534a3 Mon Sep 17 00:00:00 2001 From: Yashar Hosseinpour Date: Sat, 4 May 2024 18:04:27 +0200 Subject: [PATCH] Implement short deep links to start the bot Store CreatedAt for users Use sqids library to generate short links using a random LinkKey number and CreatedAt unix timestamps Update guregu/dynamo library --- .github/workflows/main.yml | 2 +- README.md | 2 +- bot/common/init.go | 6 ++++ bot/common/misc.go | 63 ++++++++++++++++++++++++++++---------- bot/common/user.go | 10 ++++-- bot/common/user_repo.go | 19 ++++++++++-- bot/go.mod | 3 +- bot/go.sum | 6 ++-- infra/main.tf | 23 ++++++++++++-- infra/variables.tf | 5 +++ 10 files changed, 110 insertions(+), 29 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 21a60f3..ff8ff74 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -62,7 +62,7 @@ jobs: - name: Terraform apply run: | cd infra - terraform apply -auto-approve -var aws_region=${{ vars.AWS_REGION }} -var lambda_bucket=${{ vars.S3_LAMBDA_BUCKET }} -var bot_token=${{ secrets.BOT_TOKEN }} + terraform apply -auto-approve -var aws_region=${{ vars.AWS_REGION }} -var lambda_bucket=${{ vars.S3_LAMBDA_BUCKET }} -var bot_token=${{ secrets.BOT_TOKEN }} -var sqids_alphabet=${{ secrets.SQIDS_ALPHABET }} - name: Set webhook URL shell: bash diff --git a/README.md b/README.md index b1dbb3f..7bedeb0 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Initialize the main Terraform stack: ```shell cd infra -AWS_PROFILE= init -backend-config backend_config.hcl # Run once +AWS_PROFILE= terraform init -backend-config backend_config.hcl # Run once ``` #### Deploy diff --git a/bot/common/init.go b/bot/common/init.go index 178cd15..c928d89 100644 --- a/bot/common/init.go +++ b/bot/common/init.go @@ -27,6 +27,12 @@ func InitBot(request APIRequest) (APIResponse, error) { return APIResponse{StatusCode: 500}, errors.New("TOKEN environment variable is empty") } + // Get sqids alphabet from the environment variable. + alphabet := os.Getenv("SQIDS_ALPHABET") + if alphabet == "" { + return APIResponse{StatusCode: 500}, errors.New("SQIDS_ALPHABET environment variable is empty") + } + // Create bot from environment value. b, err := gotgbot.NewBot(token, &gotgbot.BotOpts{ BotClient: &gotgbot.BaseBotClient{ diff --git a/bot/common/misc.go b/bot/common/misc.go index cb8a12b..fa7ca3d 100644 --- a/bot/common/misc.go +++ b/bot/common/misc.go @@ -5,6 +5,8 @@ import ( "github.com/PaulSonOfLars/gotgbot/v2" "github.com/PaulSonOfLars/gotgbot/v2/ext" "github.com/bugfloyd/anonymous-telegram-bot/common/i18n" + "github.com/sqids/sqids-go" + "os" "strings" ) @@ -36,7 +38,6 @@ func (r *RootHandler) start(b *gotgbot.Bot, ctx *ext.Context) error { return nil } if len(args) == 2 && args[0] == "/start" { - var err error var receiverUser *User var identity string @@ -44,14 +45,26 @@ func (r *RootHandler) start(b *gotgbot.Bot, ctx *ext.Context) error { if strings.HasPrefix(args[1], "_") { username := args[1][1:] receiverUser, err = r.userRepo.readUserByUsername(username) + if err != nil { + return fmt.Errorf("failed to retrieve the link owner: %w", err) + } + identity = receiverUser.Username } else { - receiverUser, err = r.userRepo.readUserByUUID(args[1]) + linkKey, createdAt, err := readUserLinkKey(args[1]) + if err != nil { + return fmt.Errorf("failed to read the link key: %w", err) + } + receiverUser, err = r.userRepo.readUserByLinkKey(linkKey, createdAt) + if err != nil { + return fmt.Errorf("failed to retrieve the link owner: %w", err) + } + identity = args[1] } - if err != nil || receiverUser == nil { + if receiverUser == nil { _, err = b.SendMessage(ctx.EffectiveChat.Id, i18n.T(i18n.UserNotFoundText), &gotgbot.SendMessageOpts{}) if err != nil { - return fmt.Errorf("failed to send bot info: %w", err) + return fmt.Errorf("failed to send wrong link response: %w", err) } return nil } @@ -106,14 +119,6 @@ func (r *RootHandler) start(b *gotgbot.Bot, ctx *ext.Context) error { return fmt.Errorf("failed to update user state: %w", err) } - if receiverUser.Name != "" { - identity = receiverUser.Name - } else if receiverUser.Username != "" { - identity = receiverUser.Username - } else { - identity = receiverUser.UUID - } - _, err = b.SendMessage(ctx.EffectiveChat.Id, fmt.Sprintf(i18n.T(i18n.InitialSendMessagePromptText), identity), &gotgbot.SendMessageOpts{}) if err != nil { return fmt.Errorf("failed to send bot info: %w", err) @@ -136,15 +141,24 @@ func (r *RootHandler) info(b *gotgbot.Bot, ctx *ext.Context) error { } func (r *RootHandler) getLink(b *gotgbot.Bot, ctx *ext.Context) error { + alphabet := os.Getenv("SQIDS_ALPHABET") + s, _ := sqids.New(sqids.Options{ + Alphabet: alphabet, + }) + genericLinkKey, err := s.Encode([]uint64{uint64(r.user.LinkKey), uint64(r.user.CreatedAt.Unix())}) + if err != nil { + return err + } + var link string if r.user.Username != "" { usernameLink := fmt.Sprintf("https://t.me/%s?start=_%s", b.User.Username, r.user.Username) - uuidLink := fmt.Sprintf("https://t.me/%s?start=%s", b.User.Username, r.user.UUID) - link = fmt.Sprintf("%s\n%s\n\n%s\n\n%s", i18n.T(i18n.LinkText), usernameLink, i18n.T(i18n.OrText), uuidLink) + genericLink := fmt.Sprintf("https://t.me/%s?start=%s", b.User.Username, genericLinkKey) + link = fmt.Sprintf("%s\n%s\n\n%s\n\n%s", i18n.T(i18n.LinkText), usernameLink, i18n.T(i18n.OrText), genericLink) } else { - link = fmt.Sprintf("https://t.me/%s?start=%s", b.User.Username, r.user.UUID) + link = fmt.Sprintf("https://t.me/%s?start=%s", b.User.Username, genericLinkKey) } - _, err := ctx.EffectiveMessage.Reply(b, link, nil) + _, err = ctx.EffectiveMessage.Reply(b, link, nil) if err != nil { return err } @@ -174,3 +188,20 @@ func (r *RootHandler) sendError(b *gotgbot.Bot, ctx *ext.Context, message string } return nil } + +func readUserLinkKey(link string) (int32, int64, error) { + alphabet := os.Getenv("SQIDS_ALPHABET") + s, err := sqids.New(sqids.Options{ + Alphabet: alphabet, + }) + + if err != nil { + return 0, 0, fmt.Errorf("failed to read user link key: %w", err) + } + + numbers := s.Decode(link) + if len(numbers) != 2 { + return 0, 0, fmt.Errorf("failed to read user link key") + } + return int32(numbers[0]), int64(numbers[1]), nil +} diff --git a/bot/common/user.go b/bot/common/user.go index 42525a4..6f2cd60 100644 --- a/bot/common/user.go +++ b/bot/common/user.go @@ -1,17 +1,21 @@ package common -import "github.com/bugfloyd/anonymous-telegram-bot/common/i18n" +import ( + "github.com/bugfloyd/anonymous-telegram-bot/common/i18n" + "time" +) type User struct { UUID string `dynamo:",hash"` UserID int64 `index:"UserID-GSI,hash"` Username string `index:"Username-GSI,hash"` State State - Name string - Blacklist []string `dynamo:",set,omitempty"` + Blacklist []string `dynamo:",set,omitempty,omitemptyelem"` ContactUUID string `dynamo:",omitempty"` ReplyMessageID int64 `dynamo:",omitempty"` Language i18n.Language `dynamo:",omitempty"` + LinkKey int32 `index:"LinkKey-GSI,hash"` + CreatedAt time.Time `dynamo:",unixtime" index:"LinkKey-GSI,range"` } type State string diff --git a/bot/common/user_repo.go b/bot/common/user_repo.go index ad33166..e1fdee5 100644 --- a/bot/common/user_repo.go +++ b/bot/common/user_repo.go @@ -2,7 +2,9 @@ package common import ( "fmt" + "math/rand" "os" + "time" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" @@ -37,9 +39,11 @@ func NewUserRepository() (*UserRepository, error) { func (repo *UserRepository) createUser(userId int64) (*User, error) { u := User{ - UUID: uuid.New().String(), - UserID: userId, - State: Idle, + UUID: uuid.New().String(), + UserID: userId, + State: Idle, + LinkKey: int32(rand.Intn(900000) + 100000), + CreatedAt: time.Now(), } err := repo.table.Put(u).Run() if err != nil { @@ -75,6 +79,15 @@ func (repo *UserRepository) readUserByUsername(username string) (*User, error) { return &u, nil } +func (repo *UserRepository) readUserByLinkKey(linkKey int32, createdAt int64) (*User, error) { + var u User + err := repo.table.Get("LinkKey", linkKey).Index("LinkKey-GSI").Range("CreatedAt", dynamo.Equal, createdAt).One(&u) + if err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } + return &u, nil +} + func (repo *UserRepository) updateUser(uuid string, updates map[string]interface{}) error { updateBuilder := repo.table.Update("UUID", uuid) for key, value := range updates { diff --git a/bot/go.mod b/bot/go.mod index 5c7209f..5e50cb4 100644 --- a/bot/go.mod +++ b/bot/go.mod @@ -7,7 +7,8 @@ require ( github.com/aws/aws-lambda-go v1.47.0 github.com/aws/aws-sdk-go v1.51.25 github.com/google/uuid v1.6.0 - github.com/guregu/dynamo v1.22.0 + github.com/guregu/dynamo v1.22.1 + github.com/sqids/sqids-go v0.4.1 ) require ( diff --git a/bot/go.sum b/bot/go.sum index 8ef626c..06a1b5c 100644 --- a/bot/go.sum +++ b/bot/go.sum @@ -11,14 +11,16 @@ 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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/guregu/dynamo v1.22.0 h1:lRRjRyY+Xcvd0odbIJgzEg6rRsCvgALN56zRGl8q/yY= -github.com/guregu/dynamo v1.22.0/go.mod h1:WCJu1jWjU/mEYnV9dDOqmAGdTgO7J9AnhkxcH/btXho= +github.com/guregu/dynamo v1.22.1 h1:0exG5Er2fVs+9qYUaoUbpCPr+7pepwoVPYkDTyvVTaI= +github.com/guregu/dynamo v1.22.1/go.mod h1:a0knvVZrDhT+q7eQlu1n041lf5vPi0sNfGjRh81mAnQ= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 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/sqids/sqids-go v0.4.1 h1:eQKYzmAZbLlRwHeHYPF35QhgxwZHLnlmVj9AkIj/rrw= +github.com/sqids/sqids-go v0.4.1/go.mod h1:EMwHuPQgSNFS0A49jESTfIQS+066XQTVhukrzEPScl8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= diff --git a/infra/main.tf b/infra/main.tf index abc7046..f057243 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -14,7 +14,8 @@ resource "aws_lambda_function" "anonymous_bot" { environment { variables = { - BOT_TOKEN = var.bot_token + BOT_TOKEN = var.bot_token + SQIDS_ALPHABET = var.sqids_alphabet } } } @@ -112,6 +113,16 @@ resource "aws_dynamodb_table" "main" { type = "S" } + attribute { + name = "LinkKey" + type = "N" + } + + attribute { + name = "CreatedAt" + type = "N" + } + global_secondary_index { name = "UserID-GSI" hash_key = "UserID" @@ -124,6 +135,13 @@ resource "aws_dynamodb_table" "main" { projection_type = "ALL" } + global_secondary_index { + name = "LinkKey-GSI" + hash_key = "LinkKey" + range_key = "CreatedAt" + projection_type = "ALL" + } + lifecycle { prevent_destroy = false } @@ -157,7 +175,8 @@ resource "aws_iam_policy" "lambda_dynamodb_policy" { Effect = "Allow", Resource = [ "${aws_dynamodb_table.main.arn}/index/UserID-GSI", - "${aws_dynamodb_table.main.arn}/index/Username-GSI" + "${aws_dynamodb_table.main.arn}/index/Username-GSI", + "${aws_dynamodb_table.main.arn}/index/LinkKey-GSI" ] } ] diff --git a/infra/variables.tf b/infra/variables.tf index 8bb1044..4c67d9b 100644 --- a/infra/variables.tf +++ b/infra/variables.tf @@ -13,6 +13,11 @@ variable "bot_token" { type = string } +variable "sqids_alphabet" { + description = "sqids alphabet" + type = string +} + variable "zip_bundle_path" { description = "Local Lambda zip bundle path" type = string