Files
Super-HomeworkManager/internal/service/notification_service.go

374 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)")
}