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

424 lines
13 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
"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📌 <b>%s</b>", escapeHTML(title))
if subject != "" {
fmt.Fprintf(&sb, "\n科目: %s", escapeHTML(subject))
}
fmt.Fprintf(&sb, "\n優先度: %s\nガチ期限: %s\n自分の期限: %s2日前",
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 <b>#%d</b> %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 &lt;番号&gt;")
_ = 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 &lt;課題番号&gt;\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, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}
func helpText() string {
return `📖 <b>Super Homework Manager Bot</b>
<b>コマンド一覧</b>
/add &lt;タイトル&gt; &lt;日付&gt; [時刻] [#科目] [!優先度]
課題を追加します
/list
未完了課題を一覧表示最大5件
/done &lt;番号&gt;
課題を完了にします(/list の#番号)
/help
このメッセージを表示
<b>日付の書き方</b>
今日 / 明日 / 明後日
月 火 水 木 金 土 日
1/15 または 1月15日
<b>時刻の書き方</b>(省略時: 23:59
23:59 / 17時 / 17時30分
<b>オプション</b>
#科目名 → 例: #数学
!高 / !中 / !低 → 優先度(省略時: 中)
<b>例</b>
/add レポート 金曜 23:59 #英語 !高
/add 数学課題 明日
/add 情報演習 1/20 17時`
}
func addUsageShort() string {
return `使い方: /add &lt;タイトル&gt; &lt;日付&gt; [時刻] [#科目] [!優先度]
例:
/add レポート 金曜 23:59 #英語 !高
/add 数学課題 明日
/add 情報演習 1/20 17時
詳細: /help`
}