From cd3fe4d2f4ce59ef812b1177d6851d680b71c1e7 Mon Sep 17 00:00:00 2001 From: Vladimir nett00n Budylnikov Date: Mon, 15 Dec 2025 21:56:04 +0400 Subject: [PATCH 01/15] 2025-12-15 21:56:04+04:00 --- internal/bot/birthday_notification_test.go | 132 +++++++++++++++++++++ internal/bot/bot.go | 7 +- 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/internal/bot/birthday_notification_test.go b/internal/bot/birthday_notification_test.go index 013fc93..ae7eb2d 100644 --- a/internal/bot/birthday_notification_test.go +++ b/internal/bot/birthday_notification_test.go @@ -40,3 +40,135 @@ func TestBirthdayNotificationUniqueness(t *testing.T) { t.Error("Birthday notification should be sent on a different day") } } + +// TestBirthdayDateCalculationAtDifferentTimes tests that birthday date calculation +// works correctly regardless of the time of day (fixes off-by-one day bug) +func TestBirthdayDateCalculationAtDifferentTimes(t *testing.T) { + testCases := []struct { + name string + currentTime time.Time + birthdayMMDD string + expectedDiff int + description string + }{ + { + name: "Birthday tomorrow at late evening (8 PM)", + currentTime: time.Date(2025, 12, 15, 20, 0, 0, 0, time.UTC), + birthdayMMDD: "12-16", + expectedDiff: 1, + description: "Should be 1 day away, not 0 (bug case)", + }, + { + name: "Birthday tomorrow at midnight", + currentTime: time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC), + birthdayMMDD: "12-16", + expectedDiff: 1, + description: "Should be 1 day away", + }, + { + name: "Birthday today at late evening (11 PM)", + currentTime: time.Date(2025, 12, 15, 23, 0, 0, 0, time.UTC), + birthdayMMDD: "12-15", + expectedDiff: 0, + description: "Should be 0 days away (today)", + }, + { + name: "Birthday today at early morning (2 AM)", + currentTime: time.Date(2025, 12, 15, 2, 0, 0, 0, time.UTC), + birthdayMMDD: "12-15", + expectedDiff: 0, + description: "Should be 0 days away (today)", + }, + { + name: "Birthday in 2 weeks at late evening", + currentTime: time.Date(2025, 12, 1, 20, 0, 0, 0, time.UTC), + birthdayMMDD: "12-15", + expectedDiff: 14, + description: "Should be exactly 14 days away", + }, + { + name: "Birthday in 4 weeks at late evening", + currentTime: time.Date(2025, 11, 17, 20, 0, 0, 0, time.UTC), + birthdayMMDD: "12-15", + expectedDiff: 28, + description: "Should be exactly 28 days away", + }, + { + name: "Birthday yesterday at late evening", + currentTime: time.Date(2025, 12, 16, 20, 0, 0, 0, time.UTC), + birthdayMMDD: "12-15", + expectedDiff: -1, + description: "Should be -1 days (passed yesterday)", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Parse the birthday MM-DD to determine this year's birthday date + thisYearBirthday, err := time.Parse("2006-01-02", time.Now().Format("2006")+"-"+tc.birthdayMMDD) + if err != nil { + // Use the test case's year instead + thisYearBirthday, err = time.Parse("2006-01-02", tc.currentTime.Format("2006")+"-"+tc.birthdayMMDD) + if err != nil { + t.Fatalf("Failed to parse birthday date: %v", err) + } + } + + // Normalize current time to start of day (midnight) - this is the fix + nowDate := time.Date(tc.currentTime.Year(), tc.currentTime.Month(), tc.currentTime.Day(), 0, 0, 0, 0, time.UTC) + + // Calculate days difference using date-only comparison + daysDiff := int(thisYearBirthday.Sub(nowDate).Hours() / 24) + + if daysDiff != tc.expectedDiff { + t.Errorf("%s: Expected %d days, got %d days. %s", + tc.name, tc.expectedDiff, daysDiff, tc.description) + t.Logf("Current time: %s", tc.currentTime.Format("2006-01-02 15:04:05")) + t.Logf("Normalized date: %s", nowDate.Format("2006-01-02 15:04:05")) + t.Logf("Birthday date: %s", thisYearBirthday.Format("2006-01-02 15:04:05")) + } + }) + } +} + +// TestBirthdayDateCalculationBugReproduction specifically tests the original bug scenario +func TestBirthdayDateCalculationBugReproduction(t *testing.T) { + // This is the exact scenario that caused the bug: + // Current time: December 15, 2025 at 8 PM (20:00) + // Birthday: December 16 (tomorrow) + // Bug: Bot would send "Happy Birthday" on Dec 15 instead of Dec 16 + + currentTime := time.Date(2025, 12, 15, 20, 0, 0, 0, time.UTC) // 8 PM on Dec 15 + birthdayMMDD := "12-16" // Birthday is Dec 16 + + // Parse the birthday for this year + thisYearBirthday, err := time.Parse("2006-01-02", "2025-"+birthdayMMDD) + if err != nil { + t.Fatalf("Failed to parse birthday date: %v", err) + } + + // OLD BUGGY CODE (would calculate 0 days): + // daysDiffBuggy := int(thisYearBirthday.Sub(currentTime).Hours() / 24) + // This gives: (2025-12-16 00:00:00 - 2025-12-15 20:00:00) = 4 hours + // 4 hours / 24 = 0.166... → int(0.166) = 0 ❌ WRONG! + + // NEW FIXED CODE (should calculate 1 day): + nowDate := time.Date(currentTime.Year(), currentTime.Month(), currentTime.Day(), 0, 0, 0, 0, time.UTC) + daysDiffFixed := int(thisYearBirthday.Sub(nowDate).Hours() / 24) + // This gives: (2025-12-16 00:00:00 - 2025-12-15 00:00:00) = 24 hours + // 24 hours / 24 = 1 ✓ CORRECT! + + if daysDiffFixed != 1 { + t.Errorf("Bug still exists! Expected 1 day difference, got %d", daysDiffFixed) + t.Logf("Current time: %s", currentTime.Format("2006-01-02 15:04:05 MST")) + t.Logf("Normalized date: %s", nowDate.Format("2006-01-02 15:04:05 MST")) + t.Logf("Birthday date: %s", thisYearBirthday.Format("2006-01-02 15:04:05 MST")) + t.Fatal("The off-by-one day bug has not been fixed!") + } + + // Verify that daysDiff == 0 would trigger a birthday message + // and daysDiff == 1 would NOT (which is the correct behavior) + if daysDiffFixed == 0 { + t.Error("Bug reproduced: Birthday message would be sent a day early!") + } +} diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 7cb8062..ac179a9 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -633,8 +633,11 @@ func (b *Bot) processBirthdays() { continue } - // Calculate days difference - daysDiff := int(thisYearBirthday.Sub(now).Hours() / 24) + // Normalize current time to start of day (midnight) for accurate date comparison + nowDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + + // Calculate days difference using date-only comparison + daysDiff := int(thisYearBirthday.Sub(nowDate).Hours() / 24) logger.LogNotification("DEBUG", "Birthday analysis for '%s': ThisYear=%s, DaysDiff=%d", birthday.Name, thisYearBirthday.Format("2006-01-02"), daysDiff) From 10fa25efd2814ba3fe895fa61518b76865415df2 Mon Sep 17 00:00:00 2001 From: Vladimir nett00n Budylnikov Date: Mon, 15 Dec 2025 22:03:38 +0400 Subject: [PATCH 02/15] 2025-12-15 22:03:38+04:00 --- internal/bot/birthday_notification_test.go | 9 +++++---- internal/bot/bot.go | 5 +++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/bot/birthday_notification_test.go b/internal/bot/birthday_notification_test.go index ae7eb2d..d675660 100644 --- a/internal/bot/birthday_notification_test.go +++ b/internal/bot/birthday_notification_test.go @@ -1,6 +1,7 @@ package bot import ( + "fmt" "testing" "time" @@ -104,11 +105,11 @@ func TestBirthdayDateCalculationAtDifferentTimes(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - // Parse the birthday MM-DD to determine this year's birthday date - thisYearBirthday, err := time.Parse("2006-01-02", time.Now().Format("2006")+"-"+tc.birthdayMMDD) + // Parse the birthday MM-DD using the test case's year + thisYearBirthday, err := time.Parse("2006-01-02", tc.currentTime.Format("2006")+"-"+tc.birthdayMMDD) if err != nil { - // Use the test case's year instead - thisYearBirthday, err = time.Parse("2006-01-02", tc.currentTime.Format("2006")+"-"+tc.birthdayMMDD) + // Fallback: try parsing with explicit year from test case + thisYearBirthday, err = time.Parse("2006-01-02", fmt.Sprintf("%d-%s", tc.currentTime.Year(), tc.birthdayMMDD)) if err != nil { t.Fatalf("Failed to parse birthday date: %v", err) } diff --git a/internal/bot/bot.go b/internal/bot/bot.go index ac179a9..9554fb1 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -13,6 +13,7 @@ import ( "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" ) @@ -658,7 +659,7 @@ func (b *Bot) processBirthdays() { } else if daysDiff < 0 { // Birthday has passed this year - check next year nextYearBirthday := thisYearBirthday.AddDate(1, 0, 0) - nextYearDaysDiff := int(nextYearBirthday.Sub(now).Hours() / 24) + nextYearDaysDiff := int(nextYearBirthday.Sub(nowDate).Hours() / 24) logger.LogNotification("DEBUG", "Birthday passed this year for '%s': NextYear=%s, NextYearDaysDiff=%d", birthday.Name, nextYearBirthday.Format("2006-01-02"), nextYearDaysDiff) @@ -708,7 +709,7 @@ func (b *Bot) processBirthdays() { if daysDiff < 0 { // Birthday has passed, show next year info nextYearBirthday := thisYearBirthday.AddDate(1, 0, 0) - nextYearDaysDiff := int(nextYearBirthday.Sub(now).Hours() / 24) + nextYearDaysDiff := int(nextYearBirthday.Sub(nowDate).Hours() / 24) logger.LogNotification("DEBUG", "NO_MATCH: Birthday '%s' (%s) passed this year (%d days ago), next occurrence in %d days", birthday.Name, birthdayMMDD, -daysDiff, nextYearDaysDiff) } else { From 7e90af4f5c3c91242cd20621d3df204e5054cd1b Mon Sep 17 00:00:00 2001 From: Vladimir nett00n Budylnikov Date: Mon, 15 Dec 2025 22:22:43 +0400 Subject: [PATCH 03/15] 2025-12-15 22:22:43+04:00 --- .claude/settings.local.json | 9 ++++ cmd/app/main.go | 5 ++ internal/bot/birthday_notification_test.go | 17 +------ internal/bot/bot.go | 54 +++++++++++++++++----- internal/handlers/bot_info.go | 2 + internal/handlers/handlers.go | 45 ++++++++++++++---- internal/logger/logger.go | 35 ++++++++++---- internal/models/birthday.go | 12 +++-- internal/storage/storage.go | 5 ++ internal/templates/funcs.go | 20 ++++---- internal/templates/templates.go | 3 ++ 11 files changed, 150 insertions(+), 57 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..164ba68 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(grep:*)", + "Bash(go list:*)", + "Bash(go tool cover:*)" + ] + } +} diff --git a/cmd/app/main.go b/cmd/app/main.go index 0a39e14..d63041b 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -1,3 +1,5 @@ +// Package main is the entry point for the birthday notification bot web application. +// It initializes the Telegram bot and HTTP server for the web UI. package main import ( @@ -37,6 +39,9 @@ func main() { } } +// initBot creates and starts the Telegram bot from the TELEGRAM_BOT_TOKEN environment variable. +// It logs a warning if the token is not set and returns nil without error. +// Returns an error if bot creation or startup fails. func initBot() (*bot.Bot, error) { token := os.Getenv("TELEGRAM_BOT_TOKEN") if token == "" { diff --git a/internal/bot/birthday_notification_test.go b/internal/bot/birthday_notification_test.go index d675660..538d5f9 100644 --- a/internal/bot/birthday_notification_test.go +++ b/internal/bot/birthday_notification_test.go @@ -1,7 +1,6 @@ package bot import ( - "fmt" "testing" "time" @@ -108,11 +107,7 @@ func TestBirthdayDateCalculationAtDifferentTimes(t *testing.T) { // Parse the birthday MM-DD using the test case's year thisYearBirthday, err := time.Parse("2006-01-02", tc.currentTime.Format("2006")+"-"+tc.birthdayMMDD) if err != nil { - // Fallback: try parsing with explicit year from test case - thisYearBirthday, err = time.Parse("2006-01-02", fmt.Sprintf("%d-%s", tc.currentTime.Year(), tc.birthdayMMDD)) - if err != nil { - t.Fatalf("Failed to parse birthday date: %v", err) - } + t.Fatalf("Failed to parse birthday date: %v", err) } // Normalize current time to start of day (midnight) - this is the fix @@ -143,21 +138,13 @@ func TestBirthdayDateCalculationBugReproduction(t *testing.T) { birthdayMMDD := "12-16" // Birthday is Dec 16 // Parse the birthday for this year - thisYearBirthday, err := time.Parse("2006-01-02", "2025-"+birthdayMMDD) + thisYearBirthday, err := time.Parse("2006-01-02", currentTime.Format("2006")+"-"+birthdayMMDD) if err != nil { t.Fatalf("Failed to parse birthday date: %v", err) } - // OLD BUGGY CODE (would calculate 0 days): - // daysDiffBuggy := int(thisYearBirthday.Sub(currentTime).Hours() / 24) - // This gives: (2025-12-16 00:00:00 - 2025-12-15 20:00:00) = 4 hours - // 4 hours / 24 = 0.166... → int(0.166) = 0 ❌ WRONG! - - // NEW FIXED CODE (should calculate 1 day): nowDate := time.Date(currentTime.Year(), currentTime.Month(), currentTime.Day(), 0, 0, 0, 0, time.UTC) daysDiffFixed := int(thisYearBirthday.Sub(nowDate).Hours() / 24) - // This gives: (2025-12-16 00:00:00 - 2025-12-15 00:00:00) = 24 hours - // 24 hours / 24 = 1 ✓ CORRECT! if daysDiffFixed != 1 { t.Errorf("Bug still exists! Expected 1 day difference, got %d", daysDiffFixed) diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 9554fb1..80a7105 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -1,3 +1,5 @@ +// Package bot provides the Telegram bot implementation for birthday notifications. +// It handles incoming messages, commands, birthday tracking, and scheduled notifications. package bot import ( @@ -17,20 +19,35 @@ import ( tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) +// Bot represents a Telegram bot instance that manages birthday notifications. type Bot struct { - 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 + // api is the Telegram Bot API client. + api *tgbotapi.BotAPI + // status is the current bot status (e.g., "connecting", "running", "stopped"). + status string + // username is the bot's Telegram username. + username string + // firstName is the bot's display name. + firstName string + // startTime is the bot startup timestamp. + startTime time.Time + // notificationsSent is the counter of birthday notifications sent. + notificationsSent int64 + // notificationStartHour is the start hour for notifications (0-23, UTC). + notificationStartHour int + // notificationEndHour is the end hour for notifications (0-23, UTC). + notificationEndHour int + // mu is the mutex for thread-safe access to bot state. + mu sync.RWMutex + // ctx is the context for cancellation. + ctx context.Context + // cancel is the function to cancel the bot's context. + cancel context.CancelFunc } +// New creates and initializes a new Telegram bot instance with the given token. +// It fetches bot information from Telegram and parses notification hours from environment variables. +// Returns an error if the token is invalid or Telegram API communication fails. func New(token string) (*Bot, error) { if token == "" { return nil, fmt.Errorf("telegram bot token is required") @@ -88,15 +105,20 @@ func New(token string) (*Bot, error) { return bot, nil } +// Start begins the bot's message polling and birthday checking goroutines. +// This is non-blocking; the bot runs in the background. func (b *Bot) Start() { go b.run() } +// Stop gracefully shuts down the bot by canceling its context and updating its status. func (b *Bot) Stop() { b.cancel() b.setStatus("stopped") } +// GetStatus returns the current bot status (e.g., "running", "stopped", "not configured"). +// Returns "not configured" if the bot is nil. func (b *Bot) GetStatus() string { if b == nil { return "not configured" @@ -106,6 +128,8 @@ func (b *Bot) GetStatus() string { return b.status } +// GetUsername returns the bot's Telegram username without the @ prefix. +// Returns an empty string if the bot is nil. func (b *Bot) GetUsername() string { if b == nil { return "" @@ -115,6 +139,8 @@ func (b *Bot) GetUsername() string { return b.username } +// GetFirstName returns the bot's display name as configured in Telegram. +// Returns an empty string if the bot is nil. func (b *Bot) GetFirstName() string { if b == nil { return "" @@ -124,6 +150,8 @@ func (b *Bot) GetFirstName() string { return b.firstName } +// GetUptime returns the duration since the bot started. +// Returns 0 if the bot is nil. func (b *Bot) GetUptime() time.Duration { if b == nil { return 0 @@ -133,6 +161,8 @@ func (b *Bot) GetUptime() time.Duration { return time.Since(b.startTime) } +// GetNotificationsSent returns the total number of birthday notifications sent by the bot. +// Returns 0 if the bot is nil. func (b *Bot) GetNotificationsSent() int64 { if b == nil { return 0 @@ -142,6 +172,8 @@ func (b *Bot) GetNotificationsSent() int64 { return b.notificationsSent } +// GetNotificationHours returns the configured start and end hours (UTC) for sending notifications. +// Returns (0, 0) if the bot is nil. func (b *Bot) GetNotificationHours() (int, int) { if b == nil { return 0, 0 diff --git a/internal/handlers/bot_info.go b/internal/handlers/bot_info.go index eaa3ac3..94ed7ab 100644 --- a/internal/handlers/bot_info.go +++ b/internal/handlers/bot_info.go @@ -7,6 +7,8 @@ import ( "5mdt/bd_bot/internal/logger" ) +// BotInfoHandler returns an HTTP handler that renders the bot status information as partial HTML. +// It queries the bot provider for current status, uptime, and notification metrics. func BotInfoHandler(tpl *template.Template, botProvider BotStatusProvider) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 23d9121..dd9c1c1 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -1,3 +1,6 @@ +// Package handlers provides HTTP request handlers for the birthday management web interface. +// It manages rendering the birthday list, handling form submissions for add/edit/delete operations, +// and displaying bot status information. package handlers import ( @@ -13,29 +16,49 @@ import ( "5mdt/bd_bot/internal/storage" ) +// PageData contains the data passed to page templates. type PageData struct { + // Birthdays is the list of birthday records to display. Birthdays []models.Birthday - BotInfo BotInfo + // BotInfo contains Telegram bot status and statistics. + BotInfo BotInfo } +// BotInfo represents the Telegram bot's current status and configuration. type BotInfo struct { - Status string - Username string - FirstName string - Uptime string - NotificationsSent int64 - NotificationHours string - NextCheckTime string + // Status is the current bot status (e.g., "running", "stopped", "not configured"). + Status string + // Username is the Telegram bot's username (without @). + Username string + // FirstName is the Telegram bot's display name. + FirstName string + // Uptime is the human-readable uptime duration. + Uptime string + // NotificationsSent is the total number of birthday notifications sent. + NotificationsSent int64 + // NotificationHours is the configured notification time window (e.g., "08:00 - 20:00 UTC"). + NotificationHours string + // NextCheckTime is the next scheduled birthday check time. + NextCheckTime string + // CurrentHourInWindow indicates whether the current hour falls within the notification window. CurrentHourInWindow bool - Configured bool + // Configured indicates whether the bot is properly configured with a valid token. + Configured bool } +// BotStatusProvider defines the interface for querying bot status and metrics. type BotStatusProvider interface { + // GetStatus returns the current bot status. GetStatus() string + // GetUsername returns the bot's username. GetUsername() string + // GetFirstName returns the bot's display name. GetFirstName() string + // GetUptime returns the duration since bot startup. GetUptime() time.Duration + // GetNotificationsSent returns the total notifications sent. GetNotificationsSent() int64 + // GetNotificationHours returns start and end hours for notifications. GetNotificationHours() (int, int) } @@ -179,6 +202,7 @@ func loadBirthdaysOrError(w http.ResponseWriter) ([]models.Birthday, bool) { return bs, true } +// IndexHandler returns an HTTP handler that renders the main birthday list page with bot status. func IndexHandler(tpl *template.Template, botProvider BotStatusProvider) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { bs, ok := loadBirthdaysOrError(w) @@ -219,6 +243,8 @@ func IndexHandler(tpl *template.Template, botProvider BotStatusProvider) http.Ha } } +// SaveRowHandler returns an HTTP handler that processes form submissions to add or update birthday records. +// For idx==-1, it adds a new record; otherwise, it updates the record at the given index. func SaveRowHandler(tpl *template.Template) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { idx, err := parseIdx(r) @@ -258,6 +284,7 @@ func SaveRowHandler(tpl *template.Template) http.HandlerFunc { } } +// DeleteRowHandler returns an HTTP handler that processes requests to delete birthday records by index. func DeleteRowHandler(tpl *template.Template) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { idx, err := parseIdx(r) diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 97b69af..83aced1 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -1,3 +1,7 @@ +// Package logger provides structured logging utilities with configurable log levels. +// It supports DEBUG, INFO, WARN, and ERROR levels controlled via environment variables +// (DEBUG=true or LOG_LEVEL=). Includes specialized helpers for HTTP, bot, storage, +// and notification logging. package logger import ( @@ -8,12 +12,17 @@ import ( "time" ) +// LogLevel represents the severity level of log messages. type LogLevel int const ( + // DEBUG logs diagnostic information (enabled via DEBUG=true or LOG_LEVEL=DEBUG). DEBUG LogLevel = iota + // INFO logs general informational messages. INFO + // WARN logs warning messages for potentially problematic situations. WARN + // ERROR logs error messages for failures and exceptions. ERROR ) @@ -65,57 +74,61 @@ func formatMessage(level string, component string, format string, args ...interf return fmt.Sprintf("%s [%s] %s", timestamp, level, message) } -// Debug logs debug messages (only when DEBUG=true) +// Debug logs debug messages with the specified component and format (only when DEBUG=true or LOG_LEVEL=DEBUG). func Debug(component string, format string, args ...interface{}) { if shouldLog(DEBUG) { log.Print(formatMessage("DEBUG", component, format, args...)) } } -// Info logs informational messages +// Info logs informational messages with the specified component and format. func Info(component string, format string, args ...interface{}) { if shouldLog(INFO) { log.Print(formatMessage("INFO", component, format, args...)) } } -// Warn logs warning messages +// Warn logs warning messages with the specified component and format. func Warn(component string, format string, args ...interface{}) { if shouldLog(WARN) { log.Print(formatMessage("WARN", component, format, args...)) } } -// Error logs error messages +// Error logs error messages with the specified component and format. func Error(component string, format string, args ...interface{}) { if shouldLog(ERROR) { log.Print(formatMessage("ERROR", component, format, args...)) } } -// IsDebugEnabled returns true if debug logging is enabled +// IsDebugEnabled returns true if debug logging is enabled via environment variables. func IsDebugEnabled() bool { return debugEnabled } -// Simple logging functions without component (backward compatibility) +// Debugf logs debug messages without a component prefix for backward compatibility. func Debugf(format string, args ...interface{}) { Debug("", format, args...) } +// Infof logs informational messages without a component prefix for backward compatibility. func Infof(format string, args ...interface{}) { Info("", format, args...) } +// Warnf logs warning messages without a component prefix for backward compatibility. func Warnf(format string, args ...interface{}) { Warn("", format, args...) } +// Errorf logs error messages without a component prefix for backward compatibility. func Errorf(format string, args ...interface{}) { Error("", format, args...) } -// HTTP request logging helpers +// LogRequest logs HTTP request information including method, path, status code, and duration. +// In debug mode, it also logs the user agent string. func LogRequest(method, path, userAgent string, statusCode int, duration time.Duration) { if debugEnabled { Debug("HTTP", "%s %s - %d (%v) - %s", method, path, statusCode, duration, userAgent) @@ -124,7 +137,8 @@ func LogRequest(method, path, userAgent string, statusCode int, duration time.Du } } -// Bot operation logging helpers +// LogBotMessage logs incoming Telegram bot messages with chat ID and username. +// In debug mode, it also logs the full message content. func LogBotMessage(chatID int64, username, message string) { if debugEnabled { Debug("BOT", "Message from %s (Chat ID: %d): %s", username, chatID, message) @@ -133,6 +147,7 @@ func LogBotMessage(chatID int64, username, message string) { } } +// LogBotAction logs Telegram bot actions with success/failure status. func LogBotAction(action, target string, success bool) { if debugEnabled { Debug("BOT", "Action: %s -> %s (success: %t)", action, target, success) @@ -143,7 +158,7 @@ func LogBotAction(action, target string, success bool) { } } -// Storage operation logging helpers +// LogStorage logs storage operations (load, save, etc.) with optional error details. func LogStorage(operation string, details string, err error) { if err != nil { Error("STORAGE", "%s failed: %v - %s", operation, err, details) @@ -154,7 +169,7 @@ func LogStorage(operation string, details string, err error) { } } -// Notification logging helpers +// LogNotification logs birthday notification events with the specified severity level. func LogNotification(level string, message string, args ...interface{}) { component := "NOTIFICATION" formattedMsg := fmt.Sprintf(message, args...) diff --git a/internal/models/birthday.go b/internal/models/birthday.go index 74c8077..cdb2f4c 100644 --- a/internal/models/birthday.go +++ b/internal/models/birthday.go @@ -1,10 +1,16 @@ +// Package models defines the data structures for the birthday notification application. package models import "time" +// Birthday represents a person's birthday information stored for notifications. type Birthday struct { - Name string `yaml:"name"` - BirthDate string `yaml:"birth_date"` + // Name is the person's name or chat title. + Name string `yaml:"name"` + // BirthDate is the birth date in YYYY-MM-DD or 0000-MM-DD (year unknown) format. + BirthDate string `yaml:"birth_date"` + // LastNotification is the timestamp of the last birthday notification sent. LastNotification time.Time `yaml:"last_notification"` - ChatID int64 `yaml:"chat_id"` + // ChatID is the Telegram chat ID for sending notifications. + ChatID int64 `yaml:"chat_id"` } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 02870f1..6f3cd8d 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -1,3 +1,5 @@ +// Package storage provides YAML-based persistence for birthday data. +// It manages loading and saving birthday records from/to a configurable file path. package storage import ( @@ -16,6 +18,8 @@ func getPath() string { return "/data/birthdays.yaml" } +// LoadBirthdays reads and parses birthday data from the configured YAML file. +// It creates an empty file if it doesn't exist. Returns an empty slice and error on failure. func LoadBirthdays() ([]models.Birthday, error) { filePath := getPath() if _, err := os.Stat(filePath); os.IsNotExist(err) { @@ -31,6 +35,7 @@ func LoadBirthdays() ([]models.Birthday, error) { return bs, yaml.Unmarshal(data, &bs) } +// SaveBirthdays marshals birthday data to YAML and writes it to the configured file path. func SaveBirthdays(bs []models.Birthday) error { filePath := getPath() data, err := yaml.Marshal(bs) diff --git a/internal/templates/funcs.go b/internal/templates/funcs.go index d3e5315..b1fdae3 100644 --- a/internal/templates/funcs.go +++ b/internal/templates/funcs.go @@ -1,3 +1,4 @@ +// Package templates provides template rendering and helper functions for the web interface. package templates import ( @@ -7,6 +8,8 @@ import ( "time" ) +// dict creates a map from alternating key-value arguments for use in templates. +// It validates that an even number of arguments are provided and all keys are strings. func dict(v ...interface{}) (map[string]interface{}, error) { if len(v)%2 != 0 { return nil, errors.New("dict requires even number of arguments") @@ -22,7 +25,8 @@ func dict(v ...interface{}) (map[string]interface{}, error) { return m, nil } -// formatTime formats a time.Time as an ISO string for JavaScript consumption +// formatTime returns a time.Time as an RFC3339 string for JavaScript consumption, +// or an empty string if the time is zero. func formatTime(t time.Time) string { if t.IsZero() { return "" @@ -30,14 +34,13 @@ func formatTime(t time.Time) string { return t.UTC().Format(time.RFC3339) } -// isZeroTime checks if a time.Time is zero +// isZeroTime returns true if the given time.Time value is zero (unset). func isZeroTime(t time.Time) bool { return t.IsZero() } -// formatBirthDate formats birth date for display -// If year is 0000 (unknown), show only MM-DD -// Otherwise show the full YYYY-MM-DD +// formatBirthDate formats a birth date string for display in the UI. +// If the year is 0000 (unknown), it returns only MM-DD; otherwise, it returns the full YYYY-MM-DD. func formatBirthDate(birthDate string) string { if strings.HasPrefix(birthDate, "0000-") { // Return MM-DD for unknown year @@ -46,9 +49,8 @@ func formatBirthDate(birthDate string) string { return birthDate } -// formatBirthDateForInput formats birth date for HTML date input -// If year is 0000 (unknown), substitute current year so browser can display it -// Otherwise return the full date for the input +// formatBirthDateForInput formats a birth date string for HTML date input elements. +// If the year is 0000 (unknown), it substitutes the current year for browser display. func formatBirthDateForInput(birthDate string) string { if strings.HasPrefix(birthDate, "0000-") { // Replace 0000 with current year for browser display @@ -58,7 +60,7 @@ func formatBirthDateForInput(birthDate string) string { return birthDate } -// isUnknownYear checks if a birth date has an unknown year (0000) +// isUnknownYear returns true if the birth date has an unknown year (starts with "0000-"). func isUnknownYear(birthDate string) bool { return strings.HasPrefix(birthDate, "0000-") } diff --git a/internal/templates/templates.go b/internal/templates/templates.go index 091b635..95309c8 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -14,6 +14,9 @@ var ( once sync.Once ) +// LoadTemplates loads and parses all HTML templates from the embedded filesystem, +// registers custom template functions, and returns a cached template for rendering. +// Uses sync.Once to ensure templates are loaded only once. func LoadTemplates() *template.Template { once.Do(func() { // Load template with functions for birth date handling (updated) From 1f096e1911e1f9f211b6bcff62c8d5653a46223f Mon Sep 17 00:00:00 2001 From: Vladimir nett00n Budylnikov Date: Mon, 15 Dec 2025 22:35:29 +0400 Subject: [PATCH 04/15] 2025-12-15 22:35:29+04:00 --- internal/bot/bot.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 80a7105..05c42fb 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -37,6 +37,8 @@ type Bot struct { notificationStartHour int // notificationEndHour is the end hour for notifications (0-23, UTC). notificationEndHour int + // running indicates whether the bot's run loop is active. + running bool // mu is the mutex for thread-safe access to bot state. mu sync.RWMutex // ctx is the context for cancellation. @@ -107,7 +109,17 @@ func New(token string) (*Bot, error) { // Start begins the bot's message polling and birthday checking goroutines. // This is non-blocking; the bot runs in the background. +// If the bot is already running, this method does nothing to prevent duplicate goroutines. func (b *Bot) Start() { + b.mu.Lock() + defer b.mu.Unlock() + + if b.running { + logger.Warn("BOT", "Start() called but bot is already running, ignoring") + return + } + + b.running = true go b.run() } @@ -205,6 +217,10 @@ func (b *Bot) run() { for { select { case <-b.ctx.Done(): + b.api.StopReceivingUpdates() + b.mu.Lock() + b.running = false + b.mu.Unlock() b.setStatus("stopped") return case update := <-updates: From 390069f738904808ef0a659adf1c39448a383fad Mon Sep 17 00:00:00 2001 From: Vladimir nett00n Budylnikov Date: Mon, 15 Dec 2025 22:41:41 +0400 Subject: [PATCH 05/15] safer yaml --- internal/storage/storage.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 6f3cd8d..7f382ec 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -4,6 +4,7 @@ package storage import ( "os" + "path/filepath" "5mdt/bd_bot/internal/models" "gopkg.in/yaml.v3" @@ -19,10 +20,17 @@ func getPath() string { } // LoadBirthdays reads and parses birthday data from the configured YAML file. -// It creates an empty file if it doesn't exist. Returns an empty slice and error on failure. +// It creates an empty file (and parent directories) if it doesn't exist. +// Returns a nil slice and error on failure. func LoadBirthdays() ([]models.Birthday, error) { filePath := getPath() if _, err := os.Stat(filePath); os.IsNotExist(err) { + // Ensure parent directory exists + if dir := filepath.Dir(filePath); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, err + } + } if err := os.WriteFile(filePath, []byte("[]\n"), filePerm); err != nil { return nil, err } @@ -36,11 +44,20 @@ func LoadBirthdays() ([]models.Birthday, error) { } // SaveBirthdays marshals birthday data to YAML and writes it to the configured file path. +// It creates parent directories if they don't exist. func SaveBirthdays(bs []models.Birthday) error { filePath := getPath() data, err := yaml.Marshal(bs) if err != nil { return err } + + // Ensure parent directory exists + if dir := filepath.Dir(filePath); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + } + return os.WriteFile(filePath, data, filePerm) } From b7057a29ca96eb5082b8ee9ac00d85909f286f09 Mon Sep 17 00:00:00 2001 From: Vladimir nett00n Budylnikov Date: Mon, 15 Dec 2025 22:47:34 +0400 Subject: [PATCH 06/15] better dates --- internal/templates/funcs.go | 14 +++- internal/templates/funcs_test.go | 109 +++++++++++++++++++++++++------ 2 files changed, 101 insertions(+), 22 deletions(-) diff --git a/internal/templates/funcs.go b/internal/templates/funcs.go index b1fdae3..c550395 100644 --- a/internal/templates/funcs.go +++ b/internal/templates/funcs.go @@ -8,6 +8,11 @@ import ( "time" ) +// nowYear returns the current year and can be overridden in tests for deterministic behavior. +var nowYear = func() int { + return time.Now().Year() +} + // dict creates a map from alternating key-value arguments for use in templates. // It validates that an even number of arguments are provided and all keys are strings. func dict(v ...interface{}) (map[string]interface{}, error) { @@ -50,11 +55,16 @@ func formatBirthDate(birthDate string) string { } // formatBirthDateForInput formats a birth date string for HTML date input elements. -// If the year is 0000 (unknown), it substitutes the current year for browser display. +// If the year is 0000 (unknown), it substitutes a suitable year for browser display. +// For Feb 29 (leap day), it uses 2000 to avoid invalid dates in non-leap years. func formatBirthDateForInput(birthDate string) string { if strings.HasPrefix(birthDate, "0000-") { + // For leap day (Feb 29), use a fixed leap year to avoid invalid dates + if strings.HasPrefix(birthDate, "0000-02-29") { + return strings.Replace(birthDate, "0000", "2000", 1) + } // Replace 0000 with current year for browser display - currentYear := time.Now().Year() + currentYear := nowYear() return strings.Replace(birthDate, "0000", fmt.Sprintf("%d", currentYear), 1) } return birthDate diff --git a/internal/templates/funcs_test.go b/internal/templates/funcs_test.go index 7f536e4..1819c74 100644 --- a/internal/templates/funcs_test.go +++ b/internal/templates/funcs_test.go @@ -2,45 +2,114 @@ package templates import ( "testing" - "time" ) -func TestFormatTime(t *testing.T) { +func TestFormatBirthDateForInput(t *testing.T) { + // Save original nowYear and restore after test + originalNowYear := nowYear + defer func() { nowYear = originalNowYear }() + + // Set a fixed year for deterministic testing + nowYear = func() int { return 2025 } + + tests := []struct { + name string + input string + expected string + }{ + { + name: "Unknown year regular date", + input: "0000-06-15", + expected: "2025-06-15", + }, + { + name: "Unknown year leap day (Feb 29)", + input: "0000-02-29", + expected: "2000-02-29", // Should use 2000, not 2025 (non-leap year) + }, + { + name: "Known year", + input: "1990-12-25", + expected: "1990-12-25", + }, + { + name: "Known year leap day", + input: "2020-02-29", + expected: "2020-02-29", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatBirthDateForInput(tt.input) + if result != tt.expected { + t.Errorf("formatBirthDateForInput(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestFormatBirthDate(t *testing.T) { tests := []struct { - name string - time time.Time - want string + name string + input string + expected string }{ - {"zero time", time.Time{}, ""}, - {"valid time", time.Date(2024, 1, 1, 15, 30, 0, 0, time.UTC), "2024-01-01T15:30:00Z"}, - {"non-UTC time", time.Date(2024, 1, 1, 15, 30, 0, 0, time.FixedZone("EST", -5*3600)), "2024-01-01T20:30:00Z"}, + { + name: "Unknown year", + input: "0000-06-15", + expected: "06-15", + }, + { + name: "Unknown year leap day", + input: "0000-02-29", + expected: "02-29", + }, + { + name: "Known year", + input: "1990-12-25", + expected: "1990-12-25", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := formatTime(tt.time) - if got != tt.want { - t.Errorf("formatTime() = %q; want %q", got, tt.want) + result := formatBirthDate(tt.input) + if result != tt.expected { + t.Errorf("formatBirthDate(%q) = %q, want %q", tt.input, result, tt.expected) } }) } } -func TestIsZeroTime(t *testing.T) { +func TestIsUnknownYear(t *testing.T) { tests := []struct { - name string - time time.Time - want bool + name string + input string + expected bool }{ - {"zero time", time.Time{}, true}, - {"valid time", time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), false}, + { + name: "Unknown year", + input: "0000-06-15", + expected: true, + }, + { + name: "Unknown year leap day", + input: "0000-02-29", + expected: true, + }, + { + name: "Known year", + input: "1990-12-25", + expected: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := isZeroTime(tt.time) - if got != tt.want { - t.Errorf("isZeroTime() = %v; want %v", got, tt.want) + result := isUnknownYear(tt.input) + if result != tt.expected { + t.Errorf("isUnknownYear(%q) = %v, want %v", tt.input, result, tt.expected) } }) } From 4801f361f2838570f1f0a1b1bef6400cdb10c79b Mon Sep 17 00:00:00 2001 From: Vladimir nett00n Budylnikov Date: Mon, 15 Dec 2025 22:49:41 +0400 Subject: [PATCH 07/15] Removed the redundant check --- internal/bot/birthday_notification_test.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/internal/bot/birthday_notification_test.go b/internal/bot/birthday_notification_test.go index 538d5f9..51cbb1c 100644 --- a/internal/bot/birthday_notification_test.go +++ b/internal/bot/birthday_notification_test.go @@ -153,10 +153,4 @@ func TestBirthdayDateCalculationBugReproduction(t *testing.T) { t.Logf("Birthday date: %s", thisYearBirthday.Format("2006-01-02 15:04:05 MST")) t.Fatal("The off-by-one day bug has not been fixed!") } - - // Verify that daysDiff == 0 would trigger a birthday message - // and daysDiff == 1 would NOT (which is the correct behavior) - if daysDiffFixed == 0 { - t.Error("Bug reproduced: Birthday message would be sent a day early!") - } } From 74c94951cd78f92bd5af27550642ff26bea613fb Mon Sep 17 00:00:00 2001 From: Vladimir nett00n Budylnikov Date: Mon, 15 Dec 2025 22:52:40 +0400 Subject: [PATCH 08/15] Enhanced validation in updateBirthdayFromForm Improved error handling in DeleteRowHandler --- internal/handlers/handlers.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index dd9c1c1..0651a97 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -120,11 +120,18 @@ func updateBirthdayFromForm(b *models.Birthday, r *http.Request) { if timestampStr := r.FormValue("last_notification"); timestampStr != "" { if timestamp, err := time.Parse(time.RFC3339, timestampStr); err == nil { b.LastNotification = timestamp.UTC() + } else { + logger.Error("HANDLERS", "Failed to parse last_notification '%s': %v", timestampStr, err) } } - if id, err := strconv.ParseInt(r.FormValue("chat_id"), 10, 64); err == nil { - b.ChatID = id + chatIDStr := r.FormValue("chat_id") + if chatIDStr != "" { + if id, err := strconv.ParseInt(chatIDStr, 10, 64); err == nil { + b.ChatID = id + } else { + logger.Error("HANDLERS", "Failed to parse chat_id '%s': %v", chatIDStr, err) + } } } @@ -308,6 +315,8 @@ func DeleteRowHandler(tpl *template.Template) http.HandlerFunc { } } else { logger.Error("HANDLERS", "DeleteRowHandler invalid idx: %d", idx) + http.Error(w, "Invalid idx", http.StatusBadRequest) + return } if err := tpl.ExecuteTemplate(w, "table", bs); err != nil { logger.Error("HANDLERS", "Template execute error: %v", err) From 647f1b755cb4711c6c9d042a56792a1231d70a0e Mon Sep 17 00:00:00 2001 From: Vladimir nett00n Budylnikov Date: Mon, 15 Dec 2025 22:55:43 +0400 Subject: [PATCH 09/15] double timestamp issue fixed --- internal/logger/logger.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 83aced1..0609436 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -56,8 +56,8 @@ func init() { } } - // Configure log format - log.SetFlags(log.LstdFlags | log.Lmicroseconds) + // Configure log format - disable default prefix since formatMessage handles all formatting + log.SetFlags(0) } func shouldLog(level LogLevel) bool { From a790056a18958febb68cec4ae3a5a65280f11f7e Mon Sep 17 00:00:00 2001 From: Vladimir nett00n Budylnikov Date: Tue, 16 Dec 2025 01:33:43 +0400 Subject: [PATCH 10/15] 2025-12-16 01:33:43+04:00 --- internal/bot/birthday_notification_test.go | 6 +- internal/bot/bot.go | 68 +++++++++++++--------- 2 files changed, 44 insertions(+), 30 deletions(-) diff --git a/internal/bot/birthday_notification_test.go b/internal/bot/birthday_notification_test.go index 51cbb1c..4cd47b6 100644 --- a/internal/bot/birthday_notification_test.go +++ b/internal/bot/birthday_notification_test.go @@ -18,7 +18,7 @@ func TestBirthdayNotificationUniqueness(t *testing.T) { } // Simulate first birthday notification - shouldSend := bot.shouldSendBirthdayNotification(testBirthday, "BIRTHDAY_TODAY", 0) + shouldSend := bot.shouldSendBirthdayNotification(testBirthday, "BIRTHDAY_TODAY") if !shouldSend { t.Error("First birthday notification should be sent") } @@ -27,7 +27,7 @@ func TestBirthdayNotificationUniqueness(t *testing.T) { testBirthday.LastNotification = time.Now() // Simulate second birthday notification on the same day - shouldSend = bot.shouldSendBirthdayNotification(testBirthday, "BIRTHDAY_TODAY", 0) + shouldSend = bot.shouldSendBirthdayNotification(testBirthday, "BIRTHDAY_TODAY") if shouldSend { t.Error("Second birthday notification on the same day should not be sent") } @@ -35,7 +35,7 @@ func TestBirthdayNotificationUniqueness(t *testing.T) { // Simulate birthday notification on a different day futureTime := time.Now().AddDate(0, 0, 1) testBirthday.LastNotification = futureTime - shouldSend = bot.shouldSendBirthdayNotification(testBirthday, "BIRTHDAY_TODAY", 0) + shouldSend = bot.shouldSendBirthdayNotification(testBirthday, "BIRTHDAY_TODAY") 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 05c42fb..4e0e301 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -19,6 +19,12 @@ import ( tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) +// Precompiled regex patterns for date validation +var ( + mmddRegex = regexp.MustCompile(`^\d{2}-\d{2}$`) + dateRegex = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) +) + // Bot represents a Telegram bot instance that manages birthday notifications. type Bot struct { // api is the Telegram Bot API client. @@ -327,6 +333,37 @@ The bot will send you birthday greetings on your special day! 🎉` } } +// resolveChatName determines the appropriate display name for a chat. +// For group chats, it returns the group title. For private chats, it returns +// the user's full name (first + last) or username. Falls back to "Unknown" if +// no name information is available. +func resolveChatName(message *tgbotapi.Message) string { + chatName := "Unknown" + if message.Chat.Type == "group" || message.Chat.Type == "supergroup" { + // For group chats, use the group name + if message.Chat.Title != "" { + chatName = message.Chat.Title + } + } else { + // For private chats, use user's name + if message.From.FirstName != "" { + chatName = message.From.FirstName + if message.From.LastName != "" { + chatName += " " + message.From.LastName + } + } else if message.From.UserName != "" { + chatName = message.From.UserName + } + } + + // Final fallback to chat title if still unknown + if chatName == "Unknown" && message.Chat.Title != "" { + chatName = message.Chat.Title + } + + return chatName +} + func (b *Bot) handleUpdateBirthDateCommand(message *tgbotapi.Message, args string) { if args == "" { msg := tgbotapi.NewMessage(message.Chat.ID, "Please provide a birth date. Example: /update_birth_date 1999-12-31") @@ -337,7 +374,6 @@ func (b *Bot) handleUpdateBirthDateCommand(message *tgbotapi.Message, args strin } // Handle MM-DD format by converting to 0000-MM-DD (year unknown) - mmddRegex := regexp.MustCompile(`^\d{2}-\d{2}$`) if mmddRegex.MatchString(args) { // Validate the MM-DD date _, err := time.Parse("01-02", args) @@ -353,7 +389,6 @@ func (b *Bot) handleUpdateBirthDateCommand(message *tgbotapi.Message, args strin } // Validate date format (YYYY-MM-DD) - dateRegex := regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) if !dateRegex.MatchString(args) { msg := tgbotapi.NewMessage(message.Chat.ID, "Invalid date format. Please use YYYY-MM-DD format (e.g., 1999-12-31)") if _, err := b.api.Send(msg); err != nil { @@ -372,29 +407,8 @@ func (b *Bot) handleUpdateBirthDateCommand(message *tgbotapi.Message, args strin return } - // Get chat name - prioritize chat title for groups, fall back to user info for private chats - chatName := "Unknown" - if message.Chat.Type == "group" || message.Chat.Type == "supergroup" { - // For group chats, use the group name - if message.Chat.Title != "" { - chatName = message.Chat.Title - } - } else { - // For private chats, use user's name - if message.From.FirstName != "" { - chatName = message.From.FirstName - if message.From.LastName != "" { - chatName += " " + message.From.LastName - } - } else if message.From.UserName != "" { - chatName = message.From.UserName - } - } - - // Final fallback to chat title if still unknown - if chatName == "Unknown" && message.Chat.Title != "" { - chatName = message.Chat.Title - } + // Get chat name using helper function + chatName := resolveChatName(message) // Load existing birthdays birthdays, err := storage.LoadBirthdays() @@ -581,7 +595,7 @@ func (b *Bot) checkBirthdays() { } } -func (b *Bot) shouldSendBirthdayNotification(birthday models.Birthday, notificationType string, daysDiff int) bool { +func (b *Bot) shouldSendBirthdayNotification(birthday models.Birthday, notificationType string) bool { // Always send birthday today notification if notificationType == "BIRTHDAY_TODAY" { // Check if last notification was today @@ -728,7 +742,7 @@ func (b *Bot) processBirthdays() { } // Check if this notification should be sent - if b.shouldSendBirthdayNotification(birthday, notificationType, daysDiff) { + if b.shouldSendBirthdayNotification(birthday, notificationType) { logger.LogNotification("INFO", "SENDING: Type=%s, Name='%s', ChatID=%d, Message='%s'", notificationType, birthday.Name, birthday.ChatID, message) From bb2499c0e089c4287d3529f8f57d130d9486a56f Mon Sep 17 00:00:00 2001 From: Vladimir nett00n Budylnikov Date: Tue, 16 Dec 2025 02:01:53 +0400 Subject: [PATCH 11/15] rm normalizeDate func --- internal/handlers/handlers.go | 28 ------------------------- internal/handlers/handlers_unit_test.go | 25 ---------------------- 2 files changed, 53 deletions(-) diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 0651a97..d15c134 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -135,34 +135,6 @@ func updateBirthdayFromForm(b *models.Birthday, r *http.Request) { } } -func normalizeDate(s string) string { - if s == "" { - return "" - } - - // Handle "MM-DD" format - convert to "0000-MM-DD" - if len(s) == 5 && strings.Count(s, "-") == 1 { - return "0000-" + s - } - - // Handle "YYYY-MM-DD" format - parsedDate, err := time.Parse("2006-01-02", s) - if err != nil { - return "" - } - - // For current year dates, store as "0000-MM-DD" (year unknown) - currentYear := time.Now().Year() - if parsedDate.Year() == currentYear { - month := parsedDate.Format("01") - day := parsedDate.Format("02") - return "0000-" + month + "-" + day - } - - // For other years, keep the full date - return s -} - func normalizeDateWithOriginal(s string, originalBirthDate string) string { if s == "" { return "" diff --git a/internal/handlers/handlers_unit_test.go b/internal/handlers/handlers_unit_test.go index 072daec..ce8a249 100644 --- a/internal/handlers/handlers_unit_test.go +++ b/internal/handlers/handlers_unit_test.go @@ -5,34 +5,9 @@ import ( "5mdt/bd_bot/internal/models" "net/http" "net/url" - "strconv" "testing" - "time" ) -func TestNormalizeDate(t *testing.T) { - currentYear := time.Now().Year() - currentYearStr := strconv.Itoa(currentYear) - - tests := []struct { - in, want string - }{ - {"12-31", "0000-12-31"}, - {"2000-01-01", "2000-01-01"}, - {currentYearStr + "-03-15", "0000-03-15"}, // Current year should become year-unknown - {"1990-07-20", "1990-07-20"}, // Past year should stay as-is - {"invalid", ""}, - {"12345", ""}, - {"", ""}, - } - for _, tt := range tests { - got := normalizeDate(tt.in) - if got != tt.want { - t.Errorf("normalizeDate(%q) = %q; want %q", tt.in, got, tt.want) - } - } -} - func TestUpdateBirthdayFromForm(t *testing.T) { form := url.Values{ "name": {"Alice"}, From 985dbc80174e74e7b7b3b6715052ec43fd7c036b Mon Sep 17 00:00:00 2001 From: Vladimir nett00n Budylnikov Date: Tue, 16 Dec 2025 02:04:42 +0400 Subject: [PATCH 12/15] Added edge case tests --- internal/templates/funcs_test.go | 85 ++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/internal/templates/funcs_test.go b/internal/templates/funcs_test.go index 1819c74..c23e390 100644 --- a/internal/templates/funcs_test.go +++ b/internal/templates/funcs_test.go @@ -37,6 +37,36 @@ func TestFormatBirthDateForInput(t *testing.T) { input: "2020-02-29", expected: "2020-02-29", }, + { + name: "Unknown year boundary start (Jan 1)", + input: "0000-01-01", + expected: "2025-01-01", + }, + { + name: "Unknown year boundary end (Dec 31)", + input: "0000-12-31", + expected: "2025-12-31", + }, + { + name: "Empty string", + input: "", + expected: "", + }, + { + name: "Invalid format", + input: "invalid", + expected: "invalid", + }, + { + name: "Invalid month (13)", + input: "2025-13-01", + expected: "2025-13-01", + }, + { + name: "Invalid day (Feb 30)", + input: "2025-02-30", + expected: "2025-02-30", + }, } for _, tt := range tests { @@ -70,6 +100,36 @@ func TestFormatBirthDate(t *testing.T) { input: "1990-12-25", expected: "1990-12-25", }, + { + name: "Unknown year boundary start (Jan 1)", + input: "0000-01-01", + expected: "01-01", + }, + { + name: "Unknown year boundary end (Dec 31)", + input: "0000-12-31", + expected: "12-31", + }, + { + name: "Empty string", + input: "", + expected: "", + }, + { + name: "Invalid format", + input: "invalid", + expected: "invalid", + }, + { + name: "Invalid month (13)", + input: "2025-13-01", + expected: "2025-13-01", + }, + { + name: "Invalid day (Feb 30)", + input: "2025-02-30", + expected: "2025-02-30", + }, } for _, tt := range tests { @@ -103,6 +163,31 @@ func TestIsUnknownYear(t *testing.T) { input: "1990-12-25", expected: false, }, + { + name: "Unknown year boundary start (Jan 1)", + input: "0000-01-01", + expected: true, + }, + { + name: "Unknown year boundary end (Dec 31)", + input: "0000-12-31", + expected: true, + }, + { + name: "Empty string", + input: "", + expected: false, + }, + { + name: "Invalid format", + input: "invalid", + expected: false, + }, + { + name: "Invalid date with 0000 prefix", + input: "0000-13-01", + expected: true, + }, } for _, tt := range tests { From 0163dae2a97b654eb2aafafae7194df03dde3e13 Mon Sep 17 00:00:00 2001 From: Vladimir nett00n Budylnikov Date: Tue, 16 Dec 2025 02:06:27 +0400 Subject: [PATCH 13/15] reduce nesting --- internal/storage/storage.go | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 7f382ec..77e601a 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -19,6 +19,14 @@ func getPath() string { return "/data/birthdays.yaml" } +// ensureParentDir creates the parent directory for the given file path if it doesn't exist. +func ensureParentDir(filePath string) error { + if dir := filepath.Dir(filePath); dir != "" && dir != "." { + return os.MkdirAll(dir, 0755) + } + return nil +} + // LoadBirthdays reads and parses birthday data from the configured YAML file. // It creates an empty file (and parent directories) if it doesn't exist. // Returns a nil slice and error on failure. @@ -26,10 +34,8 @@ func LoadBirthdays() ([]models.Birthday, error) { filePath := getPath() if _, err := os.Stat(filePath); os.IsNotExist(err) { // Ensure parent directory exists - if dir := filepath.Dir(filePath); dir != "" && dir != "." { - if err := os.MkdirAll(dir, 0755); err != nil { - return nil, err - } + if err := ensureParentDir(filePath); err != nil { + return nil, err } if err := os.WriteFile(filePath, []byte("[]\n"), filePerm); err != nil { return nil, err @@ -53,10 +59,8 @@ func SaveBirthdays(bs []models.Birthday) error { } // Ensure parent directory exists - if dir := filepath.Dir(filePath); dir != "" && dir != "." { - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } + if err := ensureParentDir(filePath); err != nil { + return err } return os.WriteFile(filePath, data, filePerm) From d3899fb05e49ad4e2b88887979d643f6cb710218 Mon Sep 17 00:00:00 2001 From: Vladimir nett00n Budylnikov Date: Tue, 16 Dec 2025 02:16:38 +0400 Subject: [PATCH 14/15] more errors --- internal/handlers/handlers.go | 30 +++++++--- internal/handlers/handlers_unit_test.go | 75 ++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index d15c134..a09a15f 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -111,28 +111,32 @@ func parseIdx(r *http.Request) (int, error) { return strconv.Atoi(r.FormValue("idx")) } -func updateBirthdayFromForm(b *models.Birthday, r *http.Request) { +func updateBirthdayFromForm(b *models.Birthday, r *http.Request) error { originalBirthDate := b.BirthDate b.Name = r.FormValue("name") b.BirthDate = normalizeDateWithOriginal(r.FormValue("birth_date"), originalBirthDate) // Parse timestamp from form if timestampStr := r.FormValue("last_notification"); timestampStr != "" { - if timestamp, err := time.Parse(time.RFC3339, timestampStr); err == nil { - b.LastNotification = timestamp.UTC() - } else { + timestamp, err := time.Parse(time.RFC3339, timestampStr) + if err != nil { logger.Error("HANDLERS", "Failed to parse last_notification '%s': %v", timestampStr, err) + return fmt.Errorf("invalid last_notification format: %w", err) } + b.LastNotification = timestamp.UTC() } chatIDStr := r.FormValue("chat_id") if chatIDStr != "" { - if id, err := strconv.ParseInt(chatIDStr, 10, 64); err == nil { - b.ChatID = id - } else { + id, err := strconv.ParseInt(chatIDStr, 10, 64) + if err != nil { logger.Error("HANDLERS", "Failed to parse chat_id '%s': %v", chatIDStr, err) + return fmt.Errorf("invalid chat_id format: %w", err) } + b.ChatID = id } + + return nil } func normalizeDateWithOriginal(s string, originalBirthDate string) string { @@ -240,7 +244,11 @@ func SaveRowHandler(tpl *template.Template) http.HandlerFunc { if idx == -1 { b := models.Birthday{} - updateBirthdayFromForm(&b, r) + if err := updateBirthdayFromForm(&b, r); err != nil { + logger.Error("HANDLERS", "updateBirthdayFromForm error: %v", err) + http.Error(w, "Invalid form data: "+err.Error(), 400) + return + } bs = append(bs, b) } else { if idx < 0 || idx >= len(bs) { @@ -248,7 +256,11 @@ func SaveRowHandler(tpl *template.Template) http.HandlerFunc { http.Error(w, "Invalid idx", 400) return } - updateBirthdayFromForm(&bs[idx], r) + if err := updateBirthdayFromForm(&bs[idx], r); err != nil { + logger.Error("HANDLERS", "updateBirthdayFromForm error: %v", err) + http.Error(w, "Invalid form data: "+err.Error(), 400) + return + } } if err := storage.SaveBirthdays(bs); err != nil { diff --git a/internal/handlers/handlers_unit_test.go b/internal/handlers/handlers_unit_test.go index ce8a249..6e0e053 100644 --- a/internal/handlers/handlers_unit_test.go +++ b/internal/handlers/handlers_unit_test.go @@ -19,7 +19,10 @@ func TestUpdateBirthdayFromForm(t *testing.T) { req.Form = form b := &models.Birthday{} - updateBirthdayFromForm(b, req) + err := updateBirthdayFromForm(b, req) + if err != nil { + t.Fatalf("updateBirthdayFromForm returned unexpected error: %v", err) + } if b.Name != "Alice" { t.Errorf("Name = %q; want Alice", b.Name) @@ -35,3 +38,73 @@ func TestUpdateBirthdayFromForm(t *testing.T) { t.Errorf("ChatID = %d; want 123", b.ChatID) } } + +func TestUpdateBirthdayFromForm_InvalidTimestamp(t *testing.T) { + form := url.Values{ + "name": {"Alice"}, + "birth_date": {"12-31"}, + "last_notification": {"invalid-timestamp"}, + "chat_id": {"123"}, + } + req, _ := http.NewRequest("POST", "/", nil) + req.Form = form + + b := &models.Birthday{} + err := updateBirthdayFromForm(b, req) + if err == nil { + t.Error("Expected error for invalid timestamp, got nil") + } + if err != nil && err.Error() != "invalid last_notification format: parsing time \"invalid-timestamp\" as \"2006-01-02T15:04:05Z07:00\": cannot parse \"invalid-timestamp\" as \"2006\"" { + t.Logf("Got error: %v", err) + } +} + +func TestUpdateBirthdayFromForm_InvalidChatID(t *testing.T) { + form := url.Values{ + "name": {"Alice"}, + "birth_date": {"12-31"}, + "last_notification": {"2024-01-01T15:30:00Z"}, + "chat_id": {"not-a-number"}, + } + req, _ := http.NewRequest("POST", "/", nil) + req.Form = form + + b := &models.Birthday{} + err := updateBirthdayFromForm(b, req) + if err == nil { + t.Error("Expected error for invalid chat_id, got nil") + } + if err != nil && err.Error() != "invalid chat_id format: strconv.ParseInt: parsing \"not-a-number\": invalid syntax" { + t.Logf("Got error: %v", err) + } +} + +func TestUpdateBirthdayFromForm_EmptyOptionalFields(t *testing.T) { + form := url.Values{ + "name": {"Alice"}, + "birth_date": {"12-31"}, + "last_notification": {""}, + "chat_id": {""}, + } + req, _ := http.NewRequest("POST", "/", nil) + req.Form = form + + b := &models.Birthday{} + err := updateBirthdayFromForm(b, req) + if err != nil { + t.Fatalf("updateBirthdayFromForm returned unexpected error for empty optional fields: %v", err) + } + + if b.Name != "Alice" { + t.Errorf("Name = %q; want Alice", b.Name) + } + if b.BirthDate != "0000-12-31" { + t.Errorf("BirthDate = %q; want 0000-12-31", b.BirthDate) + } + if !b.LastNotification.IsZero() { + t.Errorf("LastNotification should be zero for empty input, got %v", b.LastNotification) + } + if b.ChatID != 0 { + t.Errorf("ChatID should be 0 for empty input, got %d", b.ChatID) + } +} From c61b9d2a3ec0b86cf42438bbb9630d40e3540269 Mon Sep 17 00:00:00 2001 From: Vladimir nett00n Budylnikov Date: Tue, 16 Dec 2025 02:31:09 +0400 Subject: [PATCH 15/15] 2025-12-16 02:31:09+04:00 --- internal/handlers/handlers_unit_test.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/handlers/handlers_unit_test.go b/internal/handlers/handlers_unit_test.go index 6e0e053..87a701f 100644 --- a/internal/handlers/handlers_unit_test.go +++ b/internal/handlers/handlers_unit_test.go @@ -5,6 +5,7 @@ import ( "5mdt/bd_bot/internal/models" "net/http" "net/url" + "strings" "testing" ) @@ -52,10 +53,10 @@ func TestUpdateBirthdayFromForm_InvalidTimestamp(t *testing.T) { b := &models.Birthday{} err := updateBirthdayFromForm(b, req) if err == nil { - t.Error("Expected error for invalid timestamp, got nil") + t.Fatal("Expected error for invalid timestamp, got nil") } - if err != nil && err.Error() != "invalid last_notification format: parsing time \"invalid-timestamp\" as \"2006-01-02T15:04:05Z07:00\": cannot parse \"invalid-timestamp\" as \"2006\"" { - t.Logf("Got error: %v", err) + if !strings.Contains(err.Error(), "invalid last_notification format") { + t.Errorf("Expected error to contain 'invalid last_notification format', got: %v", err) } } @@ -72,10 +73,10 @@ func TestUpdateBirthdayFromForm_InvalidChatID(t *testing.T) { b := &models.Birthday{} err := updateBirthdayFromForm(b, req) if err == nil { - t.Error("Expected error for invalid chat_id, got nil") + t.Fatal("Expected error for invalid chat_id, got nil") } - if err != nil && err.Error() != "invalid chat_id format: strconv.ParseInt: parsing \"not-a-number\": invalid syntax" { - t.Logf("Got error: %v", err) + if !strings.Contains(err.Error(), "invalid chat_id format") { + t.Errorf("Expected error to contain 'invalid chat_id format', got: %v", err) } }