feat: add event notes with pending state, 5-min TTL, cancel, and /note command

This commit is contained in:
2026-06-24 19:49:32 +03:30
parent 68c8518522
commit 4165839477
3 changed files with 134 additions and 1 deletions

View File

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