From bd600c24c91a75f522f9fd19e2c4373bba0f5edf Mon Sep 17 00:00:00 2001 From: furu04 Date: Sun, 7 Jun 2026 12:04:47 +0900 Subject: [PATCH] =?UTF-8?q?Telegram=20Webhook=E3=81=AB=E3=82=88=E3=82=8B?= =?UTF-8?q?=E3=83=9C=E3=83=83=E3=83=88=E6=93=8D=E4=BD=9C=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=EF=BC=88=E8=AA=B2=E9=A1=8C=E3=81=AE?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E3=83=BB=E5=AE=8C=E4=BA=86=E3=83=BB=E4=B8=80?= =?UTF-8?q?=E8=A6=A7=E3=83=BB=E3=82=B9=E3=83=8C=E3=83=BC=E3=82=BA=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/server/main.go | 4 + config.ini.docker.example | 1 + config.ini.example | 4 + docs/SPECIFICATION.md | 61 +++- internal/config/config.go | 9 +- internal/handler/telegram_handler.go | 57 +++ internal/router/router.go | 6 + internal/service/date_parser.go | 169 +++++++++ internal/service/notification_service.go | 98 +++++- internal/service/telegram_service.go | 423 +++++++++++++++++++++++ 10 files changed, 812 insertions(+), 20 deletions(-) create mode 100644 internal/handler/telegram_handler.go create mode 100644 internal/service/date_parser.go create mode 100644 internal/service/telegram_service.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 7e20ffc..c19b688 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -17,6 +17,10 @@ func main() { cfg := config.Load(*configPath) + if cfg.Notification.TelegramBotToken != "" && cfg.Notification.TelegramWebhookSecret == "" { + log.Fatal("telegram_bot_token is set but telegram_webhook_secret is not set — refusing to start with unauthenticated webhook endpoint") + } + log.Printf("Connecting to database (driver: %s)", cfg.Database.Driver) if err := database.Connect(cfg.Database, cfg.Debug); err != nil { log.Fatalf("Failed to connect to database: %v", err) diff --git a/config.ini.docker.example b/config.ini.docker.example index dcf6183..d994016 100644 --- a/config.ini.docker.example +++ b/config.ini.docker.example @@ -32,6 +32,7 @@ trusted_proxies = 172.16.0.0/12 [notification] telegram_bot_token = +; telegram_webhook_secret = your-webhook-secret [captcha] ; CAPTCHAを有効にするか (true/false) diff --git a/config.ini.example b/config.ini.example index c90a46d..55920bf 100644 --- a/config.ini.example +++ b/config.ini.example @@ -59,6 +59,10 @@ rate_limit_window = 60 ; ユーザーはプロフィール画面でChat IDを設定します telegram_bot_token = +; Telegram Webhook シークレットトークン(任意) +; setWebhook の secret_token に指定した値と一致させる +; telegram_webhook_secret = your-webhook-secret + [captcha] ; CAPTCHAを有効にするか (true/false) enabled = false diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md index 33f787b..b50d77d 100644 --- a/docs/SPECIFICATION.md +++ b/docs/SPECIFICATION.md @@ -226,8 +226,65 @@ REST API認証用のAPIキーを管理するモデル。 | 重要度「中」 | **30分**ごとに通知 | | 重要度「小」 | **60分**ごとに通知 | | 停止条件 | 課題の完了ボタンを押すまで継続 | +| Inline keyboard | 督促通知にボタンを付与: ✅完了 / ⏰15分後 / 💬今やってる | -#### 4.4.3 通知チャンネル +#### 4.4.3 Telegram Bot 双方向操作 + +Telegram Bot への直接メッセージで課題の登録・確認・完了が可能。 + +**エンドポイント**: `POST /api/telegram/webhook` + +**コマンド一覧**: + +| コマンド | 形式 | 説明 | +|----------|------|------| +| `/add` | `/add <タイトル> <日付> [時刻] [#科目] [!優先度]` | 課題を追加(登録後60秒以内は取り消しボタン付き) | +| `/list` | `/list` | 未完了課題を期限昇順で最大5件表示(全件数も表示) | +| `/done` | `/done ` | 課題を完了にする(IDは/listの#番号) | +| `/help` | `/help` | コマンド一覧と使い方を表示 | + +**日付パターン**: + +| 入力 | 解釈 | +|------|------| +| `今日` / `明日` / `明後日` | 当日・翌日・翌々日 | +| `月` `火` `水` `木` `金` `土` `日` | 次回その曜日(当日なら当日) | +| `1/15` / `1月15日` | 今年のその日(過去なら翌年) | + +時刻形式: `23:59` / `17時` / `17時30分`(省略時: `23:59`) + +**督促通知 inline keyboard ボタン動作**: + +| ボタン | 動作 | +|--------|------| +| ✅ 完了 | 課題を完了済みにし、キーボードを削除 | +| ⏰ 15分後 | 次回督促を15分後に延期 | +| 💬 今やってる | 次回督促を1時間後に延期 | + +**認証**: `X-Telegram-Bot-Api-Secret-Token` ヘッダーを `telegram_webhook_secret` と定数時間比較で検証する(必須・スキップ不可)。`telegram_bot_token` と `telegram_webhook_secret` の両方が設定されている場合のみ `POST /api/telegram/webhook` ルートが登録される。Telegramの個人チャットでは `chat.id` がユーザーIDと等しく秘匿性のない数値であるため、secretを必須化しないとchat_idを知る/推測する第三者が他人になりすましてコマンドを実行できてしまう。 + +**セットアップ手順** (初回のみ): + +1. **Bot作成**: Telegramで [@BotFather](https://t.me/BotFather) に `/newbot` を送り、Bot Token (`123456:ABC-...`) を取得します。 + +2. **config.ini に設定**: + ```ini + [notification] + telegram_bot_token = 123456:ABC-... + telegram_webhook_secret = 任意のランダム文字列 + ``` + +3. **Webhook登録** (サーバー起動後、一度だけ実行): + ``` + https://api.telegram.org/bot/setWebhook?url=https://your-domain/api/telegram/webhook&secret_token= + ``` + `` と `` は上記 config.ini の値と一致させてください。 + +4. **ユーザー側のChat ID登録**: ユーザーはBotに任意のメッセージを送り、アプリの **プロフィール画面 → 通知設定** で自分のChat IDを入力して有効化します。Chat IDの確認方法: [@userinfobot](https://t.me/userinfobot) に `/start` を送ると表示されます。 + +> **必須**: `telegram_bot_token` を設定する場合は `telegram_webhook_secret` も必ず設定してください。未設定のまま `telegram_bot_token` だけを設定して起動しようとすると、安全のため起動に失敗します(Webhookルート自体も登録されません)。 + +#### 4.4.4 通知チャンネル | チャンネル | 設定方法 | |------------|----------| @@ -329,6 +386,7 @@ type = image | `security` | `rate_limit_window` | 期間(秒) | `60` | | `security` | `trusted_proxies` | 信頼するプロキシ | - | | `notification` | `telegram_bot_token` | Telegram Bot Token | - | +| `notification` | `telegram_webhook_secret` | Webhookシークレットトークン | - | | `captcha` | `enabled` | CAPTCHA有効化 | `false` | | `captcha` | `type` | CAPTCHAタイプ (`image` or `turnstile`) | `image` | | `captcha` | `turnstile_site_key` | Cloudflare Turnstile サイトキー | - | @@ -355,6 +413,7 @@ type = image | `HTTPS` | HTTPSモード (`true`/`false`) | | `TRUSTED_PROXIES` | 信頼するプロキシ | | `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | +| `TELEGRAM_WEBHOOK_SECRET` | Webhookシークレットトークン | | `CAPTCHA_ENABLED` | CAPTCHA有効化 (`true`/`false`) | | `CAPTCHA_TYPE` | CAPTCHAタイプ (`image`/`turnstile`) | | `TURNSTILE_SITE_KEY` | Cloudflare Turnstile サイトキー | diff --git a/internal/config/config.go b/internal/config/config.go index f2a04ec..f93a9e3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,7 +18,8 @@ type DatabaseConfig struct { } type NotificationConfig struct { - TelegramBotToken string + TelegramBotToken string + TelegramWebhookSecret string } type CaptchaConfig struct { @@ -146,6 +147,9 @@ func Load(configPath string) *Config { if section.HasKey("telegram_bot_token") { cfg.Notification.TelegramBotToken = section.Key("telegram_bot_token").String() } + if section.HasKey("telegram_webhook_secret") { + cfg.Notification.TelegramWebhookSecret = section.Key("telegram_webhook_secret").String() + } // Captcha section section = iniFile.Section("captcha") @@ -210,6 +214,9 @@ func Load(configPath string) *Config { if telegramToken := os.Getenv("TELEGRAM_BOT_TOKEN"); telegramToken != "" { cfg.Notification.TelegramBotToken = telegramToken } + if webhookSecret := os.Getenv("TELEGRAM_WEBHOOK_SECRET"); webhookSecret != "" { + cfg.Notification.TelegramWebhookSecret = webhookSecret + } if captchaEnabled := os.Getenv("CAPTCHA_ENABLED"); captchaEnabled != "" { cfg.Captcha.Enabled = captchaEnabled == "true" || captchaEnabled == "1" } diff --git a/internal/handler/telegram_handler.go b/internal/handler/telegram_handler.go new file mode 100644 index 0000000..9dcbeda --- /dev/null +++ b/internal/handler/telegram_handler.go @@ -0,0 +1,57 @@ +package handler + +import ( + "crypto/subtle" + "log" + "net/http" + + "homework-manager/internal/service" + + "github.com/gin-gonic/gin" +) + +// TelegramHandler handles incoming Telegram webhook requests. +type TelegramHandler struct { + telegramService *service.TelegramService + webhookSecret string +} + +// NewTelegramHandler creates a TelegramHandler. +func NewTelegramHandler(telegramService *service.TelegramService, webhookSecret string) *TelegramHandler { + return &TelegramHandler{ + telegramService: telegramService, + webhookSecret: webhookSecret, + } +} + +// Webhook is the endpoint registered as the Telegram bot webhook. +// POST /api/telegram/webhook +func (h *TelegramHandler) Webhook(c *gin.Context) { + if h.webhookSecret == "" { + c.Status(http.StatusServiceUnavailable) + return + } + secret := c.GetHeader("X-Telegram-Bot-Api-Secret-Token") + if subtle.ConstantTimeCompare([]byte(secret), []byte(h.webhookSecret)) != 1 { + c.Status(http.StatusUnauthorized) + return + } + + var update service.TelegramUpdate + if err := c.ShouldBindJSON(&update); err != nil { + c.Status(http.StatusBadRequest) + return + } + + // Process asynchronously so Telegram gets a 200 within its timeout window. + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("telegram HandleUpdate panic: %v", r) + } + }() + h.telegramService.HandleUpdate(update) + }() + + c.Status(http.StatusOK) +} diff --git a/internal/router/router.go b/internal/router/router.go index bf23958..be67326 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -221,6 +221,12 @@ func Setup(cfg *config.Config) *gin.Engine { apiHandler := handler.NewAPIHandler() apiRecurringHandler := handler.NewAPIRecurringHandler() + if cfg.Notification.TelegramBotToken != "" && cfg.Notification.TelegramWebhookSecret != "" { + telegramService := service.NewTelegramService(notificationService, service.NewAssignmentService()) + telegramHandler := handler.NewTelegramHandler(telegramService, cfg.Notification.TelegramWebhookSecret) + r.POST("/api/telegram/webhook", telegramHandler.Webhook) + } + r.GET("/captcha/:file", gin.WrapH(captcha.Server(captcha.StdWidth, captcha.StdHeight))) r.GET("/captcha-new", func(c *gin.Context) { id := captcha.New() diff --git a/internal/service/date_parser.go b/internal/service/date_parser.go new file mode 100644 index 0000000..4f5dd2f --- /dev/null +++ b/internal/service/date_parser.go @@ -0,0 +1,169 @@ +package service + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" +) + +var ( + reTimeHHMM = regexp.MustCompile(`^(\d{1,2}):(\d{2})$`) + reTimeHHJMM = regexp.MustCompile(`^(\d{1,2})時(\d{1,2})分$`) + reTimeHHJ = regexp.MustCompile(`^(\d{1,2})時$`) + reDateMD = regexp.MustCompile(`^(\d{1,2})/(\d{1,2})$`) + reDateMJDJ = regexp.MustCompile(`^(\d{1,2})月(\d{1,2})日$`) +) + +var weekdayNames = map[string]time.Weekday{ + "月": time.Monday, + "火": time.Tuesday, + "水": time.Wednesday, + "木": time.Thursday, + "金": time.Friday, + "土": time.Saturday, + "日": time.Sunday, +} + +// ParseAddCommand parses the arguments after /add. +// Format: <date> [time] [#subject] [!priority] +// Returns title, dueDate, subject, priority (Go string: "high"/"medium"/"low"), error. +// On format error, err.Error() == "usage" means show usage hint. +func ParseAddCommand(text string) (title string, dueDate time.Time, subject string, priority string, err error) { + priority = "medium" + tokens := strings.Fields(text) + if len(tokens) == 0 { + err = fmt.Errorf("usage") + return + } + + // Extract #subject and !priority modifiers + var core []string + for _, tok := range tokens { + switch { + case strings.HasPrefix(tok, "#") && len(tok) > 1: + subject = tok[1:] + case strings.HasPrefix(tok, "!") && len(tok) > 1: + priority = parsePriorityJP(tok[1:]) + default: + core = append(core, tok) + } + } + + if len(core) < 2 { + err = fmt.Errorf("usage") + return + } + + // Extract optional time token from end + hour, minute := 23, 59 + if h, m, ok := parseTimeToken(core[len(core)-1]); ok { + hour, minute = h, m + core = core[:len(core)-1] + } + + if len(core) < 2 { + err = fmt.Errorf("usage") + return + } + + // Extract date token from end + dateTok := core[len(core)-1] + parsedDate, ok := parseDateToken(dateTok, hour, minute) + if !ok { + err = fmt.Errorf("日付が読み取れませんでした: %q\n使える形式: 今日/明日/明後日/月〜日/MM/DD/M月D日", dateTok) + return + } + dueDate = parsedDate + core = core[:len(core)-1] + + if len(core) == 0 { + err = fmt.Errorf("usage") + return + } + title = strings.Join(core, " ") + return +} + +func parsePriorityJP(s string) string { + switch s { + case "高": + return "high" + case "低": + return "low" + default: + return "medium" + } +} + +func parseTimeToken(s string) (hour, minute int, ok bool) { + if m := reTimeHHMM.FindStringSubmatch(s); m != nil { + h, _ := strconv.Atoi(m[1]) + min, _ := strconv.Atoi(m[2]) + if h <= 23 && min <= 59 { + return h, min, true + } + } + if m := reTimeHHJMM.FindStringSubmatch(s); m != nil { + h, _ := strconv.Atoi(m[1]) + min, _ := strconv.Atoi(m[2]) + if h <= 23 && min <= 59 { + return h, min, true + } + } + if m := reTimeHHJ.FindStringSubmatch(s); m != nil { + h, _ := strconv.Atoi(m[1]) + if h <= 23 { + return h, 0, true + } + } + return 0, 0, false +} + +func parseDateToken(s string, hour, minute int) (time.Time, bool) { + now := time.Now() + loc := now.Location() + + switch s { + case "今日": + return time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, loc), true + case "明日": + d := now.AddDate(0, 0, 1) + return time.Date(d.Year(), d.Month(), d.Day(), hour, minute, 0, 0, loc), true + case "明後日": + d := now.AddDate(0, 0, 2) + return time.Date(d.Year(), d.Month(), d.Day(), hour, minute, 0, 0, loc), true + } + + if wd, ok := weekdayNames[s]; ok { + d := nextWeekdayFrom(now, wd) + return time.Date(d.Year(), d.Month(), d.Day(), hour, minute, 0, 0, loc), true + } + + var month, day int + if m := reDateMD.FindStringSubmatch(s); m != nil { + month, _ = strconv.Atoi(m[1]) + day, _ = strconv.Atoi(m[2]) + } else if m := reDateMJDJ.FindStringSubmatch(s); m != nil { + month, _ = strconv.Atoi(m[1]) + day, _ = strconv.Atoi(m[2]) + } else { + return time.Time{}, false + } + + if month < 1 || month > 12 || day < 1 || day > 31 { + return time.Time{}, false + } + t := time.Date(now.Year(), time.Month(month), day, hour, minute, 0, 0, loc) + if t.Before(now) { + t = t.AddDate(1, 0, 0) + } + return t, true +} + +// nextWeekdayFrom returns the date of the next occurrence of wd from 'from'. +func nextWeekdayFrom(from time.Time, wd time.Weekday) time.Time { + days := (int(wd) - int(from.Weekday()) + 7) % 7 + return from.AddDate(0, 0, days) +} diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index e74d3d0..d81836f 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -50,40 +50,92 @@ func (s *NotificationService) UpdateUserSettings(userID uint, settings *models.U settings.ID = existing.ID return database.GetDB().Save(settings).Error } -func (s *NotificationService) SendTelegramNotification(chatID, message string) error { +// InlineKeyboardMarkup represents a Telegram inline keyboard attached to a message. +type InlineKeyboardMarkup struct { + InlineKeyboard [][]InlineKeyboardButton `json:"inline_keyboard"` +} + +// InlineKeyboardButton is a single button in an inline keyboard row. +type InlineKeyboardButton struct { + Text string `json:"text"` + CallbackData string `json:"callback_data"` +} + +func (s *NotificationService) sendTelegramRequest(endpoint string, payload interface{}) error { if s.telegramBotToken == "" { return fmt.Errorf("telegram bot token is not configured") } - if chatID == "" { - return fmt.Errorf("telegram chat ID is empty") - } - - apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", s.telegramBotToken) - - payload := map[string]string{ - "chat_id": chatID, - "text": message, - "parse_mode": "HTML", - } - + apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/%s", s.telegramBotToken, endpoint) jsonPayload, err := json.Marshal(payload) if err != nil { return err } - resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonPayload)) if err != nil { return err } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("telegram API returned status %d", resp.StatusCode) + return fmt.Errorf("telegram API %s returned status %d", endpoint, resp.StatusCode) } - return nil } +func (s *NotificationService) SendTelegramNotification(chatID, message string) error { + if chatID == "" { + return fmt.Errorf("telegram chat ID is empty") + } + return s.SendMessageWithKeyboard(chatID, message, nil) +} + +// SendMessageWithKeyboard sends a Telegram message with an optional inline keyboard. +func (s *NotificationService) SendMessageWithKeyboard(chatID, text string, keyboard *InlineKeyboardMarkup) error { + if chatID == "" { + return fmt.Errorf("telegram chat ID is empty") + } + payload := map[string]interface{}{ + "chat_id": chatID, + "text": text, + "parse_mode": "HTML", + } + if keyboard != nil { + payload["reply_markup"] = keyboard + } + return s.sendTelegramRequest("sendMessage", payload) +} + +// AnswerCallbackQuery acknowledges a Telegram inline button press. +func (s *NotificationService) AnswerCallbackQuery(callbackID, text string) error { + payload := map[string]interface{}{ + "callback_query_id": callbackID, + "show_alert": false, + } + if text != "" { + payload["text"] = text + } + return s.sendTelegramRequest("answerCallbackQuery", payload) +} + +// EditMessageRemoveKeyboard removes the inline keyboard from an existing message. +func (s *NotificationService) EditMessageRemoveKeyboard(chatID string, messageID int64) error { + payload := map[string]interface{}{ + "chat_id": chatID, + "message_id": messageID, + "reply_markup": map[string]interface{}{"inline_keyboard": []interface{}{}}, + } + return s.sendTelegramRequest("editMessageReplyMarkup", payload) +} + +// FindUserIDByChatID looks up a UserID by Telegram chat ID. +func (s *NotificationService) FindUserIDByChatID(chatID string) (uint, error) { + var settings models.UserNotificationSettings + result := database.GetDB().Where("telegram_chat_id = ? AND telegram_enabled = ?", chatID, true).First(&settings) + if result.Error != nil { + return 0, fmt.Errorf("user not found for chat ID %s", chatID) + } + return settings.UserID, nil +} + func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *models.Assignment) error { settings, err := s.GetUserSettings(userID) if err != nil { @@ -201,10 +253,20 @@ func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models assignment.DueDate.Format("2006/01/02 15:04"), ) + keyboard := &InlineKeyboardMarkup{ + InlineKeyboard: [][]InlineKeyboardButton{ + { + {Text: "✅ 完了", CallbackData: fmt.Sprintf("done:%d", assignment.ID)}, + {Text: "⏰ 15分後", CallbackData: fmt.Sprintf("snooze15:%d", assignment.ID)}, + {Text: "💬 今やってる", CallbackData: fmt.Sprintf("working:%d", assignment.ID)}, + }, + }, + } + var errors []string if settings.TelegramEnabled && settings.TelegramChatID != "" { - if err := s.SendTelegramNotification(settings.TelegramChatID, message); err != nil { + if err := s.SendMessageWithKeyboard(settings.TelegramChatID, message, keyboard); err != nil { errors = append(errors, fmt.Sprintf("Telegram: %v", err)) } } diff --git a/internal/service/telegram_service.go b/internal/service/telegram_service.go new file mode 100644 index 0000000..25e15aa --- /dev/null +++ b/internal/service/telegram_service.go @@ -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` +}