package service import ( "fmt" "log" "sort" "strconv" "strings" "time" "homework-manager/internal/database" ) // TelegramUpdate is the top-level object sent by Telegram to the webhook. type TelegramUpdate struct { UpdateID int64 `json:"update_id"` Message *TelegramMessage `json:"message,omitempty"` CallbackQuery *TelegramCallback `json:"callback_query,omitempty"` } // TelegramMessage is an incoming text message from a user. type TelegramMessage struct { MessageID int64 `json:"message_id"` From TelegramUser `json:"from"` Chat TelegramChat `json:"chat"` Text string `json:"text"` } // TelegramChat holds the chat metadata. type TelegramChat struct { ID int64 `json:"id"` Type string `json:"type"` } // TelegramUser holds basic sender information. type TelegramUser struct { ID int64 `json:"id"` FirstName string `json:"first_name"` } // TelegramCallback is sent when the user presses an inline keyboard button. type TelegramCallback struct { ID string `json:"id"` From TelegramUser `json:"from"` Message *TelegramMessage `json:"message,omitempty"` Data string `json:"data"` } // TelegramService handles incoming Telegram updates and drives bot interactions. type TelegramService struct { notifService *NotificationService assignmentService *AssignmentService } // NewTelegramService creates a TelegramService. func NewTelegramService(notifService *NotificationService, assignmentService *AssignmentService) *TelegramService { return &TelegramService{ notifService: notifService, assignmentService: assignmentService, } } // HandleUpdate routes an incoming Telegram update to the appropriate handler. func (s *TelegramService) HandleUpdate(update TelegramUpdate) { switch { case update.Message != nil: s.handleMessage(update.Message) case update.CallbackQuery != nil: s.handleCallbackQuery(update.CallbackQuery) } } // --- Message handlers --- func (s *TelegramService) handleMessage(msg *TelegramMessage) { chatID := strconv.FormatInt(msg.Chat.ID, 10) text := strings.TrimSpace(msg.Text) if text == "" { return } userID, err := s.notifService.FindUserIDByChatID(chatID) if err != nil { _ = s.notifService.SendTelegramNotification(chatID, "このBotはSuper Homework Managerと連携されていません。\nアプリのプロフィール画面でChat IDを登録してください。") return } parts := strings.SplitN(text, " ", 2) cmd := strings.ToLower(parts[0]) args := "" if len(parts) > 1 { args = strings.TrimSpace(parts[1]) } switch cmd { case "/add": s.handleAdd(chatID, userID, args) case "/list": s.handleList(chatID, userID) case "/done": s.handleDone(chatID, userID, args) case "/help", "/start": s.handleHelp(chatID) default: _ = s.notifService.SendTelegramNotification(chatID, fmt.Sprintf("不明なコマンドです: %s\n\n%s", escapeHTML(cmd), addUsageShort())) } } func (s *TelegramService) handleAdd(chatID string, userID uint, args string) { if args == "" { _ = s.notifService.SendTelegramNotification(chatID, helpText()) return } title, dueDate, subject, priority, err := ParseAddCommand(args) if err != nil { if err.Error() == "usage" { _ = s.notifService.SendTelegramNotification(chatID, addUsageShort()) return } _ = s.notifService.SendTelegramNotification(chatID, fmt.Sprintf("❌ %s\n\n%s", err.Error(), addUsageShort())) return } assignment, err := s.assignmentService.Create( userID, title, "", subject, priority, dueDate, nil, false, nil, true, ) if err != nil { log.Printf("telegram /add error userID=%d: %v", userID, err) _ = s.notifService.SendTelegramNotification(chatID, "❌ 課題の登録に失敗しました。") return } softDue := assignment.GetEffectiveSoftDueDate() priorityLabel := map[string]string{"high": "高", "medium": "中", "low": "低"}[priority] var sb strings.Builder fmt.Fprintf(&sb, "✅ 課題を登録しました\n\n📌 %s", escapeHTML(title)) if subject != "" { fmt.Fprintf(&sb, "\n科目: %s", escapeHTML(subject)) } fmt.Fprintf(&sb, "\n優先度: %s\nガチ期限: %s\n自分の期限: %s(2日前)", priorityLabel, dueDate.Format("2006/01/02 15:04"), softDue.Format("2006/01/02 15:04"), ) keyboard := &InlineKeyboardMarkup{ InlineKeyboard: [][]InlineKeyboardButton{ {{Text: "🗑 取り消す", CallbackData: fmt.Sprintf("undo:%d", assignment.ID)}}, }, } _ = s.notifService.SendMessageWithKeyboard(chatID, sb.String(), keyboard) } func (s *TelegramService) handleList(chatID string, userID uint) { assignments, err := s.assignmentService.GetPendingByUser(userID) if err != nil { _ = s.notifService.SendTelegramNotification(chatID, "❌ 課題一覧の取得に失敗しました。") return } if len(assignments) == 0 { _ = s.notifService.SendTelegramNotification(chatID, "✨ 未完了の課題はありません!") return } totalCount := len(assignments) sort.Slice(assignments, func(i, j int) bool { return assignments[i].DueDate.Before(assignments[j].DueDate) }) if len(assignments) > 5 { assignments = assignments[:5] } var sb strings.Builder if totalCount > 5 { fmt.Fprintf(&sb, "📋 未完了課題(%d件中上位5件)\n\n", totalCount) } else { fmt.Fprintf(&sb, "📋 未完了課題(%d件)\n\n", totalCount) } for _, a := range assignments { fmt.Fprintf(&sb, "%s #%d %s\n", priorityEmoji(a.Priority), a.ID, escapeHTML(a.Title)) if a.Subject != "" { fmt.Fprintf(&sb, " 科目: %s\n", escapeHTML(a.Subject)) } fmt.Fprintf(&sb, " %s %s\n\n", a.DueDate.Format("1/2 15:04"), formatTimeUntil(a.DueDate)) } sb.WriteString("完了: /done <番号>") _ = s.notifService.SendTelegramNotification(chatID, sb.String()) } func (s *TelegramService) handleDone(chatID string, userID uint, args string) { args = strings.TrimPrefix(strings.TrimSpace(args), "#") id, err := strconv.ParseUint(args, 10, 64) if err != nil || id == 0 { _ = s.notifService.SendTelegramNotification(chatID, "❌ 使い方: /done <課題番号>\n例: /done 42\n\n課題番号は /list で確認できます。") return } assignment, err := s.assignmentService.GetByID(userID, uint(id)) if err != nil { _ = s.notifService.SendTelegramNotification(chatID, "❌ 課題が見つかりませんでした。") return } if assignment.IsCompleted { _ = s.notifService.SendTelegramNotification(chatID, "ℹ️ この課題はすでに完了済みです。") return } if _, err := s.assignmentService.ToggleComplete(userID, uint(id)); err != nil { _ = s.notifService.SendTelegramNotification(chatID, "❌ 完了処理に失敗しました。") return } _ = s.notifService.SendTelegramNotification(chatID, fmt.Sprintf("✅ 完了!お疲れ様!\n\n【%s】", escapeHTML(assignment.Title))) } func (s *TelegramService) handleHelp(chatID string) { _ = s.notifService.SendTelegramNotification(chatID, helpText()) } // --- Callback query handlers --- // handleCallbackQuery processes inline button presses. func (s *TelegramService) handleCallbackQuery(cb *TelegramCallback) { chatID := strconv.FormatInt(cb.From.ID, 10) userID, err := s.notifService.FindUserIDByChatID(chatID) if err != nil { _ = s.notifService.AnswerCallbackQuery(cb.ID, "ユーザーが見つかりません") return } parts := strings.SplitN(cb.Data, ":", 2) if len(parts) != 2 { _ = s.notifService.AnswerCallbackQuery(cb.ID, "") return } action := parts[0] assignmentID, err := strconv.ParseUint(parts[1], 10, 64) if err != nil || assignmentID == 0 { _ = s.notifService.AnswerCallbackQuery(cb.ID, "") return } var msgID int64 if cb.Message != nil { msgID = cb.Message.MessageID } switch action { case "done": s.callbackDone(chatID, userID, uint(assignmentID), cb.ID, msgID) case "snooze15": s.callbackSnooze(chatID, userID, uint(assignmentID), 15, cb.ID, msgID) case "working": s.callbackSnooze(chatID, userID, uint(assignmentID), 60, cb.ID, msgID) case "undo": s.callbackUndo(chatID, userID, uint(assignmentID), cb.ID, msgID) default: _ = s.notifService.AnswerCallbackQuery(cb.ID, "") } } func (s *TelegramService) callbackDone(chatID string, userID uint, assignmentID uint, cbID string, msgID int64) { assignment, err := s.assignmentService.GetByID(userID, assignmentID) if err != nil { _ = s.notifService.AnswerCallbackQuery(cbID, "課題が見つかりません") return } if assignment.IsCompleted { _ = s.notifService.AnswerCallbackQuery(cbID, "すでに完了済みです") _ = s.notifService.EditMessageRemoveKeyboard(chatID, msgID) return } if _, err := s.assignmentService.ToggleComplete(userID, assignmentID); err != nil { _ = s.notifService.AnswerCallbackQuery(cbID, "エラーが発生しました") return } _ = s.notifService.AnswerCallbackQuery(cbID, "✅ 完了!お疲れ様!") _ = s.notifService.EditMessageRemoveKeyboard(chatID, msgID) } func (s *TelegramService) callbackSnooze(chatID string, userID uint, assignmentID uint, snoozeMin int, cbID string, msgID int64) { assignment, err := s.assignmentService.GetByID(userID, assignmentID) if err != nil { _ = s.notifService.AnswerCallbackQuery(cbID, "課題が見つかりません") return } if assignment.IsCompleted { _ = s.notifService.AnswerCallbackQuery(cbID, "すでに完了済みです") _ = s.notifService.EditMessageRemoveKeyboard(chatID, msgID) return } interval := getUrgentReminderInterval(assignment.Priority) // Set last_sent so that next fire happens after snoozeMin minutes. // Condition fires when: now - lastSent >= interval // We want it to fire at: T + snoozeMin // So set: lastSent = T + snoozeMin - interval newLastSent := time.Now().Add(time.Duration(snoozeMin)*time.Minute - interval) if err := database.GetDB().Model(assignment).Update("last_urgent_reminder_sent", newLastSent).Error; err != nil { _ = s.notifService.AnswerCallbackQuery(cbID, "エラーが発生しました") return } var label string if snoozeMin == 60 { label = "1時間後に通知します。頑張れ!" } else { label = fmt.Sprintf("%d分後に通知します", snoozeMin) } _ = s.notifService.AnswerCallbackQuery(cbID, label) _ = s.notifService.EditMessageRemoveKeyboard(chatID, msgID) } func (s *TelegramService) callbackUndo(chatID string, userID uint, assignmentID uint, cbID string, msgID int64) { assignment, err := s.assignmentService.GetByID(userID, assignmentID) if err != nil { _ = s.notifService.AnswerCallbackQuery(cbID, "課題が見つかりません") return } if time.Since(assignment.CreatedAt) > 60*time.Second { _ = s.notifService.AnswerCallbackQuery(cbID, "時間切れです(60秒以内のみ取り消し可能)") _ = s.notifService.EditMessageRemoveKeyboard(chatID, msgID) return } if err := s.assignmentService.Delete(userID, assignmentID); err != nil { _ = s.notifService.AnswerCallbackQuery(cbID, "エラーが発生しました") return } _ = s.notifService.AnswerCallbackQuery(cbID, "🗑 取り消しました") _ = s.notifService.EditMessageRemoveKeyboard(chatID, msgID) } // --- Text helpers --- func priorityEmoji(p string) string { switch p { case "high": return "🚨" case "medium": return "⚠️" default: return "📌" } } func formatTimeUntil(t time.Time) string { d := time.Until(t) if d < 0 { return "(期限切れ)" } if d < time.Hour { return fmt.Sprintf("(あと%d分)", int(d.Minutes())) } if d < 24*time.Hour { return fmt.Sprintf("(あと%d時間)", int(d.Hours())) } return fmt.Sprintf("(あと%d日)", int(d.Hours()/24)) } func escapeHTML(s string) string { s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") return s } func helpText() string { return `📖 Super Homework Manager Bot コマンド一覧 /add <タイトル> <日付> [時刻] [#科目] [!優先度] 課題を追加します /list 未完了課題を一覧表示(最大5件) /done <番号> 課題を完了にします(/list の#番号) /help このメッセージを表示 日付の書き方 今日 / 明日 / 明後日 月 火 水 木 金 土 日 1/15 または 1月15日 時刻の書き方(省略時: 23:59) 23:59 / 17時 / 17時30分 オプション #科目名 → 例: #数学 !高 / !中 / !低 → 優先度(省略時: 中) /add レポート 金曜 23:59 #英語 !高 /add 数学課題 明日 /add 情報演習 1/20 17時` } func addUsageShort() string { return `使い方: /add <タイトル> <日付> [時刻] [#科目] [!優先度] 例: /add レポート 金曜 23:59 #英語 !高 /add 数学課題 明日 /add 情報演習 1/20 17時 詳細: /help` }