Telegram Webhookによるボット操作機能を追加(課題の追加・完了・一覧・スヌーズ対応)
This commit is contained in:
423
internal/service/telegram_service.go
Normal file
423
internal/service/telegram_service.go
Normal 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自分の期限: %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`
|
||||
}
|
||||
Reference in New Issue
Block a user