feat: add event notes with pending state, 5-min TTL, cancel, and /note command
This commit is contained in:
@@ -157,6 +157,16 @@ func (h *Handler) handleHistoryCallback(ctx context.Context, chatID int64, msgID
|
|||||||
h.editEventTimeMin(ctx, chatID, msgID, user, eid, hh, loc)
|
h.editEventTimeMin(ctx, chatID, msgID, user, eid, hh, loc)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Set note for an event — prompt user for text
|
||||||
|
if n, _ := fmt.Sscanf(data, "note_%d", &eid); n == 1 {
|
||||||
|
h.promptNote(ctx, chatID, msgID, user, eid, loc)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Cancel note prompt — return to day view
|
||||||
|
if n, _ := fmt.Sscanf(data, "note_cancel_%d", &eid); n == 1 {
|
||||||
|
h.backToDayView(ctx, chatID, msgID, user, eid, loc)
|
||||||
|
return
|
||||||
|
}
|
||||||
// Add IN event — show hour picker
|
// Add IN event — show hour picker
|
||||||
if strings.HasPrefix(data, "addin_") {
|
if strings.HasPrefix(data, "addin_") {
|
||||||
h.addEventTime(ctx, chatID, msgID, user, data[6:], "in", loc)
|
h.addEventTime(ctx, chatID, msgID, user, data[6:], "in", loc)
|
||||||
@@ -372,6 +382,7 @@ func (h *Handler) sendDayView(ctx context.Context, chatID int64, msgID int, user
|
|||||||
kbRows = append(kbRows, []models.InlineKeyboardButton{
|
kbRows = append(kbRows, []models.InlineKeyboardButton{
|
||||||
{Text: fmt.Sprintf("Time %s", t), CallbackData: fmt.Sprintf("edit_time_%d", e.ID)},
|
{Text: fmt.Sprintf("Time %s", t), CallbackData: fmt.Sprintf("edit_time_%d", e.ID)},
|
||||||
{Text: "Type", CallbackData: fmt.Sprintf("edit_type_%d", e.ID)},
|
{Text: "Type", CallbackData: fmt.Sprintf("edit_type_%d", e.ID)},
|
||||||
|
{Text: "Note", CallbackData: fmt.Sprintf("note_%d", e.ID)},
|
||||||
{Text: "DEL", CallbackData: fmt.Sprintf("delete_%d", e.ID), Style: "danger"},
|
{Text: "DEL", CallbackData: fmt.Sprintf("delete_%d", e.ID), Style: "danger"},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -532,6 +543,29 @@ func (h *Handler) acknowledgeCallback(ctx context.Context, chatID int64, msgID i
|
|||||||
h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: fmt.Sprintf("cb_%d_%d", chatID, msgID)})
|
h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: fmt.Sprintf("cb_%d_%d", chatID, msgID)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// promptNote asks the user to type a note for the event, then re-renders the day view.
|
||||||
|
func (h *Handler) promptNote(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) {
|
||||||
|
event, err := h.getEventByID(user.ID, eventID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t := time.Unix(event.OccurredAt, 0).In(loc).Format("15:04")
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
h.pendingNotes[chatID] = &pendingNote{eventID: eventID, createdAt: time.Now()}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
kb := models.InlineKeyboardMarkup{
|
||||||
|
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||||
|
{{Text: "Cancel", CallbackData: fmt.Sprintf("note_cancel_%d", eventID)}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{
|
||||||
|
ChatID: chatID, MessageID: msgID,
|
||||||
|
Text: fmt.Sprintf("Send the note for event at %s:", t), ReplyMarkup: &kb,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// deleteEventPrompt asks the user to confirm event deletion.
|
// deleteEventPrompt asks the user to confirm event deletion.
|
||||||
func (h *Handler) deleteEventPrompt(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) {
|
func (h *Handler) deleteEventPrompt(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) {
|
||||||
kb := models.InlineKeyboardMarkup{
|
kb := models.InlineKeyboardMarkup{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,12 +16,18 @@ import (
|
|||||||
|
|
||||||
const rateLimitInterval = 1 * time.Second
|
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.
|
// Handler holds the bot instance, database store, user allowlist, and rate-limit state.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
Bot *bot.Bot
|
Bot *bot.Bot
|
||||||
DB *db.Store
|
DB *db.Store
|
||||||
AllowedUsers map[int64]bool
|
AllowedUsers map[int64]bool
|
||||||
lastMsgTime map[int64]time.Time
|
lastMsgTime map[int64]time.Time
|
||||||
|
pendingNotes map[int64]*pendingNote
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,12 +38,13 @@ func NewHandler(bot *bot.Bot, store *db.Store, allowed map[int64]bool) *Handler
|
|||||||
DB: store,
|
DB: store,
|
||||||
AllowedUsers: allowed,
|
AllowedUsers: allowed,
|
||||||
lastMsgTime: make(map[int64]time.Time),
|
lastMsgTime: make(map[int64]time.Time),
|
||||||
|
pendingNotes: make(map[int64]*pendingNote),
|
||||||
}
|
}
|
||||||
go h.periodicCleanup()
|
go h.periodicCleanup()
|
||||||
return h
|
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() {
|
func (h *Handler) periodicCleanup() {
|
||||||
for {
|
for {
|
||||||
time.Sleep(10 * time.Second)
|
time.Sleep(10 * time.Second)
|
||||||
@@ -47,6 +55,12 @@ func (h *Handler) periodicCleanup() {
|
|||||||
delete(h.lastMsgTime, uid)
|
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()
|
h.mu.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,6 +114,25 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
slog.Info("message", "chat_id", msg.Chat.ID, "text", msg.Text)
|
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 {
|
switch msg.Text {
|
||||||
case "/start":
|
case "/start":
|
||||||
h.handleStart(ctx, msg)
|
h.handleStart(ctx, msg)
|
||||||
@@ -129,6 +162,8 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
|
|||||||
h.handleAccentMsg(ctx, msg)
|
h.handleAccentMsg(ctx, msg)
|
||||||
case "/settimezone":
|
case "/settimezone":
|
||||||
h.handleSetTimezone(ctx, msg)
|
h.handleSetTimezone(ctx, msg)
|
||||||
|
case "/note":
|
||||||
|
h.handleNoteMsg(ctx, msg)
|
||||||
default:
|
default:
|
||||||
h.sendText(ctx, msg.Chat.ID, "Unknown command")
|
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)
|
/export [YYYY-MM] - Export monthly report (current month if omitted)
|
||||||
/history - View calendar history
|
/history - View calendar history
|
||||||
/edit YYYY-MM-DD - Edit a specific day
|
/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
|
/reporttoggle - Enable/disable auto report
|
||||||
/setreporttime HH:MM - Set daily report time
|
/setreporttime HH:MM - Set daily report time
|
||||||
/setaccent - Select accent color
|
/setaccent - Select accent color
|
||||||
@@ -266,6 +302,63 @@ Buttons on the main menu also work.`
|
|||||||
h.sendText(ctx, msg.Chat.ID, text)
|
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.
|
// handleWorkTypeMsg shows the work type picker for today.
|
||||||
func (h *Handler) handleWorkTypeMsg(ctx context.Context, msg *models.Message) {
|
func (h *Handler) handleWorkTypeMsg(ctx context.Context, msg *models.Message) {
|
||||||
user, day, err := h.getUserToday(msg.Chat.ID)
|
user, day, err := h.getUserToday(msg.Chat.ID)
|
||||||
|
|||||||
@@ -570,6 +570,12 @@ func (s *Store) EventsForDayByDate(userID int64, date string) ([]Event, error) {
|
|||||||
return events, rows.Err()
|
return events, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetEventNote updates the note for an event.
|
||||||
|
func (s *Store) SetEventNote(eventID int64, note string) error {
|
||||||
|
_, err := s.db.Exec("UPDATE events SET note=? WHERE id=?", note, eventID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// EventsForDayByDayID returns all events for a day record, ordered by occurrence.
|
// EventsForDayByDayID returns all events for a day record, ordered by occurrence.
|
||||||
func (s *Store) EventsForDayByDayID(dayID int64) ([]Event, error) {
|
func (s *Store) EventsForDayByDayID(dayID int64) ([]Event, error) {
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
|
|||||||
Reference in New Issue
Block a user