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: diff --git a/bot/common/init.go b/bot/common/init.go index 0b99f25..7ff22ae 100644 --- a/bot/common/init.go +++ b/bot/common/init.go @@ -9,6 +9,7 @@ import ( "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" @@ -39,6 +40,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..27edb49 --- /dev/null +++ b/bot/common/invitations/main.go @@ -0,0 +1,76 @@ +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" + "github.com/bugfloyd/anonymous-telegram-bot/common/users" +) + +type User struct { + 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:"UserUUID-GSI,range"` + UserUUID string `index:"UserUUID-GSI,hash"` + InvitationsLeft uint32 + InvitationsUsed uint32 +} + +const ( + GeneratingInvitationState users.State = "GENERATING_INVITATION" + SendingInvitationCodeState users.State = "SENDING_INVITATION_CODE" +) + +const ( + InviteCommand Command = "invite" + RegisterCommand Command = "register" +) + +const ( + GenerateInvitationCallback CallbackCommand = "generate-invitation-callback" + CancelSendingInvitationCodeCallback CallbackCommand = "cancel-invitation-code-callback" +) + +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/repository.go b/bot/common/invitations/repository.go new file mode 100644 index 0000000..83d6ed2 --- /dev/null +++ b/bot/common/invitations/repository.go @@ -0,0 +1,145 @@ +package invitations + +import ( + "fmt" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "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) createUser(userUUID string) (*User, error) { + i := User{ + ItemID: "USER#" + userUUID, + UserUUID: userUUID, + InvitationsLeft: 0, + InvitationsUsed: 0, + Type: "NORMAL", + } + err := repo.table.Put(i).Run() + if err != nil { + return nil, fmt.Errorf("invitations: failed to create user: %w", err) + } + return &i, nil +} + +func (repo *Repository) createInvitation(code string, userUUID string, count uint32) (*Invitation, error) { + i := Invitation{ + ItemID: "INVITATION#" + code, + UserUUID: userUUID, + 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) readUser(userUUID string) (*User, error) { + var u User + err := repo.table.Get("ItemID", "USER#"+userUUID).One(&u) + if err != nil { + 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", "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) readInvitationsByUser(userUUID string) (*[]Invitation, error) { + var invitation []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) 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) + } + err := updateBuilder.Run() + if err != nil { + return fmt.Errorf("failed to update user: %w", err) + } + + // Reflecting on user to update fields based on updates map + 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() { + // 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 +} + +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 new file mode 100644 index 0000000..a1285d4 --- /dev/null +++ b/bot/common/invitations/root_handler.go @@ -0,0 +1,416 @@ +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/i18n" + "github.com/bugfloyd/anonymous-telegram-bot/common/users" + "strconv" + "strings" +) + +type RootHandler struct { + user *users.User + userRepo users.UserRepository + invitationsRepo Repository +} + +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) HandleUserAndRepos(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 := 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 + + // 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.HandleUserAndRepos(ctx) + if err != nil { + return err + } + + switch c := command.(type) { + case Command: + switch c { + case InviteCommand: + return r.inviteCommandHandler(b, ctx) + case RegisterCommand: + return r.registerCommandHandler(b, ctx) + } + 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") + case CancelSendingInvitationCodeCallback: + return r.invitationCodeCallback(b, ctx, "CANCEL") + } + } + return nil +} + +func (r *RootHandler) inviteCommandHandler(b *gotgbot.Bot, ctx *ext.Context) error { + 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 { + return fmt.Errorf("failed to reply with user invitations: %w", err) + } + return nil + } else if err != nil { + return err + } + + invitations, err := r.invitationsRepo.readInvitationsByUser(r.user.UUID) + if err != nil { + return err + } + + var msg strings.Builder + var replyMarkup gotgbot.InlineKeyboardMarkup + + if invitationUser.Type == "ZERO" { + 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\\.") + } 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" { + inviter, err := r.invitationsRepo.readUser(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 + } + + // Set user status to generating invitation + 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 + + user, err := r.invitationsRepo.readUser(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 > 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 { + return fmt.Errorf("failed to send reply message: %w", err) + } + return nil + } + + // Generate a unique invitation code + code, err := generateUniqueInvitationCode() + if err != nil { + return fmt.Errorf("failed to genemrate invitation code: %w", err) + } + invitation, err := r.invitationsRepo.createInvitation("whisper-"+code, user.UserUUID, count) + if err != nil { + return err + } + + // Update user + err = r.invitationsRepo.updateUser(user, map[string]interface{}{ + "InvitationsLeft": user.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 +} + +func (r *RootHandler) registerCommandHandler(b *gotgbot.Bot, ctx *ext.Context) error { + user, err := r.invitationsRepo.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 + + invitation, err := r.invitationsRepo.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 := r.invitationsRepo.updateInvitation(invitation, map[string]interface{}{ + "InvitationsLeft": invitation.InvitationsLeft - 1, + "InvitationsUsed": invitation.InvitationsUsed + 1, + }) + if err != nil { + return err + } + + user, err := r.invitationsRepo.readUser(invitation.UserUUID) + + if err != nil { + return fmt.Errorf("failed to get inviter user: %w", err) + } + + err = r.invitationsRepo.updateUser(user, map[string]interface{}{ + "InvitationsUsed": user.InvitationsUsed + 1, + }) + if err != nil { + return err + } + + _, err = r.invitationsRepo.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 new file mode 100644 index 0000000..3e629b5 --- /dev/null +++ b/bot/common/invitations/utils.go @@ -0,0 +1,111 @@ +package invitations + +import ( + "crypto/rand" + "fmt" + "github.com/PaulSonOfLars/gotgbot/v2" + "github.com/PaulSonOfLars/gotgbot/v2/ext" + "math/big" + "strings" +) + +// generateRandomString generates a random string of a specified length from a predefined charset. +func generateRandomString(length int) (string, error) { + const charset = "abcdefghijkmnpqrstuvwxyz123456789" + + 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() (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) + 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 + } +} + +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 c79d1b5..07ff3ae 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/bugfloyd/anonymous-telegram-bot/secrets" "github.com/sqids/sqids-go" @@ -144,6 +145,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 + } s, _ := sqids.New(sqids.Options{ Alphabet: secrets.SqidsAlphabet, }) @@ -178,7 +182,15 @@ func (r *RootHandler) processText(b *gotgbot.Bot, ctx *ext.Context) error { case users.SettingUsername: return r.setUsername(b, ctx) default: - return r.sendError(b, ctx, i18n.T(i18n.InvalidCommandText)) + processed, err := invitations.ProcessText(b, ctx) + if err != nil { + return err + } + if processed == false { + return r.sendError(b, ctx, i18n.T(i18n.InvalidCommandText)) + } else { + return nil + } } } diff --git a/infra/invitations.tf b/infra/invitations.tf new file mode 100644 index 0000000..14f208d --- /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 = "UserUUID" + type = "S" + } + + global_secondary_index { + name = "UserUUID-GSI" + hash_key = "UserUUID" + 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/UserUUID-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 100755 index 0000000..eaf7b45 --- /dev/null +++ b/invite.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# Parameters +USER_UUID=$1 +INVITATIONS_LEFT=$2 + +if [ -z "$USER_UUID" ] || [ -z "$INVITATIONS_LEFT" ]; then + echo "Usage: $0 " + exit 1 +fi + +aws dynamodb put-item --endpoint-url http://localhost:8000 --region eu-central-1 \ + --table-name AnonymousBot_Invitations \ + --item "{ + \"ItemID\": {\"S\": \"USER#$USER_UUID\"}, + \"UserUUID\": {\"S\": \"$USER_UUID\"}, + \"InvitationsLeft\": {\"N\": \"$INVITATIONS_LEFT\"}, + \"InvitationsUsed\": {\"N\": \"0\"}, + \"Type\": {\"S\": \"ZERO\"} + }" + +# Check if the command succeeded +if [ $? -eq 0 ]; then + 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