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

@@ -17,6 +17,10 @@ func main() {
cfg := config.Load(*configPath) 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) log.Printf("Connecting to database (driver: %s)", cfg.Database.Driver)
if err := database.Connect(cfg.Database, cfg.Debug); err != nil { if err := database.Connect(cfg.Database, cfg.Debug); err != nil {
log.Fatalf("Failed to connect to database: %v", err) log.Fatalf("Failed to connect to database: %v", err)

View File

@@ -32,6 +32,7 @@ trusted_proxies = 172.16.0.0/12
[notification] [notification]
telegram_bot_token = telegram_bot_token =
; telegram_webhook_secret = your-webhook-secret
[captcha] [captcha]
; CAPTCHAを有効にするか (true/false) ; CAPTCHAを有効にするか (true/false)

View File

@@ -59,6 +59,10 @@ rate_limit_window = 60
; ユーザーはプロフィール画面でChat IDを設定します ; ユーザーはプロフィール画面でChat IDを設定します
telegram_bot_token = telegram_bot_token =
; Telegram Webhook シークレットトークン(任意)
; setWebhook の secret_token に指定した値と一致させる
; telegram_webhook_secret = your-webhook-secret
[captcha] [captcha]
; CAPTCHAを有効にするか (true/false) ; CAPTCHAを有効にするか (true/false)
enabled = false enabled = false

View File

@@ -226,8 +226,65 @@ REST API認証用のAPIキーを管理するモデル。
| 重要度「中」 | **30分**ごとに通知 | | 重要度「中」 | **30分**ごとに通知 |
| 重要度「小」 | **60分**ごとに通知 | | 重要度「小」 | **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>` | 課題を完了にする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<TOKEN>/setWebhook?url=https://your-domain/api/telegram/webhook&secret_token=<SECRET>
```
`<TOKEN>` と `<SECRET>` は上記 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` | `rate_limit_window` | 期間(秒) | `60` |
| `security` | `trusted_proxies` | 信頼するプロキシ | - | | `security` | `trusted_proxies` | 信頼するプロキシ | - |
| `notification` | `telegram_bot_token` | Telegram Bot Token | - | | `notification` | `telegram_bot_token` | Telegram Bot Token | - |
| `notification` | `telegram_webhook_secret` | Webhookシークレットトークン | - |
| `captcha` | `enabled` | CAPTCHA有効化 | `false` | | `captcha` | `enabled` | CAPTCHA有効化 | `false` |
| `captcha` | `type` | CAPTCHAタイプ (`image` or `turnstile`) | `image` | | `captcha` | `type` | CAPTCHAタイプ (`image` or `turnstile`) | `image` |
| `captcha` | `turnstile_site_key` | Cloudflare Turnstile サイトキー | - | | `captcha` | `turnstile_site_key` | Cloudflare Turnstile サイトキー | - |
@@ -355,6 +413,7 @@ type = image
| `HTTPS` | HTTPSモード (`true`/`false`) | | `HTTPS` | HTTPSモード (`true`/`false`) |
| `TRUSTED_PROXIES` | 信頼するプロキシ | | `TRUSTED_PROXIES` | 信頼するプロキシ |
| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | | `TELEGRAM_BOT_TOKEN` | Telegram Bot Token |
| `TELEGRAM_WEBHOOK_SECRET` | Webhookシークレットトークン |
| `CAPTCHA_ENABLED` | CAPTCHA有効化 (`true`/`false`) | | `CAPTCHA_ENABLED` | CAPTCHA有効化 (`true`/`false`) |
| `CAPTCHA_TYPE` | CAPTCHAタイプ (`image`/`turnstile`) | | `CAPTCHA_TYPE` | CAPTCHAタイプ (`image`/`turnstile`) |
| `TURNSTILE_SITE_KEY` | Cloudflare Turnstile サイトキー | | `TURNSTILE_SITE_KEY` | Cloudflare Turnstile サイトキー |

View File

@@ -19,6 +19,7 @@ type DatabaseConfig struct {
type NotificationConfig struct { type NotificationConfig struct {
TelegramBotToken string TelegramBotToken string
TelegramWebhookSecret string
} }
type CaptchaConfig struct { type CaptchaConfig struct {
@@ -146,6 +147,9 @@ func Load(configPath string) *Config {
if section.HasKey("telegram_bot_token") { if section.HasKey("telegram_bot_token") {
cfg.Notification.TelegramBotToken = section.Key("telegram_bot_token").String() 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 // Captcha section
section = iniFile.Section("captcha") section = iniFile.Section("captcha")
@@ -210,6 +214,9 @@ func Load(configPath string) *Config {
if telegramToken := os.Getenv("TELEGRAM_BOT_TOKEN"); telegramToken != "" { if telegramToken := os.Getenv("TELEGRAM_BOT_TOKEN"); telegramToken != "" {
cfg.Notification.TelegramBotToken = telegramToken cfg.Notification.TelegramBotToken = telegramToken
} }
if webhookSecret := os.Getenv("TELEGRAM_WEBHOOK_SECRET"); webhookSecret != "" {
cfg.Notification.TelegramWebhookSecret = webhookSecret
}
if captchaEnabled := os.Getenv("CAPTCHA_ENABLED"); captchaEnabled != "" { if captchaEnabled := os.Getenv("CAPTCHA_ENABLED"); captchaEnabled != "" {
cfg.Captcha.Enabled = captchaEnabled == "true" || captchaEnabled == "1" cfg.Captcha.Enabled = captchaEnabled == "true" || captchaEnabled == "1"
} }

View File

@@ -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)
}

View File

@@ -221,6 +221,12 @@ func Setup(cfg *config.Config) *gin.Engine {
apiHandler := handler.NewAPIHandler() apiHandler := handler.NewAPIHandler()
apiRecurringHandler := handler.NewAPIRecurringHandler() 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/:file", gin.WrapH(captcha.Server(captcha.StdWidth, captcha.StdHeight)))
r.GET("/captcha-new", func(c *gin.Context) { r.GET("/captcha-new", func(c *gin.Context) {
id := captcha.New() id := captcha.New()

View File

@@ -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: <title> <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)
}

View File

@@ -50,40 +50,92 @@ func (s *NotificationService) UpdateUserSettings(userID uint, settings *models.U
settings.ID = existing.ID settings.ID = existing.ID
return database.GetDB().Save(settings).Error 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 == "" { if s.telegramBotToken == "" {
return fmt.Errorf("telegram bot token is not configured") return fmt.Errorf("telegram bot token is not configured")
} }
if chatID == "" { apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/%s", s.telegramBotToken, endpoint)
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",
}
jsonPayload, err := json.Marshal(payload) jsonPayload, err := json.Marshal(payload)
if err != nil { if err != nil {
return err return err
} }
resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonPayload)) resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonPayload))
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { 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 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 { func (s *NotificationService) SendAssignmentReminder(userID uint, assignment *models.Assignment) error {
settings, err := s.GetUserSettings(userID) settings, err := s.GetUserSettings(userID)
if err != nil { if err != nil {
@@ -201,10 +253,20 @@ func (s *NotificationService) SendUrgentReminder(userID uint, assignment *models
assignment.DueDate.Format("2006/01/02 15:04"), 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 var errors []string
if settings.TelegramEnabled && settings.TelegramChatID != "" { 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)) errors = append(errors, fmt.Sprintf("Telegram: %v", err))
} }
} }

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`
}