package service import ( "bytes" "encoding/json" "fmt" "log" "net/http" "strings" "time" "homework-manager/internal/database" "homework-manager/internal/models" ) type NotificationService struct { telegramBotToken string } func NewNotificationService(telegramBotToken string) *NotificationService { return &NotificationService{ telegramBotToken: telegramBotToken, } } func (s *NotificationService) GetUserSettings(userID uint) (*models.UserNotificationSettings, error) { var settings models.UserNotificationSettings result := database.GetDB().Where("user_id = ?", userID).First(&settings) if result.Error != nil { if result.RowsAffected == 0 { return &models.UserNotificationSettings{ UserID: userID, }, nil } return nil, result.Error } return &settings, nil } func (s *NotificationService) UpdateUserSettings(userID uint, settings *models.UserNotificationSettings) error { settings.UserID = userID var existing models.UserNotificationSettings result := database.GetDB().Where("user_id = ?", userID).First(&existing) if result.RowsAffected == 0 { return database.GetDB().Create(settings).Error } settings.ID = existing.ID return database.GetDB().Save(settings).Error } // InlineKeyboardMarkup represents a Telegram inline keyboard attached to a message. type InlineKeyboardMarkup struct { InlineKeyboard [][]InlineKeyboardButton `json:"inline_keyboard"` } // InlineKeyboardButton is a single button in an inline keyboard row. type InlineKeyboardButton struct { Text string `json:"text"` CallbackData string `json:"callback_data"` } func (s *NotificationService) sendTelegramRequest(endpoint string, payload interface{}) error { if s.telegramBotToken == "" { return fmt.Errorf("telegram bot token is not configured") } apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/%s", s.telegramBotToken, endpoint) jsonPayload, err := json.Marshal(payload) if err != nil { return err } resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonPayload)) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("telegram API %s returned status %d", endpoint, resp.StatusCode) } return nil } func (s *NotificationService) SendTelegramNotification(chatID, message string) error { if chatID == "" { return fmt.Errorf("telegram chat ID is empty") } return s.SendMessageWithKeyboard(chatID, message, nil) } // SendMessageWithKeyboard sends a Telegram message with an optional inline keyboard. func (s *NotificationService) SendMessageWithKeyboard(chatID, text string, keyboard *InlineKeyboardMarkup) error { if chatID == "" { return fmt.Errorf("telegram chat ID is empty") } payload := map[string]interface{}{ "chat_id": chatID, "text": text, "parse_mode": "HTML", } if keyboard != nil { payload["reply_markup"] = keyboard } return s.sendTelegramRequest("sendMessage", payload) } // AnswerCallbackQuery acknowledges a Telegram inline button press. func (s *NotificationService) AnswerCallbackQuery(callbackID, text string) error { payload := map[string]interface{}{ "callback_query_id": callbackID, "show_alert": false, } if text != "" { payload["text"] = text } return s.sendTelegramRequest("answerCallbackQuery", payload) } // EditMessageRemoveKeyboard removes the inline keyboard from an existing message. func (s *NotificationService) EditMessageRemoveKeyboard(chatID string, messageID int64) error { payload := map[string]interface{}{ "chat_id": chatID, "message_id": messageID, "reply_markup": map[string]interface{}{"inline_keyboard": []interface{}{}}, } return s.sendTelegramRequest("editMessageReplyMarkup", payload) } // FindUserIDByChatID looks up a UserID by Telegram chat ID. func (s *NotificationService) FindUserIDByChatID(chatID string) (uint, error) { var settings models.UserNotificationSettings result := database.GetDB().Where("telegram_chat_id = ? AND telegram_enabled = ?", chatID, true).First(&settings) if result.Error != nil { return 0, fmt.Errorf("user not found for chat ID %s", chatID) } return settings.UserID, nil } func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *models.Assignment) error { settings, err := s.GetUserSettings(userID) if err != nil { return err } message := fmt.Sprintf( "๐Ÿ“š ่ชฒ้กŒใƒชใƒžใ‚คใƒณใƒ€ใƒผ\n\nใ€%sใ€‘\n็ง‘็›ฎ: %s\nๆœŸ้™: %s\n\n%s", assignment.Title, assignment.Subject, assignment.DueDate.Format("2006/01/02 15:04"), assignment.Description, ) var errors []string if settings.TelegramEnabled && settings.TelegramChatID != "" { if err := s.SendTelegramNotification(settings.TelegramChatID, message); err != nil { errors = append(errors, fmt.Sprintf("Telegram: %v", err)) } } if len(errors) > 0 { return fmt.Errorf("notification errors: %s", strings.Join(errors, "; ")) } return nil } func (s *NotificationService) SendAssignmentCreatedNotification(userID uint, assignment *models.Assignment) error { settings, err := s.GetUserSettings(userID) if err != nil { return err } if !settings.NotifyOnCreate { return nil } if !settings.TelegramEnabled { return nil } message := fmt.Sprintf( "ๆ–ฐใ—ใ„่ชฒ้กŒใŒ่ฟฝๅŠ ใ•ใ‚Œใพใ—ใŸ\n\nใ€%sใ€‘\n็ง‘็›ฎ: %s\nๅ„ชๅ…ˆๅบฆ: %s\nๆœŸ้™: %s\n\n%s", assignment.Title, assignment.Subject, getPriorityLabel(assignment.Priority), assignment.DueDate.Format("2006/01/02 15:04"), assignment.Description, ) var errors []string if settings.TelegramEnabled && settings.TelegramChatID != "" { if err := s.SendTelegramNotification(settings.TelegramChatID, message); err != nil { errors = append(errors, fmt.Sprintf("Telegram: %v", err)) } } if len(errors) > 0 { return fmt.Errorf("notification errors: %s", strings.Join(errors, "; ")) } return nil } func getPriorityLabel(priority string) string { switch priority { case "high": return "ๅคง" case "medium": return "ไธญ" case "low": return "ๅฐ" default: return priority } } func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models.Assignment) error { settings, err := s.GetUserSettings(userID) if err != nil { return err } softDue := assignment.GetEffectiveSoftDueDate() timeRemaining := time.Until(softDue) var timeStr string if timeRemaining < 0 { timeStr = "่‡ชๅˆ†ใฎๆœŸ้™ๅˆ‡ใ‚Œ๏ผ" } else if timeRemaining < time.Hour { timeStr = fmt.Sprintf("ใ‚ใจ%dๅˆ†", int(timeRemaining.Minutes())) } else { timeStr = fmt.Sprintf("ใ‚ใจ%dๆ™‚้–“%dๅˆ†", int(timeRemaining.Hours()), int(timeRemaining.Minutes())%60) } priorityEmoji := "๐Ÿ“Œ" switch assignment.Priority { case "high": priorityEmoji = "๐Ÿšจ" case "medium": priorityEmoji = "โš ๏ธ" case "low": priorityEmoji = "๐Ÿ“Œ" } message := fmt.Sprintf( "%s ็ฃไฟƒ้€š็Ÿฅ๏ผ\n\nใ€%sใ€‘\n็ง‘็›ฎ: %s\n่‡ชๅˆ†ใฎๆœŸ้™: %s (%s)\nใ‚ฌใƒๆœŸ้™: %s\n\nๅฎŒไบ†ใ—ใŸใ‚‰ใ‚ขใƒ—ใƒชใงๅฎŒไบ†ใƒœใ‚ฟใƒณใ‚’ๆŠผใ—ใฆใใ ใ•ใ„๏ผ", priorityEmoji, assignment.Title, assignment.Subject, softDue.Format("2006/01/02 15:04"), timeStr, assignment.DueDate.Format("2006/01/02 15:04"), ) keyboard := &InlineKeyboardMarkup{ InlineKeyboard: [][]InlineKeyboardButton{ { {Text: "โœ… ๅฎŒไบ†", CallbackData: fmt.Sprintf("done:%d", assignment.ID)}, {Text: "โฐ 15ๅˆ†ๅพŒ", CallbackData: fmt.Sprintf("snooze15:%d", assignment.ID)}, {Text: "๐Ÿ’ฌ ไปŠใ‚„ใฃใฆใ‚‹", CallbackData: fmt.Sprintf("working:%d", assignment.ID)}, }, }, } var errors []string if settings.TelegramEnabled && settings.TelegramChatID != "" { if err := s.SendMessageWithKeyboard(settings.TelegramChatID, message, keyboard); err != nil { errors = append(errors, fmt.Sprintf("Telegram: %v", err)) } } if len(errors) > 0 { return fmt.Errorf("notification errors: %s", strings.Join(errors, "; ")) } return nil } func getUrgentReminderInterval(priority string) time.Duration { switch priority { case "high": return 10 * time.Minute case "medium": return 30 * time.Minute case "low": return 60 * time.Minute default: return 30 * time.Minute } } func (s *NotificationService) ProcessPendingReminders() { now := time.Now() var assignments []models.Assignment result := database.GetDB().Where( "reminder_enabled = ? AND reminder_sent = ? AND reminder_at <= ? AND is_completed = ?", true, false, now, false, ).Find(&assignments) if result.Error != nil { log.Printf("Error fetching pending reminders: %v", result.Error) return } for _, assignment := range assignments { if err := s.SendAssignmentReminder(assignment.UserID, &assignment); err != nil { log.Printf("Error sending reminder for assignment %d: %v", assignment.ID, err) continue } database.GetDB().Model(&assignment).Update("reminder_sent", true) log.Printf("Sent reminder for assignment %d to user %d", assignment.ID, assignment.UserID) } } func (s *NotificationService) ProcessUrgentReminders() { now := time.Now() urgentStartTime := 3 * time.Hour var assignments []models.Assignment result := database.GetDB().Where( "urgent_reminder_enabled = ? AND is_completed = ? AND due_date > ?", true, false, now, ).Find(&assignments) if result.Error != nil { log.Printf("Error fetching urgent reminders: %v", result.Error) return } for _, assignment := range assignments { softDue := assignment.GetEffectiveSoftDueDate() timeUntilSoftDue := softDue.Sub(now) if timeUntilSoftDue > urgentStartTime { continue } interval := getUrgentReminderInterval(assignment.Priority) if assignment.LastUrgentReminderSent != nil { timeSinceLastReminder := now.Sub(*assignment.LastUrgentReminderSent) if timeSinceLastReminder < interval { continue } } if err := s.SendUrgentReminder(assignment.UserID, &assignment); err != nil { log.Printf("Error sending urgent reminder for assignment %d: %v", assignment.ID, err) continue } database.GetDB().Model(&assignment).Update("last_urgent_reminder_sent", now) log.Printf("Sent urgent reminder for assignment %d (priority: %s) to user %d", assignment.ID, assignment.Priority, assignment.UserID) } } func (s *NotificationService) StartReminderScheduler() { go func() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for range ticker.C { s.ProcessPendingReminders() s.ProcessUrgentReminders() } }() log.Println("Reminder scheduler started (one-time + urgent reminders)") }