feat: add event notes with pending state, 5-min TTL, cancel, and /note command
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -15,12 +16,18 @@ import (
|
||||
|
||||
const rateLimitInterval = 1 * time.Second
|
||||
|
||||
type pendingNote struct {
|
||||
eventID int64
|
||||
createdAt time.Time
|
||||
}
|
||||
|
||||
// Handler holds the bot instance, database store, user allowlist, and rate-limit state.
|
||||
type Handler struct {
|
||||
Bot *bot.Bot
|
||||
DB *db.Store
|
||||
AllowedUsers map[int64]bool
|
||||
lastMsgTime map[int64]time.Time
|
||||
pendingNotes map[int64]*pendingNote
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
@@ -31,12 +38,13 @@ func NewHandler(bot *bot.Bot, store *db.Store, allowed map[int64]bool) *Handler
|
||||
DB: store,
|
||||
AllowedUsers: allowed,
|
||||
lastMsgTime: make(map[int64]time.Time),
|
||||
pendingNotes: make(map[int64]*pendingNote),
|
||||
}
|
||||
go h.periodicCleanup()
|
||||
return h
|
||||
}
|
||||
|
||||
// periodicCleanup runs in the background, purging stale rate-limit entries every 10 seconds.
|
||||
// periodicCleanup runs in the background, purging stale rate-limit entries and pending notes every 10 seconds.
|
||||
func (h *Handler) periodicCleanup() {
|
||||
for {
|
||||
time.Sleep(10 * time.Second)
|
||||
@@ -47,6 +55,12 @@ func (h *Handler) periodicCleanup() {
|
||||
delete(h.lastMsgTime, uid)
|
||||
}
|
||||
}
|
||||
noteCutoff := time.Now().Add(-5 * time.Minute)
|
||||
for chatID, pn := range h.pendingNotes {
|
||||
if pn.createdAt.Before(noteCutoff) {
|
||||
delete(h.pendingNotes, chatID)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
@@ -100,6 +114,25 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
|
||||
return
|
||||
}
|
||||
slog.Info("message", "chat_id", msg.Chat.ID, "text", msg.Text)
|
||||
|
||||
// Check for pending note — intercepts plain text only, commands always pass through.
|
||||
h.mu.Lock()
|
||||
pn, hasPN := h.pendingNotes[msg.Chat.ID]
|
||||
if hasPN {
|
||||
delete(h.pendingNotes, msg.Chat.ID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if hasPN && msg.Text != "" && msg.Text[0] != '/' {
|
||||
loc := loadLocation(user.Timezone)
|
||||
event, err := h.getEventByID(user.ID, pn.eventID)
|
||||
if err == nil {
|
||||
h.DB.SetEventNote(pn.eventID, msg.Text)
|
||||
date := time.Unix(event.OccurredAt, 0).In(loc).Format("2006-01-02")
|
||||
h.sendDayView(ctx, msg.Chat.ID, 0, user, date, loc, true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch msg.Text {
|
||||
case "/start":
|
||||
h.handleStart(ctx, msg)
|
||||
@@ -129,6 +162,8 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
|
||||
h.handleAccentMsg(ctx, msg)
|
||||
case "/settimezone":
|
||||
h.handleSetTimezone(ctx, msg)
|
||||
case "/note":
|
||||
h.handleNoteMsg(ctx, msg)
|
||||
default:
|
||||
h.sendText(ctx, msg.Chat.ID, "Unknown command")
|
||||
}
|
||||
@@ -256,6 +291,7 @@ func (h *Handler) handleHelp(ctx context.Context, msg *models.Message) {
|
||||
/export [YYYY-MM] - Export monthly report (current month if omitted)
|
||||
/history - View calendar history
|
||||
/edit YYYY-MM-DD - Edit a specific day
|
||||
/note [YYYY-MM-DD] HH:MM <text> - Set note for an event
|
||||
/reporttoggle - Enable/disable auto report
|
||||
/setreporttime HH:MM - Set daily report time
|
||||
/setaccent - Select accent color
|
||||
@@ -266,6 +302,63 @@ Buttons on the main menu also work.`
|
||||
h.sendText(ctx, msg.Chat.ID, text)
|
||||
}
|
||||
|
||||
// handleNoteMsg processes the /note command: /note [YYYY-MM-DD] HH:MM <text>
|
||||
func (h *Handler) handleNoteMsg(ctx context.Context, msg *models.Message) {
|
||||
user, err := h.getOrCreateUser(msg.Chat.ID)
|
||||
if err != nil {
|
||||
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
|
||||
return
|
||||
}
|
||||
loc := loadLocation(user.Timezone)
|
||||
|
||||
parts := strings.Fields(msg.Text)
|
||||
if len(parts) < 3 {
|
||||
h.sendText(ctx, msg.Chat.ID, "Usage: /note [YYYY-MM-DD] HH:MM <text>")
|
||||
return
|
||||
}
|
||||
|
||||
var dateStr string
|
||||
var timeStr string
|
||||
var noteParts []string
|
||||
|
||||
// Check if first argument looks like a date
|
||||
if len(parts[1]) == 10 && parts[1][4] == '-' && parts[1][7] == '-' {
|
||||
dateStr = parts[1]
|
||||
timeStr = parts[2]
|
||||
noteParts = parts[3:]
|
||||
} else {
|
||||
dateStr = time.Now().In(loc).Format("2006-01-02")
|
||||
timeStr = parts[1]
|
||||
noteParts = parts[2:]
|
||||
}
|
||||
|
||||
events, err := h.DB.EventsForDayByDate(user.ID, dateStr)
|
||||
if err != nil || len(events) == 0 {
|
||||
h.sendText(ctx, msg.Chat.ID, "No events found for that day")
|
||||
return
|
||||
}
|
||||
|
||||
var targetEvent *db.Event
|
||||
for i := range events {
|
||||
t := time.Unix(events[i].OccurredAt, 0).In(loc).Format("15:04")
|
||||
if t == timeStr {
|
||||
targetEvent = &events[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if targetEvent == nil {
|
||||
h.sendText(ctx, msg.Chat.ID, "No event found at that time")
|
||||
return
|
||||
}
|
||||
|
||||
note := strings.Join(noteParts, " ")
|
||||
if err := h.DB.SetEventNote(targetEvent.ID, note); err != nil {
|
||||
h.sendText(ctx, msg.Chat.ID, "Error saving note")
|
||||
return
|
||||
}
|
||||
h.sendText(ctx, msg.Chat.ID, "Note saved")
|
||||
}
|
||||
|
||||
// handleWorkTypeMsg shows the work type picker for today.
|
||||
func (h *Handler) handleWorkTypeMsg(ctx context.Context, msg *models.Message) {
|
||||
user, day, err := h.getUserToday(msg.Chat.ID)
|
||||
|
||||
Reference in New Issue
Block a user