424 lines
13 KiB
Go
424 lines
13 KiB
Go
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自分の期限: %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 <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 <番号>")
|
||
_ = 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 `📖 <b>Super Homework Manager Bot</b>
|
||
|
||
<b>コマンド一覧</b>
|
||
|
||
/add <タイトル> <日付> [時刻] [#科目] [!優先度]
|
||
課題を追加します
|
||
|
||
/list
|
||
未完了課題を一覧表示(最大5件)
|
||
|
||
/done <番号>
|
||
課題を完了にします(/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 <タイトル> <日付> [時刻] [#科目] [!優先度]
|
||
|
||
例:
|
||
/add レポート 金曜 23:59 #英語 !高
|
||
/add 数学課題 明日
|
||
/add 情報演習 1/20 17時
|
||
|
||
詳細: /help`
|
||
}
|