diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index bffa225..b096a0e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,6 +1,7 @@ +--- name: Build and Push Docker Images -on: +on: # yamllint disable-line rule:truthy push: branches: [main] tags: ['v*.*.*'] @@ -22,6 +23,17 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 'stable' + + - name: Install dependencies + run: make deps + + - name: Run tests + run: make ci + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 diff --git a/.gitignore b/.gitignore index 72f31d1..d03429e 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ docker-compose.override.yml .env /data/ +-e +# Build artifacts +build/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..cd09ba7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,56 @@ +# .pre-commit-config.yaml + +--- +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + always_run: true + - id: end-of-file-fixer + always_run: true + - id: check-yaml + always_run: true + - id: check-added-large-files + always_run: true + - id: check-merge-conflict + always_run: true + - id: check-symlinks + always_run: true + - id: detect-private-key + always_run: true + - id: no-commit-to-branch + args: ['--branch', 'main'] + always_run: true + + - repo: https://github.com/golangci/golangci-lint + rev: v1.55.2 + hooks: + - id: golangci-lint + args: [--fix, --timeout=5m] + always_run: true + + - repo: local + hooks: + - id: go-fmt + name: go fmt + entry: gofmt -w . + language: system + types: [go] + always_run: true + + - id: go-vet + name: go vet + entry: go vet ./... + language: system + types: [go] + pass_filenames: false + always_run: true + + - id: go-tests + name: go test + entry: go test 5mdt/bd_bot/... + language: system + types: [go] + pass_filenames: false + always_run: true diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..17aa84e --- /dev/null +++ b/.yamllint @@ -0,0 +1,11 @@ +--- + +extends: default + +rules: + braces: + level: warning + max-spaces-inside: 1 + comments-indentation: disable + comments: disable + line-length: disable diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e529703 --- /dev/null +++ b/Makefile @@ -0,0 +1,84 @@ +# Makefile for bd_bot project + +# Go parameters +GOCMD=go +GOBUILD=$(GOCMD) build +GOTEST=$(GOCMD) test +GOVET=$(GOCMD) vet +GOFMT=gofmt + +# Directories +CMD_DIR=./cmd/app +INTERNAL_DIR=./internal +BUILD_DIR=./build + +# Binary output +BINARY_NAME=bd_bot +BINARY_LINUX=$(BINARY_NAME)_linux +BINARY_DARWIN=$(BINARY_NAME)_darwin +BINARY_WIN=$(BINARY_NAME)_windows.exe + +# Packages +PKG=5mdt/bd_bot/... + +# Ensure build directory exists +$(shell mkdir -p $(BUILD_DIR)) + +.PHONY: all test vet fmt clean build build-linux build-darwin build-windows build-all deps pre-commit docker-build docker-run ci + +# Default target +all: fmt vet test build + +# Run tests +test: + $(GOTEST) $(PKG) + +# Run code quality checks +vet: + $(GOVET) $(PKG) + +# Format code +fmt: + $(GOFMT) -w . + +# Clean build artifacts +clean: + rm -rf $(BUILD_DIR) + +# Install dependencies +deps: + $(GOCMD) mod tidy + $(GOCMD) mod download + +# Run pre-commit hooks +pre-commit: + pre-commit run --all-files + +# Build for current platform +build: + $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME) $(CMD_DIR) + +# Build for Linux +build-linux: + GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_LINUX) $(CMD_DIR) + +# Build for macOS +build-darwin: + GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_DARWIN) $(CMD_DIR) + +# Build for Windows +build-windows: + GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_WIN) $(CMD_DIR) + +# Build for all platforms +build-all: build-linux build-darwin build-windows + +# Docker-related tasks +docker-build: + docker build -t bd_bot . + +docker-run: + docker run -it --rm bd_bot + +# CI tasks +ci: deps fmt vet test build diff --git a/cmd/app/main.go b/cmd/app/main.go index a9a6562..0a39e14 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -24,7 +24,6 @@ func main() { tpl := templates.LoadTemplates() - http.HandleFunc("/", handlers.IndexHandler(tpl, telegramBot)) http.HandleFunc("/bot-info", handlers.BotInfoHandler(tpl, telegramBot)) http.HandleFunc("/save-row", handlers.SaveRowHandler(tpl)) diff --git a/cmd/app/main_test.go b/cmd/app/main_test.go index f49b957..46b3414 100644 --- a/cmd/app/main_test.go +++ b/cmd/app/main_test.go @@ -47,11 +47,11 @@ func TestMainHandlers(t *testing.T) { } form := url.Values{ - "idx": {"-1"}, - "name": {"X"}, - "birth_date": {"01-01"}, - "last_notification":{"2025-01-01T12:00:00Z"}, - "chat_id": {"1"}, + "idx": {"-1"}, + "name": {"X"}, + "birth_date": {"01-01"}, + "last_notification": {"2025-01-01T12:00:00Z"}, + "chat_id": {"1"}, } w = doRequest(t, "POST", "/save-row", form, handlers.SaveRowHandler(tpl)) if w.Code != http.StatusOK { diff --git a/docker-compose.yml b/docker-compose.yml index a2d162f..e138e4a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,5 @@ +--- + services: app: build: . diff --git a/internal/bot/birthday_notification_test.go b/internal/bot/birthday_notification_test.go new file mode 100644 index 0000000..013fc93 --- /dev/null +++ b/internal/bot/birthday_notification_test.go @@ -0,0 +1,42 @@ +package bot + +import ( + "testing" + "time" + + "5mdt/bd_bot/internal/models" +) + +func TestBirthdayNotificationUniqueness(t *testing.T) { + bot := &Bot{} // Create a minimal bot instance for testing + + // Create a test birthday entry + testBirthday := models.Birthday{ + Name: "Test User", + BirthDate: time.Now().Format("01-02"), // Today's month and day + ChatID: 12345, + } + + // Simulate first birthday notification + shouldSend := bot.shouldSendBirthdayNotification(testBirthday, "BIRTHDAY_TODAY", 0) + if !shouldSend { + t.Error("First birthday notification should be sent") + } + + // Update last notification time to now + testBirthday.LastNotification = time.Now() + + // Simulate second birthday notification on the same day + shouldSend = bot.shouldSendBirthdayNotification(testBirthday, "BIRTHDAY_TODAY", 0) + if shouldSend { + t.Error("Second birthday notification on the same day should not be sent") + } + + // Simulate birthday notification on a different day + futureTime := time.Now().AddDate(0, 0, 1) + testBirthday.LastNotification = futureTime + shouldSend = bot.shouldSendBirthdayNotification(testBirthday, "BIRTHDAY_TODAY", 0) + if !shouldSend { + t.Error("Birthday notification should be sent on a different day") + } +} diff --git a/internal/bot/bot.go b/internal/bot/bot.go index b78ea83..8bfb781 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -10,24 +10,24 @@ import ( "sync" "time" - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" "5mdt/bd_bot/internal/logger" "5mdt/bd_bot/internal/models" "5mdt/bd_bot/internal/storage" + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) type Bot struct { - api *tgbotapi.BotAPI - status string - username string - firstName string - startTime time.Time - notificationsSent int64 + api *tgbotapi.BotAPI + status string + username string + firstName string + startTime time.Time + notificationsSent int64 notificationStartHour int // Start hour for notifications (0-23, UTC) notificationEndHour int // End hour for notifications (0-23, UTC) - mu sync.RWMutex - ctx context.Context - cancel context.CancelFunc + mu sync.RWMutex + ctx context.Context + cancel context.CancelFunc } func New(token string) (*Bot, error) { @@ -47,8 +47,8 @@ func New(token string) (*Bot, error) { } // Parse notification hours from environment variables - notificationStartHour := 8 // Default: 8 AM UTC - notificationEndHour := 20 // Default: 8 PM UTC + notificationStartHour := 8 // Default: 8 AM UTC + notificationEndHour := 20 // Default: 8 PM UTC if startHourStr := os.Getenv("NOTIFICATION_START_HOUR"); startHourStr != "" { if hour, err := strconv.Atoi(startHourStr); err == nil && hour >= 0 && hour <= 23 { @@ -69,15 +69,15 @@ func New(token string) (*Bot, error) { ctx, cancel := context.WithCancel(context.Background()) bot := &Bot{ - api: api, - status: "starting", - username: me.UserName, - firstName: me.FirstName, - startTime: time.Now(), + api: api, + status: "starting", + username: me.UserName, + firstName: me.FirstName, + startTime: time.Now(), notificationStartHour: notificationStartHour, notificationEndHour: notificationEndHour, - ctx: ctx, - cancel: cancel, + ctx: ctx, + cancel: cancel, } logger.Info("BOT", "Bot initialized successfully") @@ -444,7 +444,6 @@ func (b *Bot) handleMyInfoCommand(message *tgbotapi.Message) { } } - func (b *Bot) handleChatTitleChange(message *tgbotapi.Message) { newTitle := message.NewChatTitle chatID := message.Chat.ID @@ -528,6 +527,24 @@ func (b *Bot) checkBirthdays() { } } +func (b *Bot) shouldSendBirthdayNotification(birthday models.Birthday, notificationType string, daysDiff int) bool { + // Always send birthday today notification + if notificationType == "BIRTHDAY_TODAY" { + // Check if last notification was today + now := time.Now().UTC() + lastNotificationDate := "" + if !birthday.LastNotification.IsZero() { + lastNotificationDate = birthday.LastNotification.Format("2006-01-02") + } + + todayDate := now.Format("2006-01-02") + return lastNotificationDate != todayDate + } + + // For 2 and 4 weeks reminders, previous checks in the function will handle skipping + return true +} + func (b *Bot) processBirthdays() { now := time.Now().UTC() currentHour := now.Hour() @@ -571,7 +588,7 @@ func (b *Bot) processBirthdays() { // Extract MM-DD from birth date var birthdayMMDD string if len(birthday.BirthDate) >= 7 { // At least "0000-MM" or "YYYY-MM" - parts := birthday.BirthDate[5:] // Skip "0000-" or "YYYY-" + parts := birthday.BirthDate[5:] // Skip "0000-" or "YYYY-" if len(parts) >= 5 && parts[2:3] == "-" { // MM-DD birthdayMMDD = parts } @@ -601,7 +618,6 @@ func (b *Bot) processBirthdays() { } var message string - var shouldSend bool var notificationType string // Parse the birthday MM-DD to determine this year's birthday date @@ -622,17 +638,14 @@ func (b *Bot) processBirthdays() { if daysDiff == 0 { // Birthday is today message = fmt.Sprintf("🎉 Happy Birthday, %s! 🎂", birthday.Name) - shouldSend = true notificationType = "BIRTHDAY_TODAY" } else if daysDiff == 14 { // Birthday is in exactly 2 weeks message = fmt.Sprintf("📅 Reminder: %s's birthday is in 2 weeks (%s)! 🎈", birthday.Name, birthdayMMDD) - shouldSend = true notificationType = "REMINDER_2_WEEKS" } else if daysDiff == 28 { // Birthday is in exactly 4 weeks message = fmt.Sprintf("📅 Early reminder: %s's birthday is in 4 weeks (%s)! 🗓️", birthday.Name, birthdayMMDD) - shouldSend = true notificationType = "REMINDER_4_WEEKS" } else if daysDiff < 0 { // Birthday has passed this year - check next year @@ -645,17 +658,20 @@ func (b *Bot) processBirthdays() { if nextYearDaysDiff == 14 { // Birthday is in 2 weeks next year message = fmt.Sprintf("📅 Reminder: %s's birthday is in 2 weeks (%s)! 🎈", birthday.Name, birthdayMMDD) - shouldSend = true notificationType = "REMINDER_2_WEEKS_NEXT_YEAR" } else if nextYearDaysDiff == 28 { // Birthday is in 4 weeks next year message = fmt.Sprintf("📅 Early reminder: %s's birthday is in 4 weeks (%s)! 🗓️", birthday.Name, birthdayMMDD) - shouldSend = true notificationType = "REMINDER_4_WEEKS_NEXT_YEAR" + } else { + continue // No notification matches } + } else { + continue // No notification matches } - if shouldSend { + // Check if this notification should be sent + if b.shouldSendBirthdayNotification(birthday, notificationType, daysDiff) { logger.LogNotification("INFO", "SENDING: Type=%s, Name='%s', ChatID=%d, Message='%s'", notificationType, birthday.Name, birthday.ChatID, message) diff --git a/internal/bot/left_member_test.go b/internal/bot/left_member_test.go index 7c0243f..04a0a74 100644 --- a/internal/bot/left_member_test.go +++ b/internal/bot/left_member_test.go @@ -15,4 +15,4 @@ func TestLeftChatMemberBehavior(t *testing.T) { if expectWelcomeOnMemberLeave { t.Error("Bot should not send welcome messages when members leave chats") } -} \ No newline at end of file +} diff --git a/internal/handlers/bot_info.go b/internal/handlers/bot_info.go index c8c2e06..eaa3ac3 100644 --- a/internal/handlers/bot_info.go +++ b/internal/handlers/bot_info.go @@ -15,15 +15,15 @@ func BotInfoHandler(tpl *template.Template, botProvider BotStatusProvider) http. if botProvider != nil && botProvider.GetStatus() != "not configured" { startHour, endHour := botProvider.GetNotificationHours() botInfo = BotInfo{ - Status: botProvider.GetStatus(), - Username: botProvider.GetUsername(), - FirstName: botProvider.GetFirstName(), - Uptime: formatUptime(botProvider.GetUptime()), - NotificationsSent: botProvider.GetNotificationsSent(), - NotificationHours: formatNotificationHours(startHour, endHour), - NextCheckTime: calculateNextCheckTime(), - CurrentHourInWindow: isCurrentlyInNotificationWindow(startHour, endHour), - Configured: true, + Status: botProvider.GetStatus(), + Username: botProvider.GetUsername(), + FirstName: botProvider.GetFirstName(), + Uptime: formatUptime(botProvider.GetUptime()), + NotificationsSent: botProvider.GetNotificationsSent(), + NotificationHours: formatNotificationHours(startHour, endHour), + NextCheckTime: calculateNextCheckTime(), + CurrentHourInWindow: isCurrentlyInNotificationWindow(startHour, endHour), + Configured: true, } } else { botInfo = BotInfo{ @@ -44,4 +44,4 @@ func BotInfoHandler(tpl *template.Template, botProvider BotStatusProvider) http. return } } -} \ No newline at end of file +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index e51ae1a..23d9121 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -19,15 +19,15 @@ type PageData struct { } type BotInfo struct { - Status string - Username string - FirstName string - Uptime string - NotificationsSent int64 - NotificationHours string - NextCheckTime string - CurrentHourInWindow bool - Configured bool + Status string + Username string + FirstName string + Uptime string + NotificationsSent int64 + NotificationHours string + NextCheckTime string + CurrentHourInWindow bool + Configured bool } type BotStatusProvider interface { @@ -190,15 +190,15 @@ func IndexHandler(tpl *template.Template, botProvider BotStatusProvider) http.Ha if botProvider != nil { startHour, endHour := botProvider.GetNotificationHours() botInfo = BotInfo{ - Status: botProvider.GetStatus(), - Username: botProvider.GetUsername(), - FirstName: botProvider.GetFirstName(), - Uptime: formatUptime(botProvider.GetUptime()), - NotificationsSent: botProvider.GetNotificationsSent(), - NotificationHours: formatNotificationHours(startHour, endHour), - NextCheckTime: calculateNextCheckTime(), - CurrentHourInWindow: isCurrentlyInNotificationWindow(startHour, endHour), - Configured: true, + Status: botProvider.GetStatus(), + Username: botProvider.GetUsername(), + FirstName: botProvider.GetFirstName(), + Uptime: formatUptime(botProvider.GetUptime()), + NotificationsSent: botProvider.GetNotificationsSent(), + NotificationHours: formatNotificationHours(startHour, endHour), + NextCheckTime: calculateNextCheckTime(), + CurrentHourInWindow: isCurrentlyInNotificationWindow(startHour, endHour), + Configured: true, } } else { botInfo = BotInfo{ diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 284e387..97b69af 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -171,4 +171,4 @@ func LogNotification(level string, message string, args ...interface{}) { default: Info(component, formattedMsg) } -} \ No newline at end of file +} diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go index 60a550f..a73e9f0 100644 --- a/internal/logger/logger_test.go +++ b/internal/logger/logger_test.go @@ -89,4 +89,4 @@ func TestLogLevels(t *testing.T) { // Restore original level logLevel = originalLevel } -} \ No newline at end of file +} diff --git a/internal/templates/templates.go b/internal/templates/templates.go index 978ba2d..091b635 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -18,12 +18,12 @@ func LoadTemplates() *template.Template { once.Do(func() { // Load template with functions for birth date handling (updated) tpl = template.New("").Funcs(template.FuncMap{ - "dict": dict, - "formatTime": formatTime, - "isZeroTime": isZeroTime, - "formatBirthDate": formatBirthDate, + "dict": dict, + "formatTime": formatTime, + "isZeroTime": isZeroTime, + "formatBirthDate": formatBirthDate, "formatBirthDateForInput": formatBirthDateForInput, - "isUnknownYear": isUnknownYear, + "isUnknownYear": isUnknownYear, }) tpl = template.Must(tpl.ParseFS(tmplFS, "tmpl/*.gohtml")) }) diff --git a/internal/templates/tmpl/bot-info.gohtml b/internal/templates/tmpl/bot-info.gohtml index 78f71d5..1d44a15 100644 --- a/internal/templates/tmpl/bot-info.gohtml +++ b/internal/templates/tmpl/bot-info.gohtml @@ -80,4 +80,4 @@ {{end}} -{{end}} \ No newline at end of file +{{end}} diff --git a/internal/templates/tmpl/scripts.gohtml b/internal/templates/tmpl/scripts.gohtml index b9058df..8f1778c 100644 --- a/internal/templates/tmpl/scripts.gohtml +++ b/internal/templates/tmpl/scripts.gohtml @@ -255,4 +255,4 @@ function checkDateTimeLocalSupport() { } } -{{end}} \ No newline at end of file +{{end}} diff --git a/internal/templates/tmpl/styles.gohtml b/internal/templates/tmpl/styles.gohtml index c465882..82adce6 100644 --- a/internal/templates/tmpl/styles.gohtml +++ b/internal/templates/tmpl/styles.gohtml @@ -692,4 +692,4 @@ form[style*="display:inline"] { border: 1px solid #d1a827; } -{{end}} \ No newline at end of file +{{end}}