diff --git a/.checkov.yaml b/.checkov.yaml index 029c373..3109122 100644 --- a/.checkov.yaml +++ b/.checkov.yaml @@ -1,11 +1,13 @@ skip-check: # CKV - CKV_AWS_18 # INFO "Ensure the S3 bucket has access logging enabled" + - CKV_AWS_28 # HIGH "Ensure DynamoDB point in time recovery (backup) is enabled" - CKV_AWS_59 # LOW "Ensure there is no open access to back-end resources through API" - CKV_AWS_109 # LOW "Ensure IAM policies does not allow permissions management / resource exposure without constraints" - CKV_AWS_111 # LOW "Ensure IAM policies does not allow write access without constraints" - CKV_AWS_116 # LOW "Ensure that AWS Lambda function is configured for a Dead Letter Queue(DLQ)" - CKV_AWS_117 # LOW "Ensure that AWS Lambda function is configured inside a VPC" + - CKV_AWS_119 # INFO "Ensure DynamoDB Tables are encrypted using a KMS Customer Managed CMK" - CKV_AWS_120 # LOW "Ensure API Gateway caching is enabled" - CKV_AWS_144 # LOW "Ensure that S3 bucket has cross-region replication enabled" - CKV_AWS_158 # LOW "Ensure that CloudWatch Log Group is encrypted by KMS" @@ -13,10 +15,12 @@ skip-check: - CKV_AWS_225 # LOW "Ensure API Gateway method setting caching is enabled" - CKV_AWS_272 # HIGH "Ensure AWS Lambda function is configured to validate code-signing" - CKV_AWS_283 # HIGH "Ensure no IAM policies documents allow ALL or any AWS principal permissions to the resource" + - CKV_AWS_297 # HIGH "Ensure EventBridge Scheduler Schedule uses Customer Managed Key (CMK)" - CKV_AWS_337 # HIGH "Ensure SSM parameters are using KMS CMK" - CKV_AWS_338 # INFO "Ensure CloudWatch log groups retains logs for at least 1 year" - CKV_AWS_356 # HIGH "Ensure no IAM policies documents allow "*" as a statement's resource for restrictable actions" # CKV2 + - CKV2_AWS_16 # INFO "Ensure that Auto Scaling is enabled on your DynamoDB tables" - CKV2_AWS_29 # MEDIUM "Ensure public API gateway are protected by WAF" - CKV2_AWS_51 # LOW "Ensure AWS API Gateway endpoints uses client certificate authentication" - CKV2_AWS_73 # INFO "Ensure AWS SQS uses CMK not AWS default keys for encryption" diff --git a/apps/poweron/config/config.go b/apps/poweron/config/config.go index 0ec19bb..907ac8a 100644 --- a/apps/poweron/config/config.go +++ b/apps/poweron/config/config.go @@ -2,29 +2,55 @@ package config import ( "context" + "fmt" "os" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ssm" +) + +const ( + EnvAPIToken = "TELEGRAM_APITOKEN" + EnvSSMParamAPIToken = "SSM_PARAM_TELEGRAM_APITOKEN" + EnvSSMParamCache = "SSM_PARAM_CACHE" + EnvDynamoDBSubscriptionsTable = "DYNAMODB_TABLE_SUBSCRIPTIONS" + + PowerScheduleCacheTTL = 5 * time.Minute ) type Config struct { + AWSConfig aws.Config TelegramAPIToken string - SSMParamTelegramAPIToken string - SSMParamPowerScheduleCache string + SSMParamCache string + DynamoDBSubscriptionsTable string PowerScheduleCacheTTL time.Duration } -func LoadAppConfig() Config { - return Config{ - TelegramAPIToken: os.Getenv("TELEGRAM_APITOKEN"), - SSMParamTelegramAPIToken: os.Getenv("SSM_PARAM_TELEGRAM_APITOKEN"), - SSMParamPowerScheduleCache: os.Getenv("SSM_PARAM_POWERON_CACHE"), - PowerScheduleCacheTTL: 5 * time.Minute, +func LoadConfig(ctx context.Context) (*Config, error) { + awsConfig, err := config.LoadDefaultConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load AWS config: %w", err) + } + + apiToken := os.Getenv(EnvAPIToken) + if apiToken == "" { + response, err := ssm.NewFromConfig(awsConfig).GetParameter(ctx, &ssm.GetParameterInput{ + Name: aws.String(os.Getenv(EnvSSMParamAPIToken)), + WithDecryption: aws.Bool(true), + }) + if err != nil { + return nil, fmt.Errorf("failed to get Telegram API token from SSM parameter: %w", err) + } + apiToken = *response.Parameter.Value } -} -func LoadAWSConfig(ctx context.Context) (aws.Config, error) { - return config.LoadDefaultConfig(ctx) + return &Config{ + AWSConfig: awsConfig, + TelegramAPIToken: apiToken, + SSMParamCache: os.Getenv(EnvSSMParamCache), + DynamoDBSubscriptionsTable: os.Getenv(EnvDynamoDBSubscriptionsTable), + PowerScheduleCacheTTL: PowerScheduleCacheTTL, + }, nil } diff --git a/apps/poweron/go.mod b/apps/poweron/go.mod index 5b62255..535bfcc 100644 --- a/apps/poweron/go.mod +++ b/apps/poweron/go.mod @@ -6,6 +6,8 @@ require ( github.com/aws/aws-lambda-go v1.51.0 github.com/aws/aws-sdk-go-v2 v1.41.0 github.com/aws/aws-sdk-go-v2/config v1.32.5 + github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.29 + github.com/aws/aws-sdk-go-v2/service/dynamodb v1.53.5 github.com/aws/aws-sdk-go-v2/service/ssm v1.67.7 github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 ) @@ -16,7 +18,9 @@ require ( github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.32.9 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.16 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect diff --git a/apps/poweron/go.sum b/apps/poweron/go.sum index 69b2c04..9e04490 100644 --- a/apps/poweron/go.sum +++ b/apps/poweron/go.sum @@ -6,6 +6,8 @@ github.com/aws/aws-sdk-go-v2/config v1.32.5 h1:pz3duhAfUgnxbtVhIK39PGF/AHYyrzGEy github.com/aws/aws-sdk-go-v2/config v1.32.5/go.mod h1:xmDjzSUs/d0BB7ClzYPAZMmgQdrodNjPPhd6bGASwoE= github.com/aws/aws-sdk-go-v2/credentials v1.19.5 h1:xMo63RlqP3ZZydpJDMBsH9uJ10hgHYfQFIk1cHDXrR4= github.com/aws/aws-sdk-go-v2/credentials v1.19.5/go.mod h1:hhbH6oRcou+LpXfA/0vPElh/e0M3aFeOblE1sssAAEk= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.29 h1:dQFhl5Bnl/SK1EVpgElK5dckAE+lMHXnl5WCeRvNEG0= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.29/go.mod h1:BtBP1TCx5BTCh1uTVXpo3b/odnRECBpZdL5oHQarJJs= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= @@ -14,8 +16,14 @@ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.53.5 h1:mSBrQCXMjEvLHsYyJVbN8QQlcITXwHEuu+8mX9e2bSo= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.53.5/go.mod h1:eEuD0vTf9mIzsSjGBFWIaNQwtH5/mzViJOVQfnMY5DE= +github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.32.9 h1:mB79k/ZTxQL4oDPxLAf2rhcUEvXlHkj3loGA2O9xREk= +github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.32.9/go.mod h1:wXQmLDkBNh60jxAaRldON9poacv+GiSIBw/kRuT/mtE= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.16 h1:8g4OLy3zfNzLV20wXmZgx+QumI9WhWHnd4GCdvETxs4= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.16/go.mod h1:5a78jwLMs7BaesU0UIhLfVy2ZmOEgOy6ewYQXKTD37Q= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= diff --git a/apps/poweron/main.go b/apps/poweron/main.go index 2257dd4..ce06814 100644 --- a/apps/poweron/main.go +++ b/apps/poweron/main.go @@ -3,38 +3,31 @@ package main import ( "context" "encoding/json" - "fmt" "log" "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/ssm" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" "github.com/rvolykh/telegram-bot/apps/poweron/config" "github.com/rvolykh/telegram-bot/apps/poweron/operations" - "github.com/rvolykh/telegram-bot/apps/poweron/reply" ) type LambdaFunction struct { - SSMClient *ssm.Client - Config config.Config + Config *config.Config } func (f *LambdaFunction) Handler(ctx context.Context, sqsEvent events.SQSEvent) error { log.Printf("Received SQS Event with %d records", len(sqsEvent.Records)) - apiToken, err := f.getAPIToken(ctx) - if err != nil { - return fmt.Errorf("failed to get API token: %w", err) + if len(sqsEvent.Records) > 0 { + return f.handleSQS(ctx, sqsEvent.Records) } - telegram, err := reply.NewTelegram(apiToken) - if err != nil { - return fmt.Errorf("failed to create telegram: %w", err) - } + return f.handleSchedule(ctx) +} - for i, record := range sqsEvent.Records { +func (f *LambdaFunction) handleSQS(ctx context.Context, records []events.SQSMessage) error { + for i, record := range records { log.Printf("Processing record %d:", i+1) log.Printf(" Message ID: %s", record.MessageId) log.Printf(" Receipt Handle: %s", record.ReceiptHandle) @@ -61,57 +54,50 @@ func (f *LambdaFunction) Handler(ctx context.Context, sqsEvent events.SQSEvent) args := update.Message.CommandArguments() switch args { - case "": - err = operations.ShowMainMenu(ctx, telegram, chatID) - if err != nil { - log.Printf("Error showing main menu: %v", err) - continue - } - log.Printf("Main menu sent to chat: %d", chatID) - case "Сьогодні": - err = operations.Today(ctx, f.Config, telegram, f.SSMClient, chatID) - if err != nil { + if err := operations.Today(ctx, f.Config, chatID); err != nil { log.Printf("Error showing today schedule: %v", err) continue } log.Printf("Today schedule sent to chat: %d", chatID) case "Завтра": - err = operations.Tomorrow(ctx, f.Config, telegram, f.SSMClient, chatID) - if err != nil { + if err := operations.Tomorrow(ctx, f.Config, chatID); err != nil { log.Printf("Error showing tomorrow schedule: %v", err) continue } log.Printf("Tomorrow schedule sent to chat: %d", chatID) case "Підписатись": - err = operations.ShowSelectGroupMenu(ctx, telegram, chatID) - if err != nil { + if err := operations.ShowSelectGroupMenu(ctx, f.Config, chatID); err != nil { log.Printf("Error subscribing: %v", err) continue } log.Printf("Select group menu sent to chat: %d", chatID) case "Відписатись": - err = operations.Unsubscribe(ctx, f.Config, telegram, f.SSMClient, chatID) - if err != nil { + if err := operations.Unsubscribe(ctx, f.Config, chatID); err != nil { log.Printf("Error unsubscribing: %v", err) continue } log.Printf("Unsubscribed from chat: %d", chatID) + case "": + if err := operations.ShowMainMenu(ctx, f.Config, chatID); err != nil { + log.Printf("Error showing main menu: %v", err) + continue + } + log.Printf("Main menu sent to chat: %d", chatID) + case "Закрити": - err = operations.CloseMenu(ctx, telegram, chatID) - if err != nil { + if err := operations.CloseMenu(ctx, f.Config, chatID); err != nil { log.Printf("Error closing menu: %v", err) continue } log.Printf("Closed menu in chat: %d", chatID) case "1.1", "1.2", "2.1", "2.2", "3.1", "3.2", "4.1", "4.2", "5.1", "5.2", "6.1", "6.2": - err = operations.Subscribe(ctx, f.Config, telegram, f.SSMClient, chatID, args) - if err != nil { + if err := operations.Subscribe(ctx, f.Config, chatID, args); err != nil { log.Printf("Error showing group schedule: %v", err) continue } @@ -126,30 +112,15 @@ func (f *LambdaFunction) Handler(ctx context.Context, sqsEvent events.SQSEvent) return nil } -func (f *LambdaFunction) getAPIToken(ctx context.Context) (string, error) { - if f.Config.TelegramAPIToken != "" { - return f.Config.TelegramAPIToken, nil - } - - apiToken, err := f.SSMClient.GetParameter(ctx, &ssm.GetParameterInput{ - Name: aws.String(f.Config.SSMParamTelegramAPIToken), - WithDecryption: aws.Bool(true), - }) - if err != nil { - return "", fmt.Errorf("failed to get SSM parameter: %w", err) - } - return *apiToken.Parameter.Value, nil +func (f *LambdaFunction) handleSchedule(ctx context.Context) error { + return operations.DeliverScheduleUpdates(ctx, f.Config) } func main() { - cfg, err := config.LoadAWSConfig(context.TODO()) + cfg, err := config.LoadConfig(context.TODO()) if err != nil { - log.Printf("unable to load AWS config: %v", err) + log.Printf("unable to load config: %v", err) } - fn := &LambdaFunction{ - SSMClient: ssm.NewFromConfig(cfg), - Config: config.LoadAppConfig(), - } - lambda.Start(fn.Handler) + lambda.Start((&LambdaFunction{Config: cfg}).Handler) } diff --git a/apps/poweron/operations/menu.go b/apps/poweron/operations/menu.go index d2555fd..d0d4467 100644 --- a/apps/poweron/operations/menu.go +++ b/apps/poweron/operations/menu.go @@ -5,6 +5,7 @@ import ( "fmt" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/rvolykh/telegram-bot/apps/poweron/config" "github.com/rvolykh/telegram-bot/apps/poweron/reply" ) @@ -56,26 +57,29 @@ var ( keyboardCloseMenu = tgbotapi.NewRemoveKeyboard(true) ) -func ShowMainMenu(ctx context.Context, t *reply.Telegram, chatID int64) error { - err := t.SendMenu(ctx, chatID, "Оберіть операцію", keyboardMainMenu) +func ShowMainMenu(ctx context.Context, cfg *config.Config, chatID int64) error { + t, err := reply.NewTelegram(cfg) if err != nil { - return fmt.Errorf("failed to send main menu: %w", err) + return fmt.Errorf("failed to create telegram client: %w", err) } - return nil + + return t.SendMenu(ctx, chatID, "Оберіть операцію", keyboardMainMenu) } -func ShowSelectGroupMenu(ctx context.Context, t *reply.Telegram, chatID int64) error { - err := t.SendMenu(ctx, chatID, "Оберіть групу", keyboardGroupsMenu) +func ShowSelectGroupMenu(ctx context.Context, cfg *config.Config, chatID int64) error { + t, err := reply.NewTelegram(cfg) if err != nil { - return fmt.Errorf("failed to send select group menu: %w", err) + return fmt.Errorf("failed to create telegram client: %w", err) } - return nil + + return t.SendMenu(ctx, chatID, "Оберіть групу", keyboardGroupsMenu) } -func CloseMenu(ctx context.Context, t *reply.Telegram, chatID int64) error { - err := t.SendMenu(ctx, chatID, "Закрито", keyboardCloseMenu) +func CloseMenu(ctx context.Context, cfg *config.Config, chatID int64) error { + t, err := reply.NewTelegram(cfg) if err != nil { - return fmt.Errorf("failed to send close menu: %w", err) + return fmt.Errorf("failed to create telegram client: %w", err) } - return nil + + return t.SendMenu(ctx, chatID, "Закрито", keyboardCloseMenu) } diff --git a/apps/poweron/operations/schedule.go b/apps/poweron/operations/schedule.go new file mode 100644 index 0000000..da55a08 --- /dev/null +++ b/apps/poweron/operations/schedule.go @@ -0,0 +1,97 @@ +package operations + +import ( + "context" + "fmt" + "log" + "strings" + + "github.com/rvolykh/telegram-bot/apps/poweron/config" + "github.com/rvolykh/telegram-bot/apps/poweron/reply" + "github.com/rvolykh/telegram-bot/apps/poweron/scrap" + "github.com/rvolykh/telegram-bot/apps/poweron/subscriptions" +) + +func DeliverScheduleUpdates(ctx context.Context, cfg *config.Config) error { + poweron := scrap.NewPoweron(cfg) + + powerSchedule, err := poweron.GetPowerSchedule(ctx) + if err != nil { + return fmt.Errorf("failed to get power schedule: %w", err) + } + + s, err := subscriptions.NewSubscriptionsDB(cfg) + if err != nil { + return fmt.Errorf("failed to create subscriptions repository: %w", err) + } + + t, err := reply.NewTelegram(cfg) + if err != nil { + return fmt.Errorf("failed to create telegram client: %w", err) + } + + subscribers, err := s.ListSubscriptions(ctx) + if err != nil { + return fmt.Errorf("failed to list subscriptions: %w", err) + } + + for _, subscriber := range subscribers { + message := prepareMessage(powerSchedule, subscriber.Groups) + + prev, err := t.GetPinnedMessage(ctx, subscriber.ChatID) + if err != nil { + log.Printf("Failed to get pinned message %d: %s", subscriber.ChatID, err) + } + + // telegram trims last \n so should we before compare + if prev == message[:len(message)-1] { + log.Printf("No updates, skipping for chat %d", subscriber.ChatID) + continue + } + + messageID, err := t.SendMessage(ctx, subscriber.ChatID, message) + if err != nil { + log.Printf("Failed to send message to %d: %s", subscriber.ChatID, err) + continue + } + log.Printf("Message sent to chat: %d", subscriber.ChatID) + + if err := t.PinMessage(ctx, subscriber.ChatID, messageID); err != nil { + log.Printf("Failed to pin message in %d: %s", subscriber.ChatID, err) + continue + } + log.Printf("Message %d is pinned in chat %d", messageID, subscriber.ChatID) + } + return nil +} + +func prepareMessage(powerSchedule scrap.Schedule, groups []string) string { + var message strings.Builder + + message.WriteString("Сьогодні:\n") + filterPowerScheduleGroups(&message, powerSchedule.Today, groups) + + message.WriteString("\nЗавтра:\n") + filterPowerScheduleGroups(&message, powerSchedule.Tomorrow, groups) + + return message.String() +} + +func filterPowerScheduleGroups(b *strings.Builder, schedule string, groups []string) { + for i, line := range strings.Split(schedule, "\n") { + if i > 1 { + var skip = true + for _, group := range groups { + if strings.Contains(line, group) { + skip = false + break + } + } + if skip { + continue + } + } + + b.WriteString(line + "\n") + } +} diff --git a/apps/poweron/operations/subscribe.go b/apps/poweron/operations/subscribe.go index 27f1561..49551ff 100644 --- a/apps/poweron/operations/subscribe.go +++ b/apps/poweron/operations/subscribe.go @@ -2,13 +2,52 @@ package operations import ( "context" + "fmt" + "slices" - "github.com/aws/aws-sdk-go-v2/service/ssm" "github.com/rvolykh/telegram-bot/apps/poweron/config" "github.com/rvolykh/telegram-bot/apps/poweron/reply" + "github.com/rvolykh/telegram-bot/apps/poweron/subscriptions" ) -func Subscribe(ctx context.Context, cfg config.Config, t *reply.Telegram, ssmClient *ssm.Client, chatID int64, group string) error { - // TODO: Implement subscription logic - return t.SendMessage(ctx, chatID, "Підписалися на групу "+group) +func Subscribe(ctx context.Context, cfg *config.Config, chatID int64, group string) error { + t, err := reply.NewTelegram(cfg) + if err != nil { + return fmt.Errorf("failed to create telegram client: %w", err) + } + + s, err := subscriptions.NewSubscriptionsDB(cfg) + if err != nil { + return fmt.Errorf("failed to create subscriptions repository: %w", err) + } + + groups, err := s.GetSubscription(ctx, chatID) + if err != nil { + return fmt.Errorf("failed to get subscription: %w", err) + } + + if len(groups) == 0 { + if err = s.InsertSubscription(ctx, chatID, group); err != nil { + return fmt.Errorf("failed to insert subscription: %w", err) + } + } else { + if slices.Contains(groups, group) { + _, err = t.SendMessage(ctx, chatID, "Ви вже підписані на цю групу") + if err != nil { + return fmt.Errorf("failed to send message: %w", err) + } + return nil + } + + if err = s.UpdateSubscription(ctx, chatID, group); err != nil { + return fmt.Errorf("failed to update subscription: %w", err) + } + } + + _, err = t.SendMessage(ctx, chatID, "Ви підписалися на сповіщення для групи "+group) + if err != nil { + return fmt.Errorf("failed to send message: %w", err) + } + + return nil } diff --git a/apps/poweron/operations/today.go b/apps/poweron/operations/today.go index 495dfa4..66cd376 100644 --- a/apps/poweron/operations/today.go +++ b/apps/poweron/operations/today.go @@ -3,21 +3,28 @@ package operations import ( "context" "fmt" - "time" - - "github.com/aws/aws-sdk-go-v2/service/ssm" "github.com/rvolykh/telegram-bot/apps/poweron/config" "github.com/rvolykh/telegram-bot/apps/poweron/reply" "github.com/rvolykh/telegram-bot/apps/poweron/scrap" ) -func Today(ctx context.Context, cfg config.Config, t *reply.Telegram, ssmClient *ssm.Client, chatID int64) error { - poweron := scrap.NewPoweron(ssmClient, cfg.SSMParamPowerScheduleCache, 1*time.Hour) +func Today(ctx context.Context, cfg *config.Config, chatID int64) error { + poweron := scrap.NewPoweron(cfg) powerSchedule, err := poweron.GetPowerSchedule(ctx) if err != nil { return fmt.Errorf("failed to get power schedule: %w", err) } - return t.SendMessage(ctx, chatID, powerSchedule) + t, err := reply.NewTelegram(cfg) + if err != nil { + return fmt.Errorf("failed to create telegram client: %w", err) + } + + _, err = t.SendMessage(ctx, chatID, powerSchedule.Today) + if err != nil { + return fmt.Errorf("failed to send message: %w", err) + } + + return nil } diff --git a/apps/poweron/operations/tomorrow.go b/apps/poweron/operations/tomorrow.go index ea2771c..808485e 100644 --- a/apps/poweron/operations/tomorrow.go +++ b/apps/poweron/operations/tomorrow.go @@ -2,13 +2,30 @@ package operations import ( "context" + "fmt" - "github.com/aws/aws-sdk-go-v2/service/ssm" "github.com/rvolykh/telegram-bot/apps/poweron/config" "github.com/rvolykh/telegram-bot/apps/poweron/reply" + "github.com/rvolykh/telegram-bot/apps/poweron/scrap" ) -func Tomorrow(ctx context.Context, cfg config.Config, t *reply.Telegram, ssmClient *ssm.Client, chatID int64) error { - // TODO: Implement tomorrow logic - return t.SendMessage(ctx, chatID, "Дана операція ще не реалізована") +func Tomorrow(ctx context.Context, cfg *config.Config, chatID int64) error { + poweron := scrap.NewPoweron(cfg) + + powerSchedule, err := poweron.GetPowerSchedule(ctx) + if err != nil { + return fmt.Errorf("failed to get power schedule: %w", err) + } + + t, err := reply.NewTelegram(cfg) + if err != nil { + return fmt.Errorf("failed to create telegram client: %w", err) + } + + _, err = t.SendMessage(ctx, chatID, powerSchedule.Tomorrow) + if err != nil { + return fmt.Errorf("failed to send message: %w", err) + } + + return nil } diff --git a/apps/poweron/operations/unsubscribe.go b/apps/poweron/operations/unsubscribe.go index edea277..465f531 100644 --- a/apps/poweron/operations/unsubscribe.go +++ b/apps/poweron/operations/unsubscribe.go @@ -2,13 +2,32 @@ package operations import ( "context" + "fmt" - "github.com/aws/aws-sdk-go-v2/service/ssm" "github.com/rvolykh/telegram-bot/apps/poweron/config" "github.com/rvolykh/telegram-bot/apps/poweron/reply" + "github.com/rvolykh/telegram-bot/apps/poweron/subscriptions" ) -func Unsubscribe(ctx context.Context, cfg config.Config, t *reply.Telegram, ssmClient *ssm.Client, chatID int64) error { - // TODO: Implement unsubscribe logic - return t.SendMessage(ctx, chatID, "Відписалися від усіх груп") +func Unsubscribe(ctx context.Context, cfg *config.Config, chatID int64) error { + t, err := reply.NewTelegram(cfg) + if err != nil { + return fmt.Errorf("failed to create telegram client: %w", err) + } + + s, err := subscriptions.NewSubscriptionsDB(cfg) + if err != nil { + return fmt.Errorf("failed to create subscriptions repository: %w", err) + } + + if err := s.DeleteSubscription(ctx, chatID); err != nil { + return fmt.Errorf("failed to delete subscription: %w", err) + } + + _, err = t.SendMessage(ctx, chatID, "Ви відписались від сповіщень для всіх груп") + if err != nil { + return fmt.Errorf("failed to send message: %w", err) + } + + return nil } diff --git a/apps/poweron/reply/get_pinned_message.go b/apps/poweron/reply/get_pinned_message.go new file mode 100644 index 0000000..d8a04ca --- /dev/null +++ b/apps/poweron/reply/get_pinned_message.go @@ -0,0 +1,27 @@ +package reply + +import ( + "context" + "fmt" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +func (t *Telegram) GetPinnedMessage(ctx context.Context, chatID int64) (string, error) { + input := tgbotapi.ChatInfoConfig{ + ChatConfig: tgbotapi.ChatConfig{ + ChatID: chatID, + }, + } + + output, err := t.bot.GetChat(input) + if err != nil { + return "", fmt.Errorf("failed to get pinned message: %w", err) + } + + if output.PinnedMessage == nil { + return "", nil + } + + return output.PinnedMessage.Text, nil +} diff --git a/apps/poweron/reply/pin_message.go b/apps/poweron/reply/pin_message.go new file mode 100644 index 0000000..c6141ac --- /dev/null +++ b/apps/poweron/reply/pin_message.go @@ -0,0 +1,21 @@ +package reply + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +func (t *Telegram) PinMessage(ctx context.Context, chatID int64, messageID int) error { + msg := tgbotapi.PinChatMessageConfig{ChatID: chatID, MessageID: messageID, DisableNotification: true} + + _, err := t.bot.Send(msg) + // Bug in SDK, Telegram API respond to Pin request just by bool value, not message + if err != nil && !errors.Is(err, &json.UnmarshalTypeError{}) { + return fmt.Errorf("failed to pin message: %w", err) + } + return nil +} diff --git a/apps/poweron/reply/send_message.go b/apps/poweron/reply/send_message.go index 0b12a1c..74a654a 100644 --- a/apps/poweron/reply/send_message.go +++ b/apps/poweron/reply/send_message.go @@ -7,10 +7,10 @@ import ( tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) -func (t *Telegram) SendMessage(ctx context.Context, chatID int64, message string) error { - _, err := t.bot.Send(tgbotapi.NewMessage(chatID, message)) +func (t *Telegram) SendMessage(ctx context.Context, chatID int64, message string) (int, error) { + result, err := t.bot.Send(tgbotapi.NewMessage(chatID, message)) if err != nil { - return fmt.Errorf("failed to send message: %w", err) + return 0, fmt.Errorf("telegram API has failed: %w", err) } - return nil + return result.MessageID, nil } diff --git a/apps/poweron/reply/telegram.go b/apps/poweron/reply/telegram.go index c41d05e..40dcae3 100644 --- a/apps/poweron/reply/telegram.go +++ b/apps/poweron/reply/telegram.go @@ -4,14 +4,15 @@ import ( "fmt" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/rvolykh/telegram-bot/apps/poweron/config" ) type Telegram struct { bot *tgbotapi.BotAPI } -func NewTelegram(apiToken string) (*Telegram, error) { - bot, err := tgbotapi.NewBotAPI(apiToken) +func NewTelegram(cfg *config.Config) (*Telegram, error) { + bot, err := tgbotapi.NewBotAPI(cfg.TelegramAPIToken) if err != nil { return nil, fmt.Errorf("failed to create bot: %w", err) } diff --git a/apps/poweron/scrap/model.go b/apps/poweron/scrap/model.go index 175dbba..e591ef4 100644 --- a/apps/poweron/scrap/model.go +++ b/apps/poweron/scrap/model.go @@ -12,3 +12,8 @@ type PowerOnMember struct { type PowerOnResponse struct { Member []PowerOnMember `json:"hydra:member"` } + +type Schedule struct { + Today string `json:"today"` + Tomorrow string `json:"tomorrow"` +} diff --git a/apps/poweron/scrap/poweron.go b/apps/poweron/scrap/poweron.go index 142aa1f..102649f 100644 --- a/apps/poweron/scrap/poweron.go +++ b/apps/poweron/scrap/poweron.go @@ -10,6 +10,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ssm" + "github.com/rvolykh/telegram-bot/apps/poweron/config" ) type Poweron struct { @@ -18,41 +19,51 @@ type Poweron struct { cacheTTL time.Duration } -func NewPoweron(ssmClient *ssm.Client, cacheKey string, cacheTTL time.Duration) *Poweron { +func NewPoweron(cfg *config.Config) *Poweron { return &Poweron{ - ssmClient: ssmClient, - cacheKey: cacheKey, - cacheTTL: cacheTTL, + ssmClient: ssm.NewFromConfig(cfg.AWSConfig), + cacheKey: cfg.SSMParamCache, + cacheTTL: cfg.PowerScheduleCacheTTL, } } -func (p *Poweron) GetPowerSchedule(ctx context.Context) (string, error) { - powerSchedule, err := p.ssmClient.GetParameter(ctx, &ssm.GetParameterInput{ +func (p *Poweron) GetPowerSchedule(ctx context.Context) (Schedule, error) { + cacheEntry, err := p.ssmClient.GetParameter(ctx, &ssm.GetParameterInput{ Name: aws.String(p.cacheKey), WithDecryption: aws.Bool(true), }) if err != nil { log.Printf("Failed to get power schedule from cache: %v", err) } else { - isCacheValid := powerSchedule.Parameter.Value != nil && - *powerSchedule.Parameter.Value != "none" && - powerSchedule.Parameter.LastModifiedDate.After(time.Now().Add(-p.cacheTTL)) + isCacheValid := cacheEntry.Parameter.Value != nil && + *cacheEntry.Parameter.Value != "none" && + cacheEntry.Parameter.LastModifiedDate.After(time.Now().Add(-p.cacheTTL)) if isCacheValid { log.Printf("Power schedule is still valid, returning cached value") - return *powerSchedule.Parameter.Value, nil + + var powerSchedule Schedule + if err := json.Unmarshal([]byte(*cacheEntry.Parameter.Value), &powerSchedule); err != nil { + return Schedule{}, fmt.Errorf("failed to unmarshal power schedule: %w", err) + } + return powerSchedule, nil } log.Printf("Power schedule is outdated, refreshing cache") } - powerScheduleText, err := getPowerSchedule() + powerSchedule, err := getPowerSchedule() if err != nil { - return "", fmt.Errorf("failed to get power schedule: %w", err) + return Schedule{}, fmt.Errorf("failed to get power schedule: %w", err) + } + + powerScheduleJSON, err := json.Marshal(powerSchedule) + if err != nil { + return Schedule{}, fmt.Errorf("failed to marshal power schedule: %w", err) } _, err = p.ssmClient.PutParameter(ctx, &ssm.PutParameterInput{ Name: aws.String(p.cacheKey), - Value: aws.String(powerScheduleText), + Value: aws.String(string(powerScheduleJSON)), Type: "SecureString", Overwrite: aws.Bool(true), }) @@ -62,33 +73,46 @@ func (p *Poweron) GetPowerSchedule(ctx context.Context) (string, error) { log.Printf("Updated power schedule in cache") } - return powerScheduleText, nil + return powerSchedule, nil } -func getPowerSchedule() (string, error) { +func getPowerSchedule() (Schedule, error) { + var schedule Schedule + resp, err := http.Get("https://api.loe.lviv.ua/api/menus?page=1&type=photo-grafic") if err != nil { - return "", fmt.Errorf("failed to get power on: %w", err) + return schedule, fmt.Errorf("failed to get power on: %w", err) } defer resp.Body.Close() var powerOnResponse PowerOnResponse err = json.NewDecoder(resp.Body).Decode(&powerOnResponse) if err != nil { - return "", fmt.Errorf("failed to decode power on response: %w", err) + return schedule, fmt.Errorf("failed to decode power on response: %w", err) } if len(powerOnResponse.Member) == 0 { - return "", fmt.Errorf("no power on found") + return schedule, fmt.Errorf("no power on found") } if len(powerOnResponse.Member[0].MenuItems) == 0 { - return "", fmt.Errorf("no menu items found") + return schedule, fmt.Errorf("no menu items found") } - text := cleanHTML(powerOnResponse.Member[0].MenuItems[0].RawMobileHTML) - if text == "" { - text = "Немає запланованих відключень електроенергії" + for _, menuItem := range powerOnResponse.Member[0].MenuItems { + switch menuItem.Name { + case "Today": + schedule.Today = cleanHTML(menuItem.RawMobileHTML) + case "Tomorrow": + schedule.Tomorrow = cleanHTML(menuItem.RawMobileHTML) + } + } + + if schedule.Today == "" { + schedule.Today = "Немає запланованих відключень електроенергії" + } + if schedule.Tomorrow == "" { + schedule.Tomorrow = "Немає запланованих відключень електроенергії" } - return text, nil + return schedule, nil } diff --git a/apps/poweron/subscriptions/dynamodb/dynamodb.go b/apps/poweron/subscriptions/dynamodb/dynamodb.go new file mode 100644 index 0000000..eec7089 --- /dev/null +++ b/apps/poweron/subscriptions/dynamodb/dynamodb.go @@ -0,0 +1,136 @@ +package dynamodb + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/rvolykh/telegram-bot/apps/poweron/config" + "github.com/rvolykh/telegram-bot/apps/poweron/subscriptions/models" +) + +type DynamoDB struct { + tableName *string + client *dynamodb.Client +} + +func NewDynamoDB(cfg *config.Config) *DynamoDB { + return &DynamoDB{ + tableName: aws.String(cfg.DynamoDBSubscriptionsTable), + client: dynamodb.NewFromConfig(cfg.AWSConfig), + } +} + +func (d *DynamoDB) InsertSubscription(ctx context.Context, chatID int64, group string) error { + input := &dynamodb.PutItemInput{ + TableName: d.tableName, + Item: map[string]types.AttributeValue{ + "ChatId": &types.AttributeValueMemberN{ + Value: fmt.Sprintf("%d", chatID), + }, + "Groups": &types.AttributeValueMemberL{ + Value: []types.AttributeValue{ + &types.AttributeValueMemberS{Value: group}, + }, + }, + }, + ReturnValues: types.ReturnValueNone, + } + + _, err := d.client.PutItem(ctx, input) + if err != nil { + return fmt.Errorf("failed to insert subscription: %w", err) + } + return nil +} + +func (d *DynamoDB) GetSubscription(ctx context.Context, chatID int64) ([]string, error) { + input := &dynamodb.GetItemInput{ + TableName: d.tableName, + Key: map[string]types.AttributeValue{ + "ChatId": &types.AttributeValueMemberN{Value: fmt.Sprintf("%d", chatID)}, + }, + } + result, err := d.client.GetItem(ctx, input) + if err != nil { + return nil, fmt.Errorf("failed to get subscription: %w", err) + } + if result.Item == nil { + return []string{}, nil + } + + var subscription models.Subscription + err = attributevalue.UnmarshalMap(result.Item, &subscription) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal subscription: %w", err) + } + + return subscription.Groups, nil +} + +func (d *DynamoDB) UpdateSubscription(ctx context.Context, chatID int64, group string) error { + input := &dynamodb.UpdateItemInput{ + TableName: d.tableName, + Key: map[string]types.AttributeValue{ + "ChatId": &types.AttributeValueMemberN{ + Value: fmt.Sprintf("%d", chatID), + }, + }, + UpdateExpression: aws.String("SET #gs = list_append(if_not_exists(#gs, :empty_list), :new_group)"), + ExpressionAttributeNames: map[string]string{ + "#gs": "Groups", + }, + ExpressionAttributeValues: map[string]types.AttributeValue{ + ":new_group": &types.AttributeValueMemberL{ + Value: []types.AttributeValue{ + &types.AttributeValueMemberS{Value: group}, + }, + }, + ":empty_list": &types.AttributeValueMemberL{}, + }, + ReturnValues: types.ReturnValueNone, + } + + _, err := d.client.UpdateItem(ctx, input) + if err != nil { + return fmt.Errorf("failed to update subscription: %w", err) + } + return nil +} + +func (d *DynamoDB) DeleteSubscription(ctx context.Context, chatID int64) error { + input := &dynamodb.DeleteItemInput{ + TableName: d.tableName, + Key: map[string]types.AttributeValue{ + "ChatId": &types.AttributeValueMemberN{Value: fmt.Sprintf("%d", chatID)}, + }, + ReturnValues: types.ReturnValueNone, + } + + _, err := d.client.DeleteItem(ctx, input) + if err != nil { + return fmt.Errorf("failed to delete subscription: %w", err) + } + return nil +} + +func (d *DynamoDB) ListSubscriptions(ctx context.Context) ([]models.Subscription, error) { + input := &dynamodb.ScanInput{ + TableName: d.tableName, + } + result, err := d.client.Scan(ctx, input) + if err != nil { + return nil, fmt.Errorf("failed to list subscriptions: %w", err) + } + + var subscriptions []models.Subscription + err = attributevalue.UnmarshalListOfMaps(result.Items, &subscriptions) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal subscriptions: %w", err) + } + + return subscriptions, nil +} diff --git a/apps/poweron/subscriptions/models/subscription.go b/apps/poweron/subscriptions/models/subscription.go new file mode 100644 index 0000000..b28b835 --- /dev/null +++ b/apps/poweron/subscriptions/models/subscription.go @@ -0,0 +1,6 @@ +package models + +type Subscription struct { + ChatID int64 `json:"ChatId"` + Groups []string `json:"Groups"` +} diff --git a/apps/poweron/subscriptions/repository.go b/apps/poweron/subscriptions/repository.go new file mode 100644 index 0000000..18cb267 --- /dev/null +++ b/apps/poweron/subscriptions/repository.go @@ -0,0 +1,21 @@ +package subscriptions + +import ( + "context" + + "github.com/rvolykh/telegram-bot/apps/poweron/config" + "github.com/rvolykh/telegram-bot/apps/poweron/subscriptions/dynamodb" + "github.com/rvolykh/telegram-bot/apps/poweron/subscriptions/models" +) + +type SubscriptionsDB interface { + InsertSubscription(ctx context.Context, chatID int64, group string) error + GetSubscription(ctx context.Context, chatID int64) ([]string, error) + UpdateSubscription(ctx context.Context, chatID int64, group string) error + DeleteSubscription(ctx context.Context, chatID int64) error + ListSubscriptions(ctx context.Context) ([]models.Subscription, error) +} + +func NewSubscriptionsDB(cfg *config.Config) (SubscriptionsDB, error) { + return dynamodb.NewDynamoDB(cfg), nil +} diff --git a/bootstrap/data.tf b/bootstrap/data.tf index 26e9de9..5de8369 100644 --- a/bootstrap/data.tf +++ b/bootstrap/data.tf @@ -167,6 +167,34 @@ data "aws_iam_policy_document" "github_policy_3" { values = ["bootstrap"] } } + + statement { + sid = "SNS" + actions = [ + "sns:CreateTopic", + "sns:DeleteTopic", + "sns:GetTopicAttributes", + "sns:ListTopics", + "sns:ListTagsForResource", + "sns:SetTopicAttributes", + "sns:TagResource", + "sns:UntagResource", + "sns:Subscribe", + "sns:Unsubscribe", + "sns:ListSubscriptions", + "sns:ListSubscriptionsByTopic", + "sns:GetSubscriptionAttributes", + "sns:SetSubscriptionAttributes", + ] + resources = [ + "*", + ] + condition { + test = "StringNotEquals" + variable = "aws:ResourceTag/ManagedBy" + values = ["bootstrap"] + } + } } data "aws_iam_policy_document" "github_policy_4" { @@ -211,6 +239,28 @@ data "aws_iam_policy_document" "github_policy_4" { } } + statement { + sid = "Scheduler" + actions = [ + "scheduler:CreateSchedule", + "scheduler:UpdateSchedule", + "scheduler:DeleteSchedule", + "scheduler:GetSchedule", + "scheduler:ListSchedules", + "scheduler:TagResource", + "scheduler:UntagResource", + "scheduler:ListTagsForResource", + ] + resources = [ + "*", + ] + condition { + test = "StringNotEquals" + variable = "aws:ResourceTag/ManagedBy" + values = ["bootstrap"] + } + } + statement { sid = "CloudWatchGlobal" actions = [ @@ -227,22 +277,30 @@ data "aws_iam_policy_document" "github_policy_4" { data "aws_iam_policy_document" "github_policy_5" { statement { - sid = "SNS" + sid = "DynamoDBGlobal" actions = [ - "sns:CreateTopic", - "sns:DeleteTopic", - "sns:GetTopicAttributes", - "sns:ListTopics", - "sns:ListTagsForResource", - "sns:SetTopicAttributes", - "sns:TagResource", - "sns:UntagResource", - "sns:Subscribe", - "sns:Unsubscribe", - "sns:ListSubscriptions", - "sns:ListSubscriptionsByTopic", - "sns:GetSubscriptionAttributes", - "sns:SetSubscriptionAttributes", + "dynamodb:ListTables", + "dynamodb:DescribeLimits", + "dynamodb:DescribeTimeToLive", + "dynamodb:ListTagsOfResource", + ] + resources = [ + "*", + ] + } + + statement { + sid = "DynamoDBTables" + actions = [ + "dynamodb:CreateTable", + "dynamodb:DeleteTable", + "dynamodb:DescribeTable", + "dynamodb:DescribeTableReplicaAutoScaling", + "dynamodb:DescribeContinuousBackups", + "dynamodb:UpdateTable", + "dynamodb:UpdateTimeToLive", + "dynamodb:TagResource", + "dynamodb:UntagResource", ] resources = [ "*", diff --git a/infra/cmd_poweron.tf b/infra/cmd_poweron.tf index f8845d2..5d98536 100644 --- a/infra/cmd_poweron.tf +++ b/infra/cmd_poweron.tf @@ -6,6 +6,14 @@ module "telegram_bot_queue_cmd_poweron" { dead_letter_queue_arn = module.telegram_bot_queue_alerting.sqs_queue_arn } +module "telegram_bot_db_poweron_subscriptions" { + source = "./modules/db" + + name = "${var.prefix}telegram-bot-poweron-subscriptions" + hash_key_name = "ChatId" + hash_key_type = "N" +} + module "telegram_bot_cmd_poweron" { source = "./modules/handler" @@ -13,12 +21,14 @@ module "telegram_bot_cmd_poweron" { reserved_concurrent_executions = -1 source_path = "${path.root}/../apps/poweron" + timeout = 30 sqs_batch_size = 10 sqs_queue_arn = module.telegram_bot_queue_cmd_poweron.sqs_queue_arn environment_variables = { - SSM_PARAM_TELEGRAM_APITOKEN = module.telegram_bot_api_token.name - SSM_PARAM_POWERON_CACHE = module.telegram_bot_cache_poweron.name + SSM_PARAM_TELEGRAM_APITOKEN = module.telegram_bot_api_token.name + SSM_PARAM_CACHE = module.telegram_bot_cache_poweron.name + DYNAMODB_TABLE_SUBSCRIPTIONS = module.telegram_bot_db_poweron_subscriptions.name } role_policies = [ @@ -26,7 +36,16 @@ module "telegram_bot_cmd_poweron" { [ module.telegram_bot_api_token.policy_document_read_only, module.telegram_bot_cache_poweron.policy_document_read_write, + module.telegram_bot_db_poweron_subscriptions.policy_document_read_write, ], // ] } + +module "telegram_bot_cron_poweron" { + source = "./modules/cron" + + name = "${var.prefix}telegram-bot-poweron-subscriptions" + schedule_expression = "rate(20 minutes)" + lambda_function_arn = module.telegram_bot_cmd_poweron.lambda_function_arn +} diff --git a/infra/modules/cron/data.tf b/infra/modules/cron/data.tf new file mode 100644 index 0000000..f223ab3 --- /dev/null +++ b/infra/modules/cron/data.tf @@ -0,0 +1,24 @@ +data "aws_iam_policy_document" "assume_role_policy" { + statement { + effect = "Allow" + actions = [ + "sts:AssumeRole", + ] + principals { + type = "Service" + identifiers = ["scheduler.amazonaws.com"] + } + } +} + +data "aws_iam_policy_document" "role_permissions" { + statement { + effect = "Allow" + actions = [ + "lambda:InvokeFunction", + ] + resources = [ + var.lambda_function_arn, + ] + } +} diff --git a/infra/modules/cron/main.tf b/infra/modules/cron/main.tf new file mode 100644 index 0000000..9e64ba6 --- /dev/null +++ b/infra/modules/cron/main.tf @@ -0,0 +1,20 @@ +resource "aws_scheduler_schedule" "this" { + name = var.name + group_name = "default" + + flexible_time_window { + mode = "FLEXIBLE" + maximum_window_in_minutes = 10 + } + + schedule_expression = var.schedule_expression + + target { + arn = var.lambda_function_arn + role_arn = aws_iam_role.scheduler.arn + + retry_policy { + maximum_retry_attempts = 3 + } + } +} diff --git a/infra/modules/cron/roles.tf b/infra/modules/cron/roles.tf new file mode 100644 index 0000000..83d1a59 --- /dev/null +++ b/infra/modules/cron/roles.tf @@ -0,0 +1,14 @@ +resource "aws_iam_role" "scheduler" { + name = "${var.name}-scheduler-role" + + assume_role_policy = data.aws_iam_policy_document.assume_role_policy.json + + tags = var.tags +} + +resource "aws_iam_role_policy" "lambda_invoke" { + name = "${var.name}-lambda-invoke" + role = aws_iam_role.scheduler.id + + policy = data.aws_iam_policy_document.role_permissions.json +} diff --git a/infra/modules/cron/variables.tf b/infra/modules/cron/variables.tf new file mode 100644 index 0000000..c32a152 --- /dev/null +++ b/infra/modules/cron/variables.tf @@ -0,0 +1,26 @@ +variable "name" { + description = "Name to use for resources" + type = string +} + +variable "schedule_expression" { + description = "Schedule" + type = string +} + +variable "lambda_function_arn" { + description = "ARN of the Lambda function to invoke" + type = string +} + +variable "maximum_retry_attempts" { + description = "Maximum number of retry attempts to make before the request fails" + type = number + default = 3 +} + +variable "tags" { + description = "Tags to apply to resources" + type = map(string) + default = {} +} diff --git a/infra/modules/db/data.tf b/infra/modules/db/data.tf new file mode 100644 index 0000000..1f880b5 --- /dev/null +++ b/infra/modules/db/data.tf @@ -0,0 +1,36 @@ +data "aws_iam_policy_document" "read_only" { + version = "2012-10-17" + statement { + effect = "Allow" + actions = [ + "dynamodb:GetItem", + "dynamodb:Query", + "dynamodb:Scan", + ] + resources = [ + aws_dynamodb_table.this.arn, + ] + } +} + +data "aws_iam_policy_document" "write_only" { + version = "2012-10-17" + statement { + effect = "Allow" + actions = [ + "dynamodb:PutItem", + "dynamodb:UpdateItem", + "dynamodb:DeleteItem", + ] + resources = [ + aws_dynamodb_table.this.arn, + ] + } +} + +data "aws_iam_policy_document" "read_write" { + source_policy_documents = [ + data.aws_iam_policy_document.read_only.json, + data.aws_iam_policy_document.write_only.json, + ] +} diff --git a/infra/modules/db/main.tf b/infra/modules/db/main.tf new file mode 100644 index 0000000..533112e --- /dev/null +++ b/infra/modules/db/main.tf @@ -0,0 +1,15 @@ +resource "aws_dynamodb_table" "this" { + name = var.name + + billing_mode = "PROVISIONED" + read_capacity = 5 + write_capacity = 5 + hash_key = var.hash_key_name + + attribute { + name = var.hash_key_name + type = var.hash_key_type + } + + tags = var.tags +} diff --git a/infra/modules/db/outputs.tf b/infra/modules/db/outputs.tf new file mode 100644 index 0000000..9fda59c --- /dev/null +++ b/infra/modules/db/outputs.tf @@ -0,0 +1,24 @@ +output "name" { + description = "Name of the DynamoDB table" + value = aws_dynamodb_table.this.name +} + +output "arn" { + description = "ARN of the DynamoDB table" + value = aws_dynamodb_table.this.arn +} + +output "policy_document_read_only" { + description = "IAM policy document for read-only access" + value = data.aws_iam_policy_document.read_only.json +} + +output "policy_document_write_only" { + description = "IAM policy document for write-only access" + value = data.aws_iam_policy_document.write_only.json +} + +output "policy_document_read_write" { + description = "IAM policy document for read-write access" + value = data.aws_iam_policy_document.read_write.json +} diff --git a/infra/modules/db/variables.tf b/infra/modules/db/variables.tf new file mode 100644 index 0000000..9db13d4 --- /dev/null +++ b/infra/modules/db/variables.tf @@ -0,0 +1,25 @@ +variable "name" { + description = "Name of the DynamoDB table" + type = string +} + +variable "hash_key_name" { + description = "Name of the hash key" + type = string +} + +variable "hash_key_type" { + description = "Type of the hash key" + type = string + + validation { + condition = contains(["S", "N", "B"], var.hash_key_type) + error_message = "hash_key_type must be one of: S, N, B" + } +} + +variable "tags" { + description = "Tags to apply to apply to provisioned resources" + type = map(string) + default = {} +} diff --git a/infra/tests/01_unit.tftest.hcl b/infra/tests/01_unit.tftest.hcl index 0982577..b9e3e44 100644 --- a/infra/tests/01_unit.tftest.hcl +++ b/infra/tests/01_unit.tftest.hcl @@ -49,6 +49,12 @@ run "verify_plan" { condition = module.telegram_bot_cmd_poweron.lambda_function_name == "${run.prepare.prefix}telegram-bot-cmd-poweron" error_message = "module telegram_bot_cmd_poweron should create the Lambda function ${run.prepare.prefix}telegram-bot-cmd-poweron" } + + # Databases + assert { + condition = module.telegram_bot_db_poweron_subscriptions.name == "${run.prepare.prefix}telegram-bot-poweron-subscriptions" + error_message = "module telegram_bot_db_poweron_subscriptions should create the DynamoDB table ${run.prepare.prefix}telegram-bot-poweron-subscriptions" + } } run "verify_module_api_positive" { @@ -210,3 +216,52 @@ run "verify_module_alerting_negative" { expect_failures = [var.role_policies] } + +run "verify_module_db_positive" { + command = plan + + module { + source = "./modules/db" + } + + variables { + name = "${run.prepare.prefix}db-positive" + hash_key_name = "id" + hash_key_type = "S" + } + + assert { + condition = output.name == "${run.prepare.prefix}db-positive" + error_message = "module db_positive should create the DynamoDB table ${run.prepare.prefix}db-positive" + } +} + +run "verify_module_db_negative" { + command = plan + + module { + source = "./modules/db" + } + + variables { + name = "${run.prepare.prefix}db-negative" + hash_key_name = "id" + hash_key_type = "X" + } + + expect_failures = [var.hash_key_type] +} + +run "verify_module_cron_positive" { + command = plan + + module { + source = "./modules/cron" + } + + variables { + name = "${run.prepare.prefix}cron-positive" + schedule_expression = "rate(30 days)" + lambda_function_arn = "arn:aws:lambda:us-east-1:012345678901:function:target-lambda-function" + } +}