Telegram Webhookによるボット操作機能を追加(課題の追加・完了・一覧・スヌーズ対応)

This commit is contained in:
2026-06-07 12:04:47 +09:00
parent c7f4c40964
commit bd600c24c9
10 changed files with 812 additions and 20 deletions

View File

@@ -0,0 +1,423 @@
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`
}