diff --git a/apps/poweron/config/config_test.go b/apps/poweron/config/config_test.go new file mode 100644 index 0000000..b6a23e0 --- /dev/null +++ b/apps/poweron/config/config_test.go @@ -0,0 +1,95 @@ +package config + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadConfig_WithAPITokenEnvVar(t *testing.T) { + // Set test environment variables + testRegion := "us-east-1" + testAPIToken := "test-api-token-123" + testSSMParamCache := "/test/cache/param" + testDynamoDBTable := "test-subscriptions-table" + + os.Setenv("AWS_DEFAULT_REGION", testRegion) + os.Setenv(EnvAPIToken, testAPIToken) + os.Setenv(EnvSSMParamCache, testSSMParamCache) + os.Setenv(EnvDynamoDBSubscriptionsTable, testDynamoDBTable) + os.Unsetenv(EnvSSMParamAPIToken) // Ensure SSM param is not set + + ctx := context.Background() + cfg, err := LoadConfig(ctx) + require.NoError(t, err) + require.NotNil(t, cfg) + + // Verify API token + assert.Equal(t, testAPIToken, cfg.TelegramAPIToken) + + // Verify SSM param cache + assert.Equal(t, testSSMParamCache, cfg.SSMParamCache) + + // Verify DynamoDB table + assert.Equal(t, testDynamoDBTable, cfg.DynamoDBSubscriptionsTable) + + // Verify default cache TTL + assert.Equal(t, PowerScheduleCacheTTL, cfg.PowerScheduleCacheTTL) + + // Verify AWS config is set + assert.Equal(t, testRegion, cfg.AWSConfig.Region) +} + +func TestLoadConfig_WithEmptyEnvVars(t *testing.T) { + // Unset all environment variables + os.Unsetenv(EnvAPIToken) + os.Unsetenv(EnvSSMParamAPIToken) + os.Unsetenv(EnvSSMParamCache) + os.Unsetenv(EnvDynamoDBSubscriptionsTable) + + ctx := context.Background() + cfg, err := LoadConfig(ctx) + + // When API token is not set and SSM param is not set, it should fail + // because it will try to get from SSM but the parameter name is empty + assert.Error(t, err) + assert.Nil(t, cfg) +} + +func TestConfigConstants(t *testing.T) { + tests := []struct { + name string + constant string + want string + }{ + { + name: "EnvAPIToken", + constant: EnvAPIToken, + want: "TELEGRAM_APITOKEN", + }, + { + name: "EnvSSMParamAPIToken", + constant: EnvSSMParamAPIToken, + want: "SSM_PARAM_TELEGRAM_APITOKEN", + }, + { + name: "EnvSSMParamCache", + constant: EnvSSMParamCache, + want: "SSM_PARAM_CACHE", + }, + { + name: "EnvDynamoDBSubscriptionsTable", + constant: EnvDynamoDBSubscriptionsTable, + want: "DYNAMODB_TABLE_SUBSCRIPTIONS", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.constant) + }) + } +} diff --git a/apps/poweron/go.mod b/apps/poweron/go.mod index 535bfcc..0b8e3e0 100644 --- a/apps/poweron/go.mod +++ b/apps/poweron/go.mod @@ -10,6 +10,7 @@ require ( 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 + github.com/stretchr/testify v1.7.2 ) require ( @@ -27,4 +28,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect github.com/aws/smithy-go v1.24.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/apps/poweron/go.sum b/apps/poweron/go.sum index 9e04490..b6633c8 100644 --- a/apps/poweron/go.sum +++ b/apps/poweron/go.sum @@ -38,13 +38,17 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/apps/poweron/operations/schedule.go b/apps/poweron/operations/schedule.go index da55a08..5c02013 100644 --- a/apps/poweron/operations/schedule.go +++ b/apps/poweron/operations/schedule.go @@ -12,6 +12,12 @@ import ( "github.com/rvolykh/telegram-bot/apps/poweron/subscriptions" ) +const ( + tomorrow = "Завтра" + today = "Сьогодні" + updatedAt = "Інформація станом на" +) + func DeliverScheduleUpdates(ctx context.Context, cfg *config.Config) error { poweron := scrap.NewPoweron(cfg) @@ -43,11 +49,18 @@ func DeliverScheduleUpdates(ctx context.Context, cfg *config.Config) error { 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] { + // one to one match + if prev.Text == message { log.Printf("No updates, skipping for chat %d", subscriber.ChatID) continue } + // updatedAt change only + if isMessagesEqual(prev.Text, message) { + if err := t.EditMessage(ctx, subscriber.ChatID, prev.ID, message); err != nil { + log.Printf("Failed to edit message %d in chat %d: %s", prev.ID, subscriber.ChatID, err) + } + continue + } messageID, err := t.SendMessage(ctx, subscriber.ChatID, message) if err != nil { @@ -68,13 +81,15 @@ func DeliverScheduleUpdates(ctx context.Context, cfg *config.Config) error { func prepareMessage(powerSchedule scrap.Schedule, groups []string) string { var message strings.Builder - message.WriteString("Сьогодні:\n") + message.WriteString(today + ":\n") filterPowerScheduleGroups(&message, powerSchedule.Today, groups) - message.WriteString("\nЗавтра:\n") + message.WriteString("\n" + tomorrow + ":\n") filterPowerScheduleGroups(&message, powerSchedule.Tomorrow, groups) - return message.String() + // remove last new line as telegram will strip it anyway and it can create issue in cmp + result := message.String() + return result[:len(result)-1] } func filterPowerScheduleGroups(b *strings.Builder, schedule string, groups []string) { @@ -95,3 +110,28 @@ func filterPowerScheduleGroups(b *strings.Builder, schedule string, groups []str b.WriteString(line + "\n") } } + +func isMessagesEqual(src, dst string) bool { + var ( + srcLines = strings.Split(src, "\n") + dstLines = strings.Split(dst, "\n") + ) + + if len(srcLines) != len(dstLines) { + return false + } + + for i := range srcLines { + if srcLines[i] == dstLines[i] { + continue + } + + if strings.HasPrefix(srcLines[i], updatedAt) && strings.HasPrefix(dstLines[i], updatedAt) { + continue + } + + return false + } + + return true +} diff --git a/apps/poweron/operations/schedule_test.go b/apps/poweron/operations/schedule_test.go new file mode 100644 index 0000000..bb4962e --- /dev/null +++ b/apps/poweron/operations/schedule_test.go @@ -0,0 +1,54 @@ +package operations + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSchedule_isMessagesEqual(t *testing.T) { + table := []struct { + A string + B string + want bool + }{ + { + A: "", + B: "", + want: true, + }, + { + A: `Є відключення`, + B: `Є відключення`, + want: true, + }, + { + A: `Інформація станом на 10:00`, + B: `Інформація станом на 11:00`, + want: true, + }, + { + A: "Є відключення\nІнформація станом на 10:00", + B: "Є відключення\nІнформація станом на 11:00", + want: true, + }, + { + A: `Є відключення`, + B: `Нема відключення`, + want: false, + }, + { + A: `Є відключення`, + B: ``, + want: false, + }, + } + + for i, tt := range table { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + have := isMessagesEqual(tt.A, tt.B) + assert.Equal(t, tt.want, have) + }) + } +} diff --git a/apps/poweron/reply/edit_message.go b/apps/poweron/reply/edit_message.go new file mode 100644 index 0000000..79bb24c --- /dev/null +++ b/apps/poweron/reply/edit_message.go @@ -0,0 +1,18 @@ +package reply + +import ( + "context" + "fmt" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +func (t *Telegram) EditMessage(ctx context.Context, chatID int64, messageID int, text string) error { + msg := tgbotapi.NewEditMessageText(chatID, messageID, text) + + _, err := t.bot.Send(msg) + if err != nil { + return fmt.Errorf("failed to edit message %d: %w", messageID, err) + } + return nil +} diff --git a/apps/poweron/reply/get_pinned_message.go b/apps/poweron/reply/get_pinned_message.go index d8a04ca..cf8d90e 100644 --- a/apps/poweron/reply/get_pinned_message.go +++ b/apps/poweron/reply/get_pinned_message.go @@ -7,7 +7,7 @@ import ( tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) -func (t *Telegram) GetPinnedMessage(ctx context.Context, chatID int64) (string, error) { +func (t *Telegram) GetPinnedMessage(ctx context.Context, chatID int64) (Message, error) { input := tgbotapi.ChatInfoConfig{ ChatConfig: tgbotapi.ChatConfig{ ChatID: chatID, @@ -16,12 +16,15 @@ func (t *Telegram) GetPinnedMessage(ctx context.Context, chatID int64) (string, output, err := t.bot.GetChat(input) if err != nil { - return "", fmt.Errorf("failed to get pinned message: %w", err) + return Message{}, fmt.Errorf("failed to get pinned message: %w", err) } if output.PinnedMessage == nil { - return "", nil + return Message{}, nil } - return output.PinnedMessage.Text, nil + return Message{ + ID: output.PinnedMessage.MessageID, + Text: output.PinnedMessage.Text, + }, nil } diff --git a/apps/poweron/reply/models.go b/apps/poweron/reply/models.go new file mode 100644 index 0000000..18a230f --- /dev/null +++ b/apps/poweron/reply/models.go @@ -0,0 +1,6 @@ +package reply + +type Message struct { + ID int + Text string +}