From 3413007f2fe0b99b7b1755aa0c8f325ba3724ef4 Mon Sep 17 00:00:00 2001 From: Yashar Hosseinpour Date: Mon, 6 May 2024 23:45:06 +0200 Subject: [PATCH 1/7] add invitations module Invitation and Invitation repo A command to list available invitations A callback button to create invitations --- bot/common/init.go | 2 + bot/common/invitations/main.go | 42 ++++ bot/common/invitations/repository.go | 118 ++++++++++ bot/common/invitations/root_handler.go | 301 +++++++++++++++++++++++++ bot/common/invitations/utils.go | 75 ++++++ bot/common/misc.go | 8 + infra/invitations.tf | 65 ++++++ invite.sh | 39 ++++ 8 files changed, 650 insertions(+) create mode 100644 bot/common/invitations/main.go create mode 100644 bot/common/invitations/repository.go create mode 100644 bot/common/invitations/root_handler.go create mode 100644 bot/common/invitations/utils.go create mode 100644 infra/invitations.tf create mode 100644 invite.sh diff --git a/bot/common/init.go b/bot/common/init.go index c928d89..d542cd3 100644 --- a/bot/common/init.go +++ b/bot/common/init.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/bugfloyd/anonymous-telegram-bot/common/invitations" "log" "net/http" "os" @@ -53,6 +54,7 @@ func InitBot(request APIRequest) (APIResponse, error) { MaxRoutines: ext.DefaultMaxRoutines, }) + invitations.InitInvitations(dispatcher) rootHandler := NewRootHandler() // Commands diff --git a/bot/common/invitations/main.go b/bot/common/invitations/main.go new file mode 100644 index 0000000..80e8b2c --- /dev/null +++ b/bot/common/invitations/main.go @@ -0,0 +1,42 @@ +package invitations + +import ( + "github.com/PaulSonOfLars/gotgbot/v2/ext" + "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers" + "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/callbackquery" + "github.com/bugfloyd/anonymous-telegram-bot/common/users" +) + +type Inviter struct { + ItemID string `dynamo:",hash"` + Inviter string `index:"Inviter-GSI,hash"` + InvitationsLeft uint32 + InvitationsUsed uint32 + Level uint8 +} + +type Invitation struct { + ItemID string `dynamo:",hash"` + Inviter string `index:"Inviter-GSI,hash"` + InvitationsLeft uint32 + InvitationsUsed uint32 +} + +const ( + GeneratingInvitationState users.State = "GENERATING_INVITATION" +) + +const ( + InviteCommand Command = "invite" +) + +const ( + GenerateInvitationCallback CallbackCommand = "generate-invitation-callback" +) + +func InitInvitations(dispatcher *ext.Dispatcher) { + rootHandler := NewRootHandler() + + dispatcher.AddHandler(handlers.NewCommand(string(InviteCommand), rootHandler.init(InviteCommand))) + dispatcher.AddHandler(handlers.NewCallback(callbackquery.Prefix("inv|g"), rootHandler.init(GenerateInvitationCallback))) +} diff --git a/bot/common/invitations/repository.go b/bot/common/invitations/repository.go new file mode 100644 index 0000000..04bfae3 --- /dev/null +++ b/bot/common/invitations/repository.go @@ -0,0 +1,118 @@ +package invitations + +import ( + "fmt" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/google/uuid" + "github.com/guregu/dynamo" + "os" + "reflect" +) + +type Repository struct { + table dynamo.Table +} + +func NewRepository() (*Repository, error) { + var sess *session.Session + customDynamoDbEndpoint := os.Getenv("DYNAMODB_ENDPOINT") + awsRegion := os.Getenv("AWS_REGION") + + if customDynamoDbEndpoint != "" { + sess = session.Must(session.NewSession(&aws.Config{ + Region: aws.String(awsRegion), + Endpoint: aws.String(customDynamoDbEndpoint), + })) + } else { + sess = session.Must(session.NewSession(&aws.Config{Region: aws.String(awsRegion)})) + } + + db := dynamo.New(sess) + + return &Repository{ + table: db.Table("AnonymousBot_Invitations"), + }, nil +} + +func (repo *Repository) createInviter(inviter string, count uint32) (*Inviter, error) { + i := Inviter{ + ItemID: "INVITER#" + uuid.New().String(), + Inviter: inviter, + InvitationsLeft: count, + InvitationsUsed: 0, + } + err := repo.table.Put(i).Run() + if err != nil { + return nil, fmt.Errorf("failed to create inviter: %w", err) + } + return &i, nil +} + +func (repo *Repository) createInvitation(code string, inviter string, count uint32) (*Invitation, error) { + i := Invitation{ + ItemID: "INVITATION#" + code, + Inviter: inviter, + InvitationsLeft: count, + InvitationsUsed: 0, + } + err := repo.table.Put(i).Run() + if err != nil { + return nil, fmt.Errorf("failed to create invitation: %w", err) + } + return &i, nil +} + +func (repo *Repository) readInviter(uuid string) (*Inviter, error) { + var u Inviter + err := repo.table.Get("ItemID", fmt.Sprintf("INVITER#%s", uuid)).One(&u) + if err != nil { + return nil, fmt.Errorf("failed to get inviter: %w", err) + } + return &u, nil +} + +func (repo *Repository) readInvitation(code string) (*Invitation, error) { + var u Invitation + err := repo.table.Get("ItemID", fmt.Sprintf("INVITATION#%s", code)).One(&u) + if err != nil { + return nil, fmt.Errorf("failed to get invitation by id: %w", err) + } + return &u, nil +} + +func (repo *Repository) readInvitationsByInviter(uuid string) (*[]Invitation, error) { + var invitation []Invitation + err := repo.table.Get("Inviter", uuid).Index("Inviter-GSI").Range("ItemID", dynamo.BeginsWith, "INVITATION").All(&invitation) + if err != nil { + return nil, fmt.Errorf("failed to get invitations by inviter: %w", err) + } + return &invitation, nil +} + +func (repo *Repository) updateInviter(inviter *Inviter, updates map[string]interface{}) error { + updateBuilder := repo.table.Update("ItemID", inviter.ItemID) + for key, value := range updates { + updateBuilder = updateBuilder.Set(key, value) + } + err := updateBuilder.Run() + if err != nil { + return fmt.Errorf("failed to update inviter: %w", err) + } + + // Reflecting on user to update fields based on updates map + val := reflect.ValueOf(inviter).Elem() // We use .Elem() to dereference the pointer to user + for key, value := range updates { + fieldVal := val.FieldByName(key) + if fieldVal.IsValid() && fieldVal.CanSet() { + // Ensure the value is of the correct type + correctTypeValue := reflect.ValueOf(value) + if correctTypeValue.Type().ConvertibleTo(fieldVal.Type()) { + correctTypeValue = correctTypeValue.Convert(fieldVal.Type()) + } + fieldVal.Set(correctTypeValue) + } + } + + return nil +} diff --git a/bot/common/invitations/root_handler.go b/bot/common/invitations/root_handler.go new file mode 100644 index 0000000..fdd58d5 --- /dev/null +++ b/bot/common/invitations/root_handler.go @@ -0,0 +1,301 @@ +package invitations + +import ( + "fmt" + "github.com/PaulSonOfLars/gotgbot/v2" + "github.com/PaulSonOfLars/gotgbot/v2/ext" + "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers" + "github.com/bugfloyd/anonymous-telegram-bot/common/users" + "strconv" + "strings" +) + +type RootHandler struct { + user *users.User + userRepo users.UserRepository +} + +type Command string +type CallbackCommand string + +func NewRootHandler() *RootHandler { + return &RootHandler{} +} + +func (r *RootHandler) init(commandName interface{}) handlers.Response { + return func(b *gotgbot.Bot, ctx *ext.Context) error { + return r.runCommand(b, ctx, commandName) + } +} + +func (r *RootHandler) RetrieveUser(ctx *ext.Context) error { + // create user repo + userRepo, err := users.NewUserRepository() + if err != nil { + return fmt.Errorf("failed to init db repo: %w", err) + } + user, err := r.processUser(userRepo, ctx) + + if err != nil || user == nil { + return fmt.Errorf("failed to process user: %w", err) + } + r.user = user + r.userRepo = *userRepo + return nil +} + +func (r *RootHandler) runCommand(b *gotgbot.Bot, ctx *ext.Context, command interface{}) error { + err := r.RetrieveUser(ctx) + if err != nil { + return err + } + + switch c := command.(type) { + case Command: + switch c { + case InviteCommand: + return r.inviteCommandHandler(b, ctx) + default: + return fmt.Errorf("unknown command: %s", c) + } + case CallbackCommand: + // Reset user state if necessary + if r.user.State != users.Idle || r.user.ContactUUID != "" || r.user.ReplyMessageID != 0 { + err := r.userRepo.ResetUserState(r.user) + if err != nil { + return err + } + } + + switch c { + case GenerateInvitationCallback: + return r.manageInvitation(b, ctx, "GENERATE") + default: + return fmt.Errorf("unknown command: %s", c) + } + default: + return fmt.Errorf("unknown command: %s", command) + } +} + +func (r *RootHandler) processUser(userRepo *users.UserRepository, ctx *ext.Context) (*users.User, error) { + user, err := userRepo.ReadUserByUserId(ctx.EffectiveUser.Id) + if err != nil { + user, err = userRepo.CreateUser(ctx.EffectiveUser.Id) + if err != nil { + return nil, err + } + } + + return user, nil +} + +func (r *RootHandler) inviteCommandHandler(b *gotgbot.Bot, ctx *ext.Context) error { + // create invitations repo + repo, err := NewRepository() + if err != nil { + return fmt.Errorf("failed to init invitations db repo: %w", err) + } + + invitationUser, err := repo.readInviter(r.user.UUID) + if err != nil && strings.Contains(err.Error(), "dynamo: no item found") { + _, err = ctx.EffectiveMessage.Reply(b, "You don't have any invitations!", nil) + if err != nil { + return fmt.Errorf("failed to reply with user invitations: %w", err) + } + return nil + } else if err != nil { + return err + } + + invitations, err := repo.readInvitationsByInviter(r.user.UUID) + if err != nil { + return err + } + + var msg strings.Builder + var replyMarkup gotgbot.InlineKeyboardMarkup + + if invitationUser.Level == 1 { + msg.WriteString(fmt.Sprintf("Total invitations left: *%d*\nTotal invitations used: *%d*", invitationUser.InvitationsLeft, invitationUser.InvitationsUsed)) + + if len(*invitations) == 0 { + msg.WriteString("\n\n" + "You have no generated invitation codes\\.") + } else { + msg.WriteString("\n\n" + fmt.Sprintf("You have *%d* generated invitation codes:\n", len(*invitations))) + + // Iterate through the slice of invitations and add each to the text + for _, inv := range *invitations { + // Create the formatted string for each invitation + escapedItemID := strings.ReplaceAll(inv.ItemID, "-", "\\-") + invitationCode := strings.TrimPrefix(escapedItemID, "INVITATION#") + line := fmt.Sprintf("`%s` %d/%d\n", invitationCode, inv.InvitationsUsed, inv.InvitationsLeft) + msg.WriteString(line) + } + } + + if invitationUser.InvitationsLeft > 0 { + replyMarkup = gotgbot.InlineKeyboardMarkup{ + InlineKeyboard: [][]gotgbot.InlineKeyboardButton{ + { + { + Text: "Generate Code", + CallbackData: "inv|g", + }, + }, + }, + } + } + } else { + _, err = ctx.EffectiveMessage.Reply(b, "You don't have any invitations!", nil) + if err != nil { + return fmt.Errorf("failed to reply with user invitations: %w", err) + } + return nil + } + + _, err = ctx.EffectiveMessage.Reply(b, msg.String(), &gotgbot.SendMessageOpts{ + ReplyMarkup: replyMarkup, + ParseMode: gotgbot.ParseModeMarkdownV2, + }) + if err != nil { + return fmt.Errorf("failed to reply with user invitations: %w", err) + } + + return nil +} + +func (r *RootHandler) manageInvitation(b *gotgbot.Bot, ctx *ext.Context, action string) error { + cb := ctx.Update.CallbackQuery + _, _, err := cb.Message.EditReplyMarkup(b, &gotgbot.EditMessageReplyMarkupOpts{}) + if err != nil { + return fmt.Errorf("failed to update invitation manager message markup: %w", err) + } + + if action == "GENERATE" { + repo, err := NewRepository() + if err != nil { + return fmt.Errorf("failed to init invitations db repo: %w", err) + } + + inviter, err := repo.readInviter(r.user.UUID) + if err != nil { + return err + } + + if inviter.InvitationsLeft == 0 { + // Send reply instruction + _, err = ctx.EffectiveMessage.Reply(b, "You do not have any invitation codes left!", nil) + if err != nil { + return fmt.Errorf("failed to send reply message: %w", err) + } + + // Send callback answer to telegram + _, err = cb.Answer(b, &gotgbot.AnswerCallbackQueryOpts{ + Text: "No invitations left!", + }) + if err != nil { + return fmt.Errorf("failed to answer callback: %w", err) + } + return nil + } + + // Store the message id in the user and set status to replying + err = r.userRepo.UpdateUser(r.user, map[string]interface{}{ + "State": GeneratingInvitationState, + "ContactUUID": "", + "ReplyMessageID": 0, + }) + if err != nil { + return fmt.Errorf("failed to update user state: %w", err) + } + + // Send reply instruction + _, err = ctx.EffectiveMessage.Reply(b, fmt.Sprintf("Please enter the number of available usages for this code\\.\nNote that this number should be lower than your total available invitations which is currently *%d*\\.", inviter.InvitationsLeft), &gotgbot.SendMessageOpts{ + ParseMode: gotgbot.ParseModeMarkdownV2, + }) + if err != nil { + return fmt.Errorf("failed to send reply message: %w", err) + } + + // Send callback answer to telegram + _, err = cb.Answer(b, &gotgbot.AnswerCallbackQueryOpts{ + Text: "Generating invitation code...", + }) + if err != nil { + return fmt.Errorf("failed to answer callback: %w", err) + } + } + + return nil +} + +func (r *RootHandler) GenerateInvitation(b *gotgbot.Bot, ctx *ext.Context) error { + invitationCount := ctx.EffectiveMessage.Text + + // create invitations repo + repo, err := NewRepository() + if err != nil { + return fmt.Errorf("failed to init invitations db repo: %w", err) + } + + inviter, err := repo.readInviter(r.user.UUID) + if err != nil { + return err + } + + // Try to parse the string as an integer + number, err := strconv.Atoi(invitationCount) + if err != nil || number < 1 { + _, err := ctx.EffectiveMessage.Reply(b, "Input is not a valid integer. Enter again.", nil) + if err != nil { + return fmt.Errorf("failed to send reply message: %w", err) + } + return nil + } + count := uint32(number) + + // Check if the integer is within the range of uint8 + if count > inviter.InvitationsLeft { + _, err := ctx.EffectiveMessage.Reply(b, fmt.Sprintf("Currently you only have *%d* invitations left and cannot generate a code with *%d* usage\\.", inviter.InvitationsLeft, count), &gotgbot.SendMessageOpts{ + ParseMode: gotgbot.ParseModeMarkdownV2, + }) + if err != nil { + return fmt.Errorf("failed to send reply message: %w", err) + } + return nil + } + + // Generate a unique invitation code + code, err := generateUniqueInvitationCode(repo) + if err != nil { + return fmt.Errorf("failed to genemrate invitation code: %w", err) + } + invitation, err := repo.createInvitation("whisper-"+code, inviter.Inviter, count) + if err != nil { + return err + } + + // Update inviter + err = repo.updateInviter(inviter, map[string]interface{}{ + "InvitationsLeft": inviter.InvitationsLeft - count, + }) + if err != nil { + return err + } + + _, err = ctx.EffectiveMessage.Reply(b, fmt.Sprintf("Invitation created\\!\n\nCode: `%s`\nUsages left: *%d*", strings.TrimPrefix(invitation.ItemID, "INVITATION#"), invitation.InvitationsLeft), &gotgbot.SendMessageOpts{ + ParseMode: gotgbot.ParseModeMarkdownV2, + }) + if err != nil { + return fmt.Errorf("failed to send reply message: %w", err) + } + + // Reset sender user + err = r.userRepo.ResetUserState(r.user) + if err != nil { + return err + } + + return nil +} diff --git a/bot/common/invitations/utils.go b/bot/common/invitations/utils.go new file mode 100644 index 0000000..89b5fb5 --- /dev/null +++ b/bot/common/invitations/utils.go @@ -0,0 +1,75 @@ +package invitations + +import ( + "crypto/rand" + "fmt" + "math/big" + "strings" +) + +// generateRandomString generates a random string of a specified length from a predefined charset. +func generateRandomString(length int) (string, error) { + const charset = "abcdefghijklmnopqrstuvwxyz0123456789" + + result := make([]byte, length) + for i := range result { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + return "", err + } + result[i] = charset[num.Int64()] + } + return string(result), nil +} + +// getRandomLength returns a random integer between min and max inclusive. +func getRandomLength(min, max int) (int, error) { + if min > max { + return 0, fmt.Errorf("min should not be greater than max") + } + num, err := rand.Int(rand.Reader, big.NewInt(int64(max-min+1))) + if err != nil { + return 0, err + } + return int(num.Int64()) + min, nil +} + +// generateUniqueInvitationCode generates a unique invitation code by ensuring it's not already in DynamoDB. +func generateUniqueInvitationCode(repo *Repository) (string, error) { + for { + // Get random lengths for both sections + section1Len, err := getRandomLength(3, 5) + if err != nil { + return "", err + } + section2Len, err := getRandomLength(3, 5) + if err != nil { + return "", err + } + + // Generate two sections of random alphanumeric strings + section1, err := generateRandomString(section1Len) + if err != nil { + return "", err + } + section2, err := generateRandomString(section2Len) + if err != nil { + return "", err + } + + // Concatenate the sections with a hyphen + code := fmt.Sprintf("%s-%s", section1, section2) + + // Check if the generated code already exists + existingInvitation, err := repo.readInvitation("whisper-" + code) + if err != nil && !strings.Contains(err.Error(), "dynamo: no item found") { + return "", err + } + + // If no existing invitation is found, the code is unique + if existingInvitation == nil || strings.Contains(err.Error(), "dynamo: no item found") { + return code, nil + } + // Otherwise, keep generating until a unique code is found + } +} diff --git a/bot/common/misc.go b/bot/common/misc.go index 733e266..ef348bf 100644 --- a/bot/common/misc.go +++ b/bot/common/misc.go @@ -5,6 +5,7 @@ import ( "github.com/PaulSonOfLars/gotgbot/v2" "github.com/PaulSonOfLars/gotgbot/v2/ext" "github.com/bugfloyd/anonymous-telegram-bot/common/i18n" + "github.com/bugfloyd/anonymous-telegram-bot/common/invitations" "github.com/bugfloyd/anonymous-telegram-bot/common/users" "github.com/sqids/sqids-go" "os" @@ -176,6 +177,13 @@ func (r *RootHandler) processText(b *gotgbot.Bot, ctx *ext.Context) error { return r.sendAnonymousMessage(b, ctx) case users.SettingUsername: return r.setUsername(b, ctx) + case invitations.GeneratingInvitationState: + irh := invitations.NewRootHandler() + err := irh.RetrieveUser(ctx) + if err != nil { + return err + } + return irh.GenerateInvitation(b, ctx) default: return r.sendError(b, ctx, i18n.T(i18n.InvalidCommandText)) } diff --git a/infra/invitations.tf b/infra/invitations.tf new file mode 100644 index 0000000..5910201 --- /dev/null +++ b/infra/invitations.tf @@ -0,0 +1,65 @@ +resource "aws_dynamodb_table" "invitations" { + name = "AnonymousBot_Invitations" + billing_mode = "PAY_PER_REQUEST" + hash_key = "ItemID" + + attribute { + name = "ItemID" + type = "S" + } + + attribute { + name = "Inviter" + type = "S" + } + + global_secondary_index { + name = "Inviter-GSI" + hash_key = "SenderUUID" + range_key = "ItemID" + projection_type = "ALL" + } + + lifecycle { + prevent_destroy = false + } +} + +resource "aws_iam_policy" "lambda_dynamodb_invitations_policy" { + name = "AnonymousInvitationsDynamoDBLambdaPolicy" + description = "Policy to allow Lambda function to manage invitations DynamoDB table" + + policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Action = [ + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:UpdateItem", + "dynamodb:DeleteItem", + "dynamodb:Query", + "dynamodb:BatchGetItem", + "dynamodb:BatchWriteItem", + "dynamodb:DescribeTable", + ], + Effect = "Allow", + Resource = aws_dynamodb_table.invitations.arn + }, + { + Action = [ + "dynamodb:Query", + ], + Effect = "Allow", + Resource = [ + "${aws_dynamodb_table.invitations.arn}/index/Inviter-GSI" + ] + } + ] + }) +} + +resource "aws_iam_role_policy_attachment" "rest_backend_lambda_invitations_dynamodb_policy_attachment" { + role = aws_iam_role.lambda_exec_role.name + policy_arn = aws_iam_policy.lambda_dynamodb_invitations_policy.arn +} \ No newline at end of file diff --git a/invite.sh b/invite.sh new file mode 100644 index 0000000..6d264ce --- /dev/null +++ b/invite.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# Parameters +SENDER_UUID=$1 +INVITATIONS_LEFT=$2 + +if [ -z "$SENDER_UUID" ] || [ -z "$INVITATIONS_LEFT" ]; then + echo "Usage: $0 " + exit 1 +fi + +# Generate a random ID +#ID=$(uuidgen) + +aws dynamodb put-item --endpoint-url http://localhost:8000 --region eu-central-1 \ + --table-name AnonymousBot_Invitations \ + --item "{ + \"ItemID\": {\"S\": \"INVITER#$SENDER_UUID\"}, + \"Inviter\": {\"S\": \"$SENDER_UUID\"}, + \"InvitationsLeft\": {\"N\": \"$INVITATIONS_LEFT\"}, + \"InvitationsUsed\": {\"N\": \"0\"}, + \"Level\": {\"N\": \"1\"} + }" + +# Create the new item in the DynamoDB table without empty sets +#aws dynamodb put-item --endpoint-url http://localhost:8000 --region eu-central-1 \ +# --table-name AnonymousBot_Invitations \ +# --item "{ +# \"ItemID\": {\"S\": \"$ID\"}, +# \"Inviter\": {\"S\": \"$SENDER_UUID\"}, +# \"InvitationsLeft\": {\"N\": \"$INVITATIONS_LEFT\"} +# }" + +# Check if the command succeeded +if [ $? -eq 0 ]; then + echo "Successfully added item with ID $SENDER_UUID to AnonymousBot_Invitations" +else + echo "Error: Failed to add item to AnonymousBot_Invitations" +fi \ No newline at end of file From 84b3d51172fe7dd17da8faf0da766b61bfdf1ae4 Mon Sep 17 00:00:00 2001 From: Yashar Hosseinpour Date: Thu, 9 May 2024 02:40:01 +0200 Subject: [PATCH 2/7] rename inviter/sender to user change user level to user type add ItemID as UserID-GSI range key --- bot/common/invitations/main.go | 12 ++++---- bot/common/invitations/repository.go | 42 +++++++++++++------------- bot/common/invitations/root_handler.go | 12 ++++---- infra/invitations.tf | 8 ++--- invite.sh | 24 ++++----------- 5 files changed, 43 insertions(+), 55 deletions(-) mode change 100644 => 100755 invite.sh diff --git a/bot/common/invitations/main.go b/bot/common/invitations/main.go index 80e8b2c..65f2c8f 100644 --- a/bot/common/invitations/main.go +++ b/bot/common/invitations/main.go @@ -7,17 +7,17 @@ import ( "github.com/bugfloyd/anonymous-telegram-bot/common/users" ) -type Inviter struct { - ItemID string `dynamo:",hash"` - Inviter string `index:"Inviter-GSI,hash"` +type User struct { + ItemID string `dynamo:",hash" index:"UserID-GSI,range"` + UserID string `index:"UserID-GSI,hash"` InvitationsLeft uint32 InvitationsUsed uint32 - Level uint8 + Type string } type Invitation struct { - ItemID string `dynamo:",hash"` - Inviter string `index:"Inviter-GSI,hash"` + ItemID string `dynamo:",hash" index:"UserID-GSI,range"` + UserID string `index:"UserID-GSI,hash"` InvitationsLeft uint32 InvitationsUsed uint32 } diff --git a/bot/common/invitations/repository.go b/bot/common/invitations/repository.go index 04bfae3..8210d3d 100644 --- a/bot/common/invitations/repository.go +++ b/bot/common/invitations/repository.go @@ -4,7 +4,6 @@ import ( "fmt" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" - "github.com/google/uuid" "github.com/guregu/dynamo" "os" "reflect" @@ -35,24 +34,25 @@ func NewRepository() (*Repository, error) { }, nil } -func (repo *Repository) createInviter(inviter string, count uint32) (*Inviter, error) { - i := Inviter{ - ItemID: "INVITER#" + uuid.New().String(), - Inviter: inviter, - InvitationsLeft: count, +func (repo *Repository) createUser(userID string) (*User, error) { + i := User{ + ItemID: "USER#" + userID, + UserID: userID, + InvitationsLeft: 0, InvitationsUsed: 0, + Type: "NORMAL", } err := repo.table.Put(i).Run() if err != nil { - return nil, fmt.Errorf("failed to create inviter: %w", err) + return nil, fmt.Errorf("invitations: failed to create user: %w", err) } return &i, nil } -func (repo *Repository) createInvitation(code string, inviter string, count uint32) (*Invitation, error) { +func (repo *Repository) createInvitation(code string, userID string, count uint32) (*Invitation, error) { i := Invitation{ ItemID: "INVITATION#" + code, - Inviter: inviter, + UserID: userID, InvitationsLeft: count, InvitationsUsed: 0, } @@ -63,45 +63,45 @@ func (repo *Repository) createInvitation(code string, inviter string, count uint return &i, nil } -func (repo *Repository) readInviter(uuid string) (*Inviter, error) { - var u Inviter - err := repo.table.Get("ItemID", fmt.Sprintf("INVITER#%s", uuid)).One(&u) +func (repo *Repository) readUser(userId string) (*User, error) { + var u User + err := repo.table.Get("ItemID", "USER#"+userId).One(&u) if err != nil { - return nil, fmt.Errorf("failed to get inviter: %w", err) + return nil, fmt.Errorf("failed to get user: %w", err) } return &u, nil } func (repo *Repository) readInvitation(code string) (*Invitation, error) { var u Invitation - err := repo.table.Get("ItemID", fmt.Sprintf("INVITATION#%s", code)).One(&u) + err := repo.table.Get("ItemID", "INVITATION#"+code).One(&u) if err != nil { return nil, fmt.Errorf("failed to get invitation by id: %w", err) } return &u, nil } -func (repo *Repository) readInvitationsByInviter(uuid string) (*[]Invitation, error) { +func (repo *Repository) readInvitationsByUser(userID string) (*[]Invitation, error) { var invitation []Invitation - err := repo.table.Get("Inviter", uuid).Index("Inviter-GSI").Range("ItemID", dynamo.BeginsWith, "INVITATION").All(&invitation) + err := repo.table.Get("UserID", userID).Index("UserID-GSI").Range("ItemID", dynamo.BeginsWith, "INVITATION").All(&invitation) if err != nil { - return nil, fmt.Errorf("failed to get invitations by inviter: %w", err) + return nil, fmt.Errorf("failed to get invitations by user: %w", err) } return &invitation, nil } -func (repo *Repository) updateInviter(inviter *Inviter, updates map[string]interface{}) error { - updateBuilder := repo.table.Update("ItemID", inviter.ItemID) +func (repo *Repository) updateInviter(user *User, updates map[string]interface{}) error { + updateBuilder := repo.table.Update("ItemID", user.ItemID) for key, value := range updates { updateBuilder = updateBuilder.Set(key, value) } err := updateBuilder.Run() if err != nil { - return fmt.Errorf("failed to update inviter: %w", err) + return fmt.Errorf("failed to update user: %w", err) } // Reflecting on user to update fields based on updates map - val := reflect.ValueOf(inviter).Elem() // We use .Elem() to dereference the pointer to user + val := reflect.ValueOf(user).Elem() // We use .Elem() to dereference the pointer to user for key, value := range updates { fieldVal := val.FieldByName(key) if fieldVal.IsValid() && fieldVal.CanSet() { diff --git a/bot/common/invitations/root_handler.go b/bot/common/invitations/root_handler.go index fdd58d5..d0601f8 100644 --- a/bot/common/invitations/root_handler.go +++ b/bot/common/invitations/root_handler.go @@ -97,7 +97,7 @@ func (r *RootHandler) inviteCommandHandler(b *gotgbot.Bot, ctx *ext.Context) err return fmt.Errorf("failed to init invitations db repo: %w", err) } - invitationUser, err := repo.readInviter(r.user.UUID) + invitationUser, err := repo.readUser(r.user.UUID) if err != nil && strings.Contains(err.Error(), "dynamo: no item found") { _, err = ctx.EffectiveMessage.Reply(b, "You don't have any invitations!", nil) if err != nil { @@ -108,7 +108,7 @@ func (r *RootHandler) inviteCommandHandler(b *gotgbot.Bot, ctx *ext.Context) err return err } - invitations, err := repo.readInvitationsByInviter(r.user.UUID) + invitations, err := repo.readInvitationsByUser(r.user.UUID) if err != nil { return err } @@ -116,7 +116,7 @@ func (r *RootHandler) inviteCommandHandler(b *gotgbot.Bot, ctx *ext.Context) err var msg strings.Builder var replyMarkup gotgbot.InlineKeyboardMarkup - if invitationUser.Level == 1 { + if invitationUser.Type == "ZERO" { msg.WriteString(fmt.Sprintf("Total invitations left: *%d*\nTotal invitations used: *%d*", invitationUser.InvitationsLeft, invitationUser.InvitationsUsed)) if len(*invitations) == 0 { @@ -178,7 +178,7 @@ func (r *RootHandler) manageInvitation(b *gotgbot.Bot, ctx *ext.Context, action return fmt.Errorf("failed to init invitations db repo: %w", err) } - inviter, err := repo.readInviter(r.user.UUID) + inviter, err := repo.readUser(r.user.UUID) if err != nil { return err } @@ -239,7 +239,7 @@ func (r *RootHandler) GenerateInvitation(b *gotgbot.Bot, ctx *ext.Context) error return fmt.Errorf("failed to init invitations db repo: %w", err) } - inviter, err := repo.readInviter(r.user.UUID) + inviter, err := repo.readUser(r.user.UUID) if err != nil { return err } @@ -271,7 +271,7 @@ func (r *RootHandler) GenerateInvitation(b *gotgbot.Bot, ctx *ext.Context) error if err != nil { return fmt.Errorf("failed to genemrate invitation code: %w", err) } - invitation, err := repo.createInvitation("whisper-"+code, inviter.Inviter, count) + invitation, err := repo.createInvitation("whisper-"+code, inviter.UserID, count) if err != nil { return err } diff --git a/infra/invitations.tf b/infra/invitations.tf index 5910201..b4dc8e9 100644 --- a/infra/invitations.tf +++ b/infra/invitations.tf @@ -9,13 +9,13 @@ resource "aws_dynamodb_table" "invitations" { } attribute { - name = "Inviter" + name = "UserID" type = "S" } global_secondary_index { - name = "Inviter-GSI" - hash_key = "SenderUUID" + name = "UserID-GSI" + hash_key = "UserID" range_key = "ItemID" projection_type = "ALL" } @@ -52,7 +52,7 @@ resource "aws_iam_policy" "lambda_dynamodb_invitations_policy" { ], Effect = "Allow", Resource = [ - "${aws_dynamodb_table.invitations.arn}/index/Inviter-GSI" + "${aws_dynamodb_table.invitations.arn}/index/UserID-GSI" ] } ] diff --git a/invite.sh b/invite.sh old mode 100644 new mode 100755 index 6d264ce..f4f7370 --- a/invite.sh +++ b/invite.sh @@ -1,39 +1,27 @@ #!/bin/bash # Parameters -SENDER_UUID=$1 +USER_ID=$1 INVITATIONS_LEFT=$2 -if [ -z "$SENDER_UUID" ] || [ -z "$INVITATIONS_LEFT" ]; then +if [ -z "$USER_ID" ] || [ -z "$INVITATIONS_LEFT" ]; then echo "Usage: $0 " exit 1 fi -# Generate a random ID -#ID=$(uuidgen) - aws dynamodb put-item --endpoint-url http://localhost:8000 --region eu-central-1 \ --table-name AnonymousBot_Invitations \ --item "{ - \"ItemID\": {\"S\": \"INVITER#$SENDER_UUID\"}, - \"Inviter\": {\"S\": \"$SENDER_UUID\"}, + \"ItemID\": {\"S\": \"USER#$USER_ID\"}, + \"UserID\": {\"S\": \"$USER_ID\"}, \"InvitationsLeft\": {\"N\": \"$INVITATIONS_LEFT\"}, \"InvitationsUsed\": {\"N\": \"0\"}, - \"Level\": {\"N\": \"1\"} + \"Type\": {\"S\": \"ZERO\"} }" -# Create the new item in the DynamoDB table without empty sets -#aws dynamodb put-item --endpoint-url http://localhost:8000 --region eu-central-1 \ -# --table-name AnonymousBot_Invitations \ -# --item "{ -# \"ItemID\": {\"S\": \"$ID\"}, -# \"Inviter\": {\"S\": \"$SENDER_UUID\"}, -# \"InvitationsLeft\": {\"N\": \"$INVITATIONS_LEFT\"} -# }" - # Check if the command succeeded if [ $? -eq 0 ]; then - echo "Successfully added item with ID $SENDER_UUID to AnonymousBot_Invitations" + echo "Successfully added item with ID $USER_ID to AnonymousBot_Invitations" else echo "Error: Failed to add item to AnonymousBot_Invitations" fi \ No newline at end of file From 5fcb6d74e6ab4d35136a1982177451a6d9be2210 Mon Sep 17 00:00:00 2001 From: Yashar Hosseinpour Date: Thu, 9 May 2024 19:33:08 +0200 Subject: [PATCH 3/7] add register by invitation code rename UserID to UserUUID check user registration status in link command --- bot/common/invitations/main.go | 20 +-- bot/common/invitations/repository.go | 47 +++++-- bot/common/invitations/root_handler.go | 166 +++++++++++++++++++++++-- bot/common/invitations/utils.go | 30 +++++ bot/common/misc.go | 10 ++ infra/invitations.tf | 8 +- invite.sh | 10 +- 7 files changed, 256 insertions(+), 35 deletions(-) diff --git a/bot/common/invitations/main.go b/bot/common/invitations/main.go index 65f2c8f..16c00c7 100644 --- a/bot/common/invitations/main.go +++ b/bot/common/invitations/main.go @@ -8,35 +8,41 @@ import ( ) type User struct { - ItemID string `dynamo:",hash" index:"UserID-GSI,range"` - UserID string `index:"UserID-GSI,hash"` + ItemID string `dynamo:",hash" index:"UserUUID-GSI,range"` + UserUUID string `index:"UserUUID-GSI,hash"` InvitationsLeft uint32 InvitationsUsed uint32 Type string } type Invitation struct { - ItemID string `dynamo:",hash" index:"UserID-GSI,range"` - UserID string `index:"UserID-GSI,hash"` + ItemID string `dynamo:",hash" index:"UserUUID-GSI,range"` + UserUUID string `index:"UserUUID-GSI,hash"` InvitationsLeft uint32 InvitationsUsed uint32 } const ( - GeneratingInvitationState users.State = "GENERATING_INVITATION" + GeneratingInvitationState users.State = "GENERATING_INVITATION" + SendingInvitationCodeState users.State = "SENDING_INVITATION_CODE" ) const ( - InviteCommand Command = "invite" + InviteCommand Command = "invite" + RegisterCommand Command = "register" ) const ( - GenerateInvitationCallback CallbackCommand = "generate-invitation-callback" + GenerateInvitationCallback CallbackCommand = "generate-invitation-callback" + CancelSendingInvitationCodeCallback CallbackCommand = "cancel-invitation-code-callback" ) func InitInvitations(dispatcher *ext.Dispatcher) { rootHandler := NewRootHandler() dispatcher.AddHandler(handlers.NewCommand(string(InviteCommand), rootHandler.init(InviteCommand))) + dispatcher.AddHandler(handlers.NewCommand(string(RegisterCommand), rootHandler.init(RegisterCommand))) dispatcher.AddHandler(handlers.NewCallback(callbackquery.Prefix("inv|g"), rootHandler.init(GenerateInvitationCallback))) + dispatcher.AddHandler(handlers.NewCallback(callbackquery.Prefix("inv|reg|c"), rootHandler.init(CancelSendingInvitationCodeCallback))) + } diff --git a/bot/common/invitations/repository.go b/bot/common/invitations/repository.go index 8210d3d..83d6ed2 100644 --- a/bot/common/invitations/repository.go +++ b/bot/common/invitations/repository.go @@ -34,10 +34,10 @@ func NewRepository() (*Repository, error) { }, nil } -func (repo *Repository) createUser(userID string) (*User, error) { +func (repo *Repository) createUser(userUUID string) (*User, error) { i := User{ - ItemID: "USER#" + userID, - UserID: userID, + ItemID: "USER#" + userUUID, + UserUUID: userUUID, InvitationsLeft: 0, InvitationsUsed: 0, Type: "NORMAL", @@ -49,10 +49,10 @@ func (repo *Repository) createUser(userID string) (*User, error) { return &i, nil } -func (repo *Repository) createInvitation(code string, userID string, count uint32) (*Invitation, error) { +func (repo *Repository) createInvitation(code string, userUUID string, count uint32) (*Invitation, error) { i := Invitation{ ItemID: "INVITATION#" + code, - UserID: userID, + UserUUID: userUUID, InvitationsLeft: count, InvitationsUsed: 0, } @@ -63,9 +63,9 @@ func (repo *Repository) createInvitation(code string, userID string, count uint3 return &i, nil } -func (repo *Repository) readUser(userId string) (*User, error) { +func (repo *Repository) readUser(userUUID string) (*User, error) { var u User - err := repo.table.Get("ItemID", "USER#"+userId).One(&u) + err := repo.table.Get("ItemID", "USER#"+userUUID).One(&u) if err != nil { return nil, fmt.Errorf("failed to get user: %w", err) } @@ -81,16 +81,16 @@ func (repo *Repository) readInvitation(code string) (*Invitation, error) { return &u, nil } -func (repo *Repository) readInvitationsByUser(userID string) (*[]Invitation, error) { +func (repo *Repository) readInvitationsByUser(userUUID string) (*[]Invitation, error) { var invitation []Invitation - err := repo.table.Get("UserID", userID).Index("UserID-GSI").Range("ItemID", dynamo.BeginsWith, "INVITATION").All(&invitation) + err := repo.table.Get("UserUUID", userUUID).Index("UserUUID-GSI").Range("ItemID", dynamo.BeginsWith, "INVITATION").All(&invitation) if err != nil { return nil, fmt.Errorf("failed to get invitations by user: %w", err) } return &invitation, nil } -func (repo *Repository) updateInviter(user *User, updates map[string]interface{}) error { +func (repo *Repository) updateUser(user *User, updates map[string]interface{}) error { updateBuilder := repo.table.Update("ItemID", user.ItemID) for key, value := range updates { updateBuilder = updateBuilder.Set(key, value) @@ -116,3 +116,30 @@ func (repo *Repository) updateInviter(user *User, updates map[string]interface{} return nil } + +func (repo *Repository) updateInvitation(invitation *Invitation, updates map[string]interface{}) error { + updateBuilder := repo.table.Update("ItemID", invitation.ItemID) + for key, value := range updates { + updateBuilder = updateBuilder.Set(key, value) + } + err := updateBuilder.Run() + if err != nil { + return fmt.Errorf("failed to update invitation: %w", err) + } + + // Reflecting on user to update fields based on updates map + val := reflect.ValueOf(invitation).Elem() // We use .Elem() to dereference the pointer to user + for key, value := range updates { + fieldVal := val.FieldByName(key) + if fieldVal.IsValid() && fieldVal.CanSet() { + // Ensure the value is of the correct type + correctTypeValue := reflect.ValueOf(value) + if correctTypeValue.Type().ConvertibleTo(fieldVal.Type()) { + correctTypeValue = correctTypeValue.Convert(fieldVal.Type()) + } + fieldVal.Set(correctTypeValue) + } + } + + return nil +} diff --git a/bot/common/invitations/root_handler.go b/bot/common/invitations/root_handler.go index d0601f8..f1e119a 100644 --- a/bot/common/invitations/root_handler.go +++ b/bot/common/invitations/root_handler.go @@ -5,6 +5,7 @@ import ( "github.com/PaulSonOfLars/gotgbot/v2" "github.com/PaulSonOfLars/gotgbot/v2/ext" "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers" + "github.com/bugfloyd/anonymous-telegram-bot/common/i18n" "github.com/bugfloyd/anonymous-telegram-bot/common/users" "strconv" "strings" @@ -55,6 +56,8 @@ func (r *RootHandler) runCommand(b *gotgbot.Bot, ctx *ext.Context, command inter switch c { case InviteCommand: return r.inviteCommandHandler(b, ctx) + case RegisterCommand: + return r.registerCommandHandler(b, ctx) default: return fmt.Errorf("unknown command: %s", c) } @@ -70,6 +73,8 @@ func (r *RootHandler) runCommand(b *gotgbot.Bot, ctx *ext.Context, command inter switch c { case GenerateInvitationCallback: return r.manageInvitation(b, ctx, "GENERATE") + case CancelSendingInvitationCodeCallback: + return r.invitationCodeCallback(b, ctx, "CANCEL") default: return fmt.Errorf("unknown command: %s", c) } @@ -117,7 +122,7 @@ func (r *RootHandler) inviteCommandHandler(b *gotgbot.Bot, ctx *ext.Context) err var replyMarkup gotgbot.InlineKeyboardMarkup if invitationUser.Type == "ZERO" { - msg.WriteString(fmt.Sprintf("Total invitations left: *%d*\nTotal invitations used: *%d*", invitationUser.InvitationsLeft, invitationUser.InvitationsUsed)) + msg.WriteString(fmt.Sprintf("Total invitations available: *%d*\nTotal invitations used: *%d*", invitationUser.InvitationsLeft, invitationUser.InvitationsUsed)) if len(*invitations) == 0 { msg.WriteString("\n\n" + "You have no generated invitation codes\\.") @@ -200,7 +205,7 @@ func (r *RootHandler) manageInvitation(b *gotgbot.Bot, ctx *ext.Context, action return nil } - // Store the message id in the user and set status to replying + // Set user status to generating invitation err = r.userRepo.UpdateUser(r.user, map[string]interface{}{ "State": GeneratingInvitationState, "ContactUUID": "", @@ -239,7 +244,7 @@ func (r *RootHandler) GenerateInvitation(b *gotgbot.Bot, ctx *ext.Context) error return fmt.Errorf("failed to init invitations db repo: %w", err) } - inviter, err := repo.readUser(r.user.UUID) + user, err := repo.readUser(r.user.UUID) if err != nil { return err } @@ -256,8 +261,8 @@ func (r *RootHandler) GenerateInvitation(b *gotgbot.Bot, ctx *ext.Context) error count := uint32(number) // Check if the integer is within the range of uint8 - if count > inviter.InvitationsLeft { - _, err := ctx.EffectiveMessage.Reply(b, fmt.Sprintf("Currently you only have *%d* invitations left and cannot generate a code with *%d* usage\\.", inviter.InvitationsLeft, count), &gotgbot.SendMessageOpts{ + if count > user.InvitationsLeft { + _, err := ctx.EffectiveMessage.Reply(b, fmt.Sprintf("Currently you only have *%d* invitations left and cannot generate a code with *%d* usage\\.", user.InvitationsLeft, count), &gotgbot.SendMessageOpts{ ParseMode: gotgbot.ParseModeMarkdownV2, }) if err != nil { @@ -271,14 +276,14 @@ func (r *RootHandler) GenerateInvitation(b *gotgbot.Bot, ctx *ext.Context) error if err != nil { return fmt.Errorf("failed to genemrate invitation code: %w", err) } - invitation, err := repo.createInvitation("whisper-"+code, inviter.UserID, count) + invitation, err := repo.createInvitation("whisper-"+code, user.UserUUID, count) if err != nil { return err } - // Update inviter - err = repo.updateInviter(inviter, map[string]interface{}{ - "InvitationsLeft": inviter.InvitationsLeft - count, + // Update user + err = repo.updateUser(user, map[string]interface{}{ + "InvitationsLeft": user.InvitationsLeft - count, }) if err != nil { return err @@ -299,3 +304,146 @@ func (r *RootHandler) GenerateInvitation(b *gotgbot.Bot, ctx *ext.Context) error return nil } + +func (r *RootHandler) registerCommandHandler(b *gotgbot.Bot, ctx *ext.Context) error { + repo, err := NewRepository() + if err != nil { + return fmt.Errorf("failed to init invitations db repo: %w", err) + } + + user, err := repo.readUser(r.user.UUID) + + if user == nil || err != nil { + // Set user state to sending invitation code + err = r.userRepo.UpdateUser(r.user, map[string]interface{}{ + "State": SendingInvitationCodeState, + "ContactUUID": "", + "ReplyMessageID": 0, + }) + if err != nil { + return fmt.Errorf("failed to update user state: %w", err) + } + + _, err = ctx.EffectiveMessage.Reply(b, "If you have an invitation code please enter it below.", &gotgbot.SendMessageOpts{ + ReplyMarkup: gotgbot.InlineKeyboardMarkup{ + InlineKeyboard: [][]gotgbot.InlineKeyboardButton{ + { + { + Text: i18n.T(i18n.CancelButtonText), + CallbackData: "inv|reg|c", + }, + }, + }, + }, + }) + if err != nil { + return fmt.Errorf("failed to reply with user invitations: %w", err) + } + } else { + _, err = ctx.EffectiveMessage.Reply(b, "You are already registered silly!", nil) + if err != nil { + return fmt.Errorf("failed to reply with user invitations: %w", err) + } + } + + return nil +} + +func (r *RootHandler) ValidateCode(b *gotgbot.Bot, ctx *ext.Context) error { + invitationCode := ctx.EffectiveMessage.Text + + // create invitations repo + repo, err := NewRepository() + if err != nil { + return fmt.Errorf("failed to init invitations db repo: %w", err) + } + + invitation, err := repo.readInvitation(invitationCode) + + if err != nil || invitation == nil || invitation.InvitationsLeft == 0 { + // Send username instruction + _, err = ctx.EffectiveMessage.Reply(b, "The code you entered is not valid or used before. Please check the code and enter again.", &gotgbot.SendMessageOpts{ + ReplyMarkup: gotgbot.InlineKeyboardMarkup{ + InlineKeyboard: [][]gotgbot.InlineKeyboardButton{ + { + { + Text: i18n.T(i18n.CancelButtonText), + CallbackData: "inv|reg|c", + }, + }, + }, + }, + }) + if err != nil { + return fmt.Errorf("failed to send reply message: %w", err) + } + return nil + } else { + err := repo.updateInvitation(invitation, map[string]interface{}{ + "InvitationsLeft": invitation.InvitationsLeft - 1, + "InvitationsUsed": invitation.InvitationsUsed + 1, + }) + if err != nil { + return err + } + + user, err := repo.readUser(invitation.UserUUID) + + if err != nil { + return fmt.Errorf("failed to get inviter user: %w", err) + } + + err = repo.updateUser(user, map[string]interface{}{ + "InvitationsUsed": user.InvitationsUsed + 1, + }) + if err != nil { + return err + } + + _, err = repo.createUser(r.user.UUID) + if err != nil { + return fmt.Errorf("failed to create user after registration: %w", err) + } + + err = r.userRepo.ResetUserState(r.user) + if err != nil { + return fmt.Errorf("failed to reset user state: %w", err) + } + + // Send username instruction + _, err = ctx.EffectiveMessage.Reply(b, "You registered successfully! Now you can use /link command to get your own link and start using the bot! :)", nil) + if err != nil { + return fmt.Errorf("failed to send reply message: %w", err) + } + return nil + } +} + +func (r *RootHandler) invitationCodeCallback(b *gotgbot.Bot, ctx *ext.Context, action string) error { + cb := ctx.Update.CallbackQuery + + // Remove invitation code command buttons + _, _, err := cb.Message.EditReplyMarkup(b, &gotgbot.EditMessageReplyMarkupOpts{}) + if err != nil { + return fmt.Errorf("failed to update invitation code message markup: %w", err) + } + + if action == "CANCEL" { + // Send callback answer to telegram + _, err = cb.Answer(b, &gotgbot.AnswerCallbackQueryOpts{ + Text: i18n.T(i18n.NeverMindButtonText), + }) + if err != nil { + return fmt.Errorf("failed to answer callback: %w", err) + } + + // Send instruction + _, err = ctx.EffectiveMessage.Reply(b, "Okay! You can always use the command /register to enter invitation codes when you get a valid one!", nil) + if err != nil { + return fmt.Errorf("failed to send reply message: %w", err) + } + } else if action == "SET" { + + } + return nil +} diff --git a/bot/common/invitations/utils.go b/bot/common/invitations/utils.go index 89b5fb5..522ba89 100644 --- a/bot/common/invitations/utils.go +++ b/bot/common/invitations/utils.go @@ -3,6 +3,8 @@ package invitations import ( "crypto/rand" "fmt" + "github.com/PaulSonOfLars/gotgbot/v2" + "github.com/PaulSonOfLars/gotgbot/v2/ext" "math/big" "strings" ) @@ -73,3 +75,31 @@ func generateUniqueInvitationCode(repo *Repository) (string, error) { // Otherwise, keep generating until a unique code is found } } + +func isInvited(repo *Repository, userUUID string) bool { + user, err := repo.readUser(userUUID) + if user == nil || err != nil { + return false + } + return true +} + +func CheckUserInvitation(userUUID string, b *gotgbot.Bot, ctx *ext.Context) bool { + // create invitations repo + repo, err := NewRepository() + if err != nil { + fmt.Sprintln("failed to init invitations db repo: %w", err) + return false + } + + isValid := isInvited(repo, userUUID) + if !isValid { + _, err = ctx.EffectiveMessage.Reply(b, "The bot is not public yet! :(\nIf you have an invitation code, run /register command to activate your account.", nil) + if err != nil { + fmt.Sprintln("failed to reply: %w", err) + } + return false + } + + return true +} diff --git a/bot/common/misc.go b/bot/common/misc.go index ef348bf..b92751d 100644 --- a/bot/common/misc.go +++ b/bot/common/misc.go @@ -143,6 +143,9 @@ func (r *RootHandler) info(b *gotgbot.Bot, ctx *ext.Context) error { } func (r *RootHandler) getLink(b *gotgbot.Bot, ctx *ext.Context) error { + if invitations.CheckUserInvitation(r.user.UUID, b, ctx) != true { + return nil + } alphabet := os.Getenv("SQIDS_ALPHABET") s, _ := sqids.New(sqids.Options{ Alphabet: alphabet, @@ -184,6 +187,13 @@ func (r *RootHandler) processText(b *gotgbot.Bot, ctx *ext.Context) error { return err } return irh.GenerateInvitation(b, ctx) + case invitations.SendingInvitationCodeState: + irh := invitations.NewRootHandler() + err := irh.RetrieveUser(ctx) + if err != nil { + return err + } + return irh.ValidateCode(b, ctx) default: return r.sendError(b, ctx, i18n.T(i18n.InvalidCommandText)) } diff --git a/infra/invitations.tf b/infra/invitations.tf index b4dc8e9..14f208d 100644 --- a/infra/invitations.tf +++ b/infra/invitations.tf @@ -9,13 +9,13 @@ resource "aws_dynamodb_table" "invitations" { } attribute { - name = "UserID" + name = "UserUUID" type = "S" } global_secondary_index { - name = "UserID-GSI" - hash_key = "UserID" + name = "UserUUID-GSI" + hash_key = "UserUUID" range_key = "ItemID" projection_type = "ALL" } @@ -52,7 +52,7 @@ resource "aws_iam_policy" "lambda_dynamodb_invitations_policy" { ], Effect = "Allow", Resource = [ - "${aws_dynamodb_table.invitations.arn}/index/UserID-GSI" + "${aws_dynamodb_table.invitations.arn}/index/UserUUID-GSI" ] } ] diff --git a/invite.sh b/invite.sh index f4f7370..eaf7b45 100755 --- a/invite.sh +++ b/invite.sh @@ -1,10 +1,10 @@ #!/bin/bash # Parameters -USER_ID=$1 +USER_UUID=$1 INVITATIONS_LEFT=$2 -if [ -z "$USER_ID" ] || [ -z "$INVITATIONS_LEFT" ]; then +if [ -z "$USER_UUID" ] || [ -z "$INVITATIONS_LEFT" ]; then echo "Usage: $0 " exit 1 fi @@ -12,8 +12,8 @@ fi aws dynamodb put-item --endpoint-url http://localhost:8000 --region eu-central-1 \ --table-name AnonymousBot_Invitations \ --item "{ - \"ItemID\": {\"S\": \"USER#$USER_ID\"}, - \"UserID\": {\"S\": \"$USER_ID\"}, + \"ItemID\": {\"S\": \"USER#$USER_UUID\"}, + \"UserUUID\": {\"S\": \"$USER_UUID\"}, \"InvitationsLeft\": {\"N\": \"$INVITATIONS_LEFT\"}, \"InvitationsUsed\": {\"N\": \"0\"}, \"Type\": {\"S\": \"ZERO\"} @@ -21,7 +21,7 @@ aws dynamodb put-item --endpoint-url http://localhost:8000 --region eu-central-1 # Check if the command succeeded if [ $? -eq 0 ]; then - echo "Successfully added item with ID $USER_ID to AnonymousBot_Invitations" + echo "Successfully added item with ID $USER_UUID to AnonymousBot_Invitations" else echo "Error: Failed to add item to AnonymousBot_Invitations" fi \ No newline at end of file From f17c787b5a02b2ac84e1e0add71ef8f3e8e6c789 Mon Sep 17 00:00:00 2001 From: Yashar Hosseinpour Date: Thu, 9 May 2024 20:08:48 +0200 Subject: [PATCH 4/7] cleanup invitations repo initialization --- bot/common/invitations/root_handler.go | 70 +++++++++----------------- bot/common/invitations/utils.go | 8 ++- bot/common/misc.go | 4 +- 3 files changed, 34 insertions(+), 48 deletions(-) diff --git a/bot/common/invitations/root_handler.go b/bot/common/invitations/root_handler.go index f1e119a..f4c6faa 100644 --- a/bot/common/invitations/root_handler.go +++ b/bot/common/invitations/root_handler.go @@ -12,8 +12,9 @@ import ( ) type RootHandler struct { - user *users.User - userRepo users.UserRepository + user *users.User + userRepo users.UserRepository + invitationsRepo Repository } type Command string @@ -29,7 +30,7 @@ func (r *RootHandler) init(commandName interface{}) handlers.Response { } } -func (r *RootHandler) RetrieveUser(ctx *ext.Context) error { +func (r *RootHandler) HandleUserAndRepos(ctx *ext.Context) error { // create user repo userRepo, err := users.NewUserRepository() if err != nil { @@ -42,11 +43,18 @@ func (r *RootHandler) RetrieveUser(ctx *ext.Context) error { } r.user = user r.userRepo = *userRepo + + // create invitations repo + invitationsRepo, err := NewRepository() + if err != nil { + return fmt.Errorf("failed to init invitations db repo: %w", err) + } + r.invitationsRepo = *invitationsRepo return nil } func (r *RootHandler) runCommand(b *gotgbot.Bot, ctx *ext.Context, command interface{}) error { - err := r.RetrieveUser(ctx) + err := r.HandleUserAndRepos(ctx) if err != nil { return err } @@ -96,13 +104,7 @@ func (r *RootHandler) processUser(userRepo *users.UserRepository, ctx *ext.Conte } func (r *RootHandler) inviteCommandHandler(b *gotgbot.Bot, ctx *ext.Context) error { - // create invitations repo - repo, err := NewRepository() - if err != nil { - return fmt.Errorf("failed to init invitations db repo: %w", err) - } - - invitationUser, err := repo.readUser(r.user.UUID) + invitationUser, err := r.invitationsRepo.readUser(r.user.UUID) if err != nil && strings.Contains(err.Error(), "dynamo: no item found") { _, err = ctx.EffectiveMessage.Reply(b, "You don't have any invitations!", nil) if err != nil { @@ -113,7 +115,7 @@ func (r *RootHandler) inviteCommandHandler(b *gotgbot.Bot, ctx *ext.Context) err return err } - invitations, err := repo.readInvitationsByUser(r.user.UUID) + invitations, err := r.invitationsRepo.readInvitationsByUser(r.user.UUID) if err != nil { return err } @@ -178,12 +180,7 @@ func (r *RootHandler) manageInvitation(b *gotgbot.Bot, ctx *ext.Context, action } if action == "GENERATE" { - repo, err := NewRepository() - if err != nil { - return fmt.Errorf("failed to init invitations db repo: %w", err) - } - - inviter, err := repo.readUser(r.user.UUID) + inviter, err := r.invitationsRepo.readUser(r.user.UUID) if err != nil { return err } @@ -238,13 +235,7 @@ func (r *RootHandler) manageInvitation(b *gotgbot.Bot, ctx *ext.Context, action func (r *RootHandler) GenerateInvitation(b *gotgbot.Bot, ctx *ext.Context) error { invitationCount := ctx.EffectiveMessage.Text - // create invitations repo - repo, err := NewRepository() - if err != nil { - return fmt.Errorf("failed to init invitations db repo: %w", err) - } - - user, err := repo.readUser(r.user.UUID) + user, err := r.invitationsRepo.readUser(r.user.UUID) if err != nil { return err } @@ -272,17 +263,17 @@ func (r *RootHandler) GenerateInvitation(b *gotgbot.Bot, ctx *ext.Context) error } // Generate a unique invitation code - code, err := generateUniqueInvitationCode(repo) + code, err := generateUniqueInvitationCode() if err != nil { return fmt.Errorf("failed to genemrate invitation code: %w", err) } - invitation, err := repo.createInvitation("whisper-"+code, user.UserUUID, count) + invitation, err := r.invitationsRepo.createInvitation("whisper-"+code, user.UserUUID, count) if err != nil { return err } // Update user - err = repo.updateUser(user, map[string]interface{}{ + err = r.invitationsRepo.updateUser(user, map[string]interface{}{ "InvitationsLeft": user.InvitationsLeft - count, }) if err != nil { @@ -306,12 +297,7 @@ func (r *RootHandler) GenerateInvitation(b *gotgbot.Bot, ctx *ext.Context) error } func (r *RootHandler) registerCommandHandler(b *gotgbot.Bot, ctx *ext.Context) error { - repo, err := NewRepository() - if err != nil { - return fmt.Errorf("failed to init invitations db repo: %w", err) - } - - user, err := repo.readUser(r.user.UUID) + user, err := r.invitationsRepo.readUser(r.user.UUID) if user == nil || err != nil { // Set user state to sending invitation code @@ -352,13 +338,7 @@ func (r *RootHandler) registerCommandHandler(b *gotgbot.Bot, ctx *ext.Context) e func (r *RootHandler) ValidateCode(b *gotgbot.Bot, ctx *ext.Context) error { invitationCode := ctx.EffectiveMessage.Text - // create invitations repo - repo, err := NewRepository() - if err != nil { - return fmt.Errorf("failed to init invitations db repo: %w", err) - } - - invitation, err := repo.readInvitation(invitationCode) + invitation, err := r.invitationsRepo.readInvitation(invitationCode) if err != nil || invitation == nil || invitation.InvitationsLeft == 0 { // Send username instruction @@ -379,7 +359,7 @@ func (r *RootHandler) ValidateCode(b *gotgbot.Bot, ctx *ext.Context) error { } return nil } else { - err := repo.updateInvitation(invitation, map[string]interface{}{ + err := r.invitationsRepo.updateInvitation(invitation, map[string]interface{}{ "InvitationsLeft": invitation.InvitationsLeft - 1, "InvitationsUsed": invitation.InvitationsUsed + 1, }) @@ -387,20 +367,20 @@ func (r *RootHandler) ValidateCode(b *gotgbot.Bot, ctx *ext.Context) error { return err } - user, err := repo.readUser(invitation.UserUUID) + user, err := r.invitationsRepo.readUser(invitation.UserUUID) if err != nil { return fmt.Errorf("failed to get inviter user: %w", err) } - err = repo.updateUser(user, map[string]interface{}{ + err = r.invitationsRepo.updateUser(user, map[string]interface{}{ "InvitationsUsed": user.InvitationsUsed + 1, }) if err != nil { return err } - _, err = repo.createUser(r.user.UUID) + _, err = r.invitationsRepo.createUser(r.user.UUID) if err != nil { return fmt.Errorf("failed to create user after registration: %w", err) } diff --git a/bot/common/invitations/utils.go b/bot/common/invitations/utils.go index 522ba89..bb68024 100644 --- a/bot/common/invitations/utils.go +++ b/bot/common/invitations/utils.go @@ -37,7 +37,13 @@ func getRandomLength(min, max int) (int, error) { } // generateUniqueInvitationCode generates a unique invitation code by ensuring it's not already in DynamoDB. -func generateUniqueInvitationCode(repo *Repository) (string, error) { +func generateUniqueInvitationCode() (string, error) { + // create invitations repo + repo, err := NewRepository() + if err != nil { + return "", fmt.Errorf("failed to init invitations db repo: %w", err) + } + for { // Get random lengths for both sections section1Len, err := getRandomLength(3, 5) diff --git a/bot/common/misc.go b/bot/common/misc.go index 95dcf96..84f859a 100644 --- a/bot/common/misc.go +++ b/bot/common/misc.go @@ -184,14 +184,14 @@ func (r *RootHandler) processText(b *gotgbot.Bot, ctx *ext.Context) error { return r.setUsername(b, ctx) case invitations.GeneratingInvitationState: irh := invitations.NewRootHandler() - err := irh.RetrieveUser(ctx) + err := irh.HandleUserAndRepos(ctx) if err != nil { return err } return irh.GenerateInvitation(b, ctx) case invitations.SendingInvitationCodeState: irh := invitations.NewRootHandler() - err := irh.RetrieveUser(ctx) + err := irh.HandleUserAndRepos(ctx) if err != nil { return err } From b4e98f72b1e2442f2e322b1a4d676e4a538ccb90 Mon Sep 17 00:00:00 2001 From: Yashar Hosseinpour Date: Thu, 9 May 2024 20:14:02 +0200 Subject: [PATCH 5/7] add invitations branch to GitHub workflow --- .github/workflows/main.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ff8ff74..85c91a0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - feat/invitations jobs: build-upload-deploy: From 8781493d528017714c59b628b82bafe985fc4d66 Mon Sep 17 00:00:00 2001 From: Yashar Hosseinpour Date: Thu, 9 May 2024 21:48:08 +0200 Subject: [PATCH 6/7] improve ProcessText logic for invitations module remove l,o, and 0 from invitation codes character set --- bot/common/invitations/main.go | 28 +++++++++++++++++++++++++ bot/common/invitations/root_handler.go | 29 +++++++------------------- bot/common/invitations/utils.go | 2 +- bot/common/misc.go | 18 ++++++---------- 4 files changed, 43 insertions(+), 34 deletions(-) diff --git a/bot/common/invitations/main.go b/bot/common/invitations/main.go index 16c00c7..27edb49 100644 --- a/bot/common/invitations/main.go +++ b/bot/common/invitations/main.go @@ -1,6 +1,7 @@ package invitations import ( + "github.com/PaulSonOfLars/gotgbot/v2" "github.com/PaulSonOfLars/gotgbot/v2/ext" "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers" "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/callbackquery" @@ -40,9 +41,36 @@ const ( func InitInvitations(dispatcher *ext.Dispatcher) { rootHandler := NewRootHandler() + // Commands dispatcher.AddHandler(handlers.NewCommand(string(InviteCommand), rootHandler.init(InviteCommand))) dispatcher.AddHandler(handlers.NewCommand(string(RegisterCommand), rootHandler.init(RegisterCommand))) + + // Callbacks dispatcher.AddHandler(handlers.NewCallback(callbackquery.Prefix("inv|g"), rootHandler.init(GenerateInvitationCallback))) dispatcher.AddHandler(handlers.NewCallback(callbackquery.Prefix("inv|reg|c"), rootHandler.init(CancelSendingInvitationCodeCallback))) +} + +func ProcessText(b *gotgbot.Bot, ctx *ext.Context) (bool, error) { + rh := NewRootHandler() + err := rh.HandleUserAndRepos(ctx) + if err != nil { + return false, err + } + switch rh.user.State { + case GeneratingInvitationState: + err = rh.GenerateInvitation(b, ctx) + if err != nil { + return true, err + } + return true, nil + case SendingInvitationCodeState: + err = rh.ValidateCode(b, ctx) + if err != nil { + return true, err + } + return true, nil + default: + return false, nil + } } diff --git a/bot/common/invitations/root_handler.go b/bot/common/invitations/root_handler.go index f4c6faa..a1285d4 100644 --- a/bot/common/invitations/root_handler.go +++ b/bot/common/invitations/root_handler.go @@ -36,11 +36,15 @@ func (r *RootHandler) HandleUserAndRepos(ctx *ext.Context) error { if err != nil { return fmt.Errorf("failed to init db repo: %w", err) } - user, err := r.processUser(userRepo, ctx) - if err != nil || user == nil { - return fmt.Errorf("failed to process user: %w", err) + user, err := userRepo.ReadUserByUserId(ctx.EffectiveUser.Id) + if err != nil { + user, err = userRepo.CreateUser(ctx.EffectiveUser.Id) + if err != nil || user == nil { + return fmt.Errorf("failed to process user: %w", err) + } } + r.user = user r.userRepo = *userRepo @@ -66,8 +70,6 @@ func (r *RootHandler) runCommand(b *gotgbot.Bot, ctx *ext.Context, command inter return r.inviteCommandHandler(b, ctx) case RegisterCommand: return r.registerCommandHandler(b, ctx) - default: - return fmt.Errorf("unknown command: %s", c) } case CallbackCommand: // Reset user state if necessary @@ -83,24 +85,9 @@ func (r *RootHandler) runCommand(b *gotgbot.Bot, ctx *ext.Context, command inter return r.manageInvitation(b, ctx, "GENERATE") case CancelSendingInvitationCodeCallback: return r.invitationCodeCallback(b, ctx, "CANCEL") - default: - return fmt.Errorf("unknown command: %s", c) } - default: - return fmt.Errorf("unknown command: %s", command) } -} - -func (r *RootHandler) processUser(userRepo *users.UserRepository, ctx *ext.Context) (*users.User, error) { - user, err := userRepo.ReadUserByUserId(ctx.EffectiveUser.Id) - if err != nil { - user, err = userRepo.CreateUser(ctx.EffectiveUser.Id) - if err != nil { - return nil, err - } - } - - return user, nil + return nil } func (r *RootHandler) inviteCommandHandler(b *gotgbot.Bot, ctx *ext.Context) error { diff --git a/bot/common/invitations/utils.go b/bot/common/invitations/utils.go index bb68024..3e629b5 100644 --- a/bot/common/invitations/utils.go +++ b/bot/common/invitations/utils.go @@ -11,7 +11,7 @@ import ( // generateRandomString generates a random string of a specified length from a predefined charset. func generateRandomString(length int) (string, error) { - const charset = "abcdefghijklmnopqrstuvwxyz0123456789" + const charset = "abcdefghijkmnpqrstuvwxyz123456789" result := make([]byte, length) for i := range result { diff --git a/bot/common/misc.go b/bot/common/misc.go index 84f859a..af2fd33 100644 --- a/bot/common/misc.go +++ b/bot/common/misc.go @@ -182,22 +182,16 @@ func (r *RootHandler) processText(b *gotgbot.Bot, ctx *ext.Context) error { return r.sendAnonymousMessage(b, ctx) case users.SettingUsername: return r.setUsername(b, ctx) - case invitations.GeneratingInvitationState: - irh := invitations.NewRootHandler() - err := irh.HandleUserAndRepos(ctx) + default: + processed, err := invitations.ProcessText(b, ctx) if err != nil { return err } - return irh.GenerateInvitation(b, ctx) - case invitations.SendingInvitationCodeState: - irh := invitations.NewRootHandler() - err := irh.HandleUserAndRepos(ctx) - if err != nil { - return err + if processed == false { + return r.sendError(b, ctx, i18n.T(i18n.InvalidCommandText)) + } else { + return nil } - return irh.ValidateCode(b, ctx) - default: - return r.sendError(b, ctx, i18n.T(i18n.InvalidCommandText)) } } From 7e106a7aacfd4e68049779e7f92ea85b5b606452 Mon Sep 17 00:00:00 2001 From: Yashar Hosseinpour Date: Fri, 10 May 2024 15:45:39 +0200 Subject: [PATCH 7/7] cleanup imports --- bot/common/init.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/bot/common/init.go b/bot/common/init.go index a486ff1..7ff22ae 100644 --- a/bot/common/init.go +++ b/bot/common/init.go @@ -3,17 +3,13 @@ package common import ( "encoding/json" "fmt" - "github.com/bugfloyd/anonymous-telegram-bot/common/invitations" - "log" - "net/http" - "os" - "github.com/PaulSonOfLars/gotgbot/v2" "github.com/PaulSonOfLars/gotgbot/v2/ext" "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers" "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/callbackquery" "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/message" "github.com/aws/aws-lambda-go/events" + "github.com/bugfloyd/anonymous-telegram-bot/common/invitations" "github.com/bugfloyd/anonymous-telegram-bot/secrets" "log" "net/http"