From 4a5778afe5f8ffb85d9e495e4352501a7447b176 Mon Sep 17 00:00:00 2001 From: db123 Date: Tue, 23 Jun 2026 21:42:52 +0330 Subject: [PATCH] feat: add inline keyboard, /start command, and callback handlers - Show main menu with Clock In/Out, Report, Export, Remote, Day Off buttons - Each action result includes a 'Back to Menu' button - Handle callback queries alongside text commands - Delete stale webhook before switching to polling - Log i18n initialization error instead of silently ignoring it --- cmd/bot/main.go | 13 +- cmd/bot/webhook.go | 3 + internal/bot/handlers.go | 297 +++++++++++++++++++++++++++++++-------- 3 files changed, 254 insertions(+), 59 deletions(-) diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 5fff1ca..1a57fd7 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -33,7 +33,10 @@ func main() { log.Fatal(err) } - translator, _ := i18n.NewTranslator("en") + translator, err := i18n.NewTranslator("en") + if err != nil { + log.Fatal("i18n: ", err) + } handler := bot.NewHandler(botAPI, dbStore, translator) @@ -41,7 +44,10 @@ func main() { if webhookURL != "" { startWebhook(botAPI, handler, webhookURL) } else { - // Fallback to long‑polling + // Fallback to long‑polling — remove any stale webhook first + if _, err := botAPI.Request(tgbotapi.DeleteWebhookConfig{}); err != nil { + log.Printf("DeleteWebhook: %v", err) + } u := tgbotapi.NewUpdate(0) u.Timeout = 30 updates := botAPI.GetUpdatesChan(u) @@ -49,6 +55,9 @@ func main() { if update.Message != nil { handler.HandleMessage(update) } + if update.CallbackQuery != nil { + handler.HandleCallback(update) + } } } } diff --git a/cmd/bot/webhook.go b/cmd/bot/webhook.go index 2e0a2b4..9007626 100644 --- a/cmd/bot/webhook.go +++ b/cmd/bot/webhook.go @@ -57,5 +57,8 @@ func startWebhook(botAPI *tgbotapi.BotAPI, handler *bot.Handler, webhookURL stri if update.Message != nil { handler.HandleMessage(update) } + if update.CallbackQuery != nil { + handler.HandleCallback(update) + } } } diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index 633dce4..5766435 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -22,22 +22,15 @@ func NewHandler(bot *tgbotapi.BotAPI, store *db.Store, tr *i18n.Translator) *Han return &Handler{Bot: bot, DB: store, Translator: tr} } -// HandleMessage routes incoming messages. func (h *Handler) HandleMessage(update tgbotapi.Update) { msg := update.Message switch msg.Text { + case "/start": + h.handleStart(msg) case "/clockin": - if err := h.handleClockIn(); err != nil { - h.replyWithError(msg.Chat.ID, err.Error()) - } else { - h.replyWithText(msg.Chat.ID, "clock_in") - } + h.handleClockInMsg(msg) case "/clockout": - if err := h.handleClockOut(); err != nil { - h.replyWithError(msg.Chat.ID, err.Error()) - } else { - h.replyWithText(msg.Chat.ID, "clock_out") - } + h.handleClockOutMsg(msg) case "/remote": h.toggleRemote(msg) case "/report": @@ -51,42 +44,80 @@ func (h *Handler) HandleMessage(update tgbotapi.Update) { } } -// handleClockIn validates and records a clock‑in event. -func (h *Handler) handleClockIn() error { - last, err := h.DB.GetLastEvent() - if err != nil && err != sql.ErrNoRows { - return err - } - if last != nil && last.EventType == "in" { - return errors.New(h.Translator.Get("already_clocked_in", "en")) - } - // Set default remote flag after first clock‑in. - if _, err := h.DB.GetSetting("remote_flag"); err != nil { - _ = h.DB.SetSetting("remote_flag", "onsite") - } - return h.DB.CreateEvent("in", time.Now().Unix(), "") -} - -// handleClockOut validates and records a clock‑out event. -func (h *Handler) handleClockOut() error { - last, err := h.DB.GetLastEvent() - if err != nil { - return err - } - if last == nil { - return errors.New(h.Translator.Get("error", "en")) - } - switch last.EventType { - case "in": - return h.DB.CreateEvent("out", time.Now().Unix(), "") - case "out": - return errors.New(h.Translator.Get("already_clocked_out", "en")) - default: - return errors.New(h.Translator.Get("error", "en")) +func (h *Handler) HandleCallback(update tgbotapi.Update) { + cb := update.CallbackQuery + chatID := cb.Message.Chat.ID + msgID := cb.Message.MessageID + switch cb.Data { + case "clockin": + h.clockInCallback(chatID, msgID, cb.ID) + case "clockout": + h.clockOutCallback(chatID, msgID, cb.ID) + case "remote": + h.toggleRemoteCallback(chatID, msgID, cb.ID) + case "report": + h.reportCallback(chatID, msgID, cb.ID) + case "export": + h.exportCallback(chatID, msgID, cb.ID) + case "dayoff": + h.dayOffCallback(chatID, msgID, cb.ID) + case "back_menu": + h.backToMenu(chatID, msgID, cb.ID) + } +} + +// --- /start with inline keyboard --- + +func (h *Handler) handleStart(msg *tgbotapi.Message) { + text := "Welcome! Choose an action:" + reply := tgbotapi.NewMessage(msg.Chat.ID, text) + reply.ReplyMarkup = mainKeyboard() + _, _ = h.Bot.Send(reply) +} + +func mainKeyboard() tgbotapi.InlineKeyboardMarkup { + return tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Clock In", "clockin"), + tgbotapi.NewInlineKeyboardButtonData("Clock Out", "clockout"), + ), + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Report", "report"), + tgbotapi.NewInlineKeyboardButtonData("Export", "export"), + ), + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Toggle Remote/Onsite", "remote"), + tgbotapi.NewInlineKeyboardButtonData("Day Off", "dayoff"), + ), + ) +} + +func backKeyboard() tgbotapi.InlineKeyboardMarkup { + return tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), + ), + ) +} + +// --- Message-only handlers (legacy text commands) --- + +func (h *Handler) handleClockInMsg(msg *tgbotapi.Message) { + if err := h.doClockIn(); err != nil { + h.replyWithError(msg.Chat.ID, err.Error()) + } else { + h.replyWithText(msg.Chat.ID, "clock_in") + } +} + +func (h *Handler) handleClockOutMsg(msg *tgbotapi.Message) { + if err := h.doClockOut(); err != nil { + h.replyWithError(msg.Chat.ID, err.Error()) + } else { + h.replyWithText(msg.Chat.ID, "clock_out") } } -// toggleRemote switches the remote/onsite flag. func (h *Handler) toggleRemote(msg *tgbotapi.Message) { current, err := h.DB.GetSetting("remote_flag") if err != nil { @@ -107,7 +138,6 @@ func (h *Handler) toggleRemote(msg *tgbotapi.Message) { h.replyWithText(msg.Chat.ID, key) } -// handleReport sends daily work time summary. func (h *Handler) handleReport(msg *tgbotapi.Message) { today := time.Now().Format("2006-01-02") events, err := h.DB.EventsForDay(today) @@ -127,17 +157,6 @@ func (h *Handler) handleReport(msg *tgbotapi.Message) { _, _ = h.Bot.Send(reply) } -// formatDuration converts seconds to a human‑readable string. -func formatDuration(seconds int64) string { - hours := seconds / 3600 - mins := (seconds % 3600) / 60 - if hours > 0 { - return fmt.Sprintf("%dh %dm", hours, mins) - } - return fmt.Sprintf("%dm", mins) -} - -// handleExport generates and sends a monthly Excel report. func (h *Handler) handleExport(msg *tgbotapi.Message) { now := time.Now() year, month := now.Year(), now.Month() @@ -155,7 +174,6 @@ func (h *Handler) handleExport(msg *tgbotapi.Message) { } } -// handleDayOff toggles today as a day off. func (h *Handler) handleDayOff(msg *tgbotapi.Message) { today := time.Now().Format("2006-01-02") isOff, err := h.DB.IsDayOff(today) @@ -178,6 +196,171 @@ func (h *Handler) handleDayOff(msg *tgbotapi.Message) { } } +// --- Inline callback handlers --- + +func (h *Handler) clockInCallback(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + text := h.Translator.Get("clock_in", "en") + if err := h.doClockIn(); err != nil { + text = err.Error() + } + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + kb := backKeyboard() + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +func (h *Handler) clockOutCallback(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + text := h.Translator.Get("clock_out", "en") + if err := h.doClockOut(); err != nil { + text = err.Error() + } + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + kb := backKeyboard() + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +func (h *Handler) toggleRemoteCallback(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + current, err := h.DB.GetSetting("remote_flag") + if err != nil { + current = "onsite" + } + newMode := "remote" + if current == "remote" { + newMode = "onsite" + } + if err := h.DB.SetSetting("remote_flag", newMode); err != nil { + return + } + key := "remote_switched_on" + if newMode == "onsite" { + key = "remote_switched_off" + } + edit := tgbotapi.NewEditMessageText(chatID, msgID, h.Translator.Get(key, "en")) + kb := backKeyboard() + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +func (h *Handler) reportCallback(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + today := time.Now().Format("2006-01-02") + events, err := h.DB.EventsForDay(today) + if err != nil { + return + } + totals := ComputeDailyTotals(events) + text := "No events today" + if totals.PairCount > 0 { + workStr := formatDuration(totals.TotalSeconds) + breakStr := formatDuration(totals.BreakSeconds) + text = fmt.Sprintf("Work: %s | Break: %s", workStr, breakStr) + } + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + kb := backKeyboard() + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +func (h *Handler) exportCallback(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + now := time.Now() + year, month := now.Year(), now.Month() + data, err := GenerateMonthlyReport(h.DB, year, month) + if err != nil { + edit := tgbotapi.NewEditMessageText(chatID, msgID, "Error generating report") + kb := backKeyboard() + edit.ReplyMarkup = &kb + h.Bot.Send(edit) + return + } + edit := tgbotapi.NewEditMessageText(chatID, msgID, "Here is your report:") + kb := backKeyboard() + edit.ReplyMarkup = &kb + h.Bot.Send(edit) + doc := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{ + Name: fmt.Sprintf("worktime_%s.xlsx", now.Format("2006_01")), + Bytes: data, + }) + h.Bot.Send(doc) +} + +func (h *Handler) dayOffCallback(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + today := time.Now().Format("2006-01-02") + isOff, err := h.DB.IsDayOff(today) + if err != nil { + return + } + text := h.Translator.Get("dayoff_set", "en") + if isOff { + h.DB.RemoveDayOff(today) + text = h.Translator.Get("dayoff_removed", "en") + } else { + h.DB.SetDayOff(today, "") + } + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + kb := backKeyboard() + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +func (h *Handler) backToMenu(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + edit := tgbotapi.NewEditMessageText(chatID, msgID, "Welcome! Choose an action:") + kb := mainKeyboard() + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +// --- Pure logic (no chat I/O) --- + +func (h *Handler) doClockIn() error { + last, err := h.DB.GetLastEvent() + if err != nil && err != sql.ErrNoRows { + return err + } + if last != nil && last.EventType == "in" { + return errors.New(h.Translator.Get("already_clocked_in", "en")) + } + if _, err := h.DB.GetSetting("remote_flag"); err != nil { + _ = h.DB.SetSetting("remote_flag", "onsite") + } + return h.DB.CreateEvent("in", time.Now().Unix(), "") +} + +func (h *Handler) doClockOut() error { + last, err := h.DB.GetLastEvent() + if err != nil { + return err + } + if last == nil { + return errors.New(h.Translator.Get("error", "en")) + } + switch last.EventType { + case "in": + return h.DB.CreateEvent("out", time.Now().Unix(), "") + case "out": + return errors.New(h.Translator.Get("already_clocked_out", "en")) + default: + return errors.New(h.Translator.Get("error", "en")) + } +} + +// --- Helpers --- + +func formatDuration(seconds int64) string { + hours := seconds / 3600 + mins := (seconds % 3600) / 60 + if hours > 0 { + return fmt.Sprintf("%dh %dm", hours, mins) + } + return fmt.Sprintf("%dm", mins) +} + func (h *Handler) replyWithText(chatID int64, key string) { text := h.Translator.Get(key, "en") reply := tgbotapi.NewMessage(chatID, text)