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

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

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()
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()

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

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