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
This commit is contained in:
2026-06-23 21:42:52 +03:30
parent 0be03f7078
commit 4a5778afe5
3 changed files with 254 additions and 59 deletions

View File

@@ -33,7 +33,10 @@ func main() {
log.Fatal(err) 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) handler := bot.NewHandler(botAPI, dbStore, translator)
@@ -41,7 +44,10 @@ func main() {
if webhookURL != "" { if webhookURL != "" {
startWebhook(botAPI, handler, webhookURL) startWebhook(botAPI, handler, webhookURL)
} else { } else {
// Fallback to longpolling // Fallback to longpolling — remove any stale webhook first
if _, err := botAPI.Request(tgbotapi.DeleteWebhookConfig{}); err != nil {
log.Printf("DeleteWebhook: %v", err)
}
u := tgbotapi.NewUpdate(0) u := tgbotapi.NewUpdate(0)
u.Timeout = 30 u.Timeout = 30
updates := botAPI.GetUpdatesChan(u) updates := botAPI.GetUpdatesChan(u)
@@ -49,6 +55,9 @@ func main() {
if update.Message != nil { if update.Message != nil {
handler.HandleMessage(update) handler.HandleMessage(update)
} }
if update.CallbackQuery != nil {
handler.HandleCallback(update)
}
} }
} }
} }

View File

@@ -57,5 +57,8 @@ func startWebhook(botAPI *tgbotapi.BotAPI, handler *bot.Handler, webhookURL stri
if update.Message != nil { if update.Message != nil {
handler.HandleMessage(update) handler.HandleMessage(update)
} }
if update.CallbackQuery != nil {
handler.HandleCallback(update)
}
} }
} }

View File

@@ -22,22 +22,15 @@ func NewHandler(bot *tgbotapi.BotAPI, store *db.Store, tr *i18n.Translator) *Han
return &Handler{Bot: bot, DB: store, Translator: tr} return &Handler{Bot: bot, DB: store, Translator: tr}
} }
// HandleMessage routes incoming messages.
func (h *Handler) HandleMessage(update tgbotapi.Update) { func (h *Handler) HandleMessage(update tgbotapi.Update) {
msg := update.Message msg := update.Message
switch msg.Text { switch msg.Text {
case "/start":
h.handleStart(msg)
case "/clockin": case "/clockin":
if err := h.handleClockIn(); err != nil { h.handleClockInMsg(msg)
h.replyWithError(msg.Chat.ID, err.Error())
} else {
h.replyWithText(msg.Chat.ID, "clock_in")
}
case "/clockout": case "/clockout":
if err := h.handleClockOut(); err != nil { h.handleClockOutMsg(msg)
h.replyWithError(msg.Chat.ID, err.Error())
} else {
h.replyWithText(msg.Chat.ID, "clock_out")
}
case "/remote": case "/remote":
h.toggleRemote(msg) h.toggleRemote(msg)
case "/report": case "/report":
@@ -51,42 +44,80 @@ func (h *Handler) HandleMessage(update tgbotapi.Update) {
} }
} }
// handleClockIn validates and records a clockin event. func (h *Handler) HandleCallback(update tgbotapi.Update) {
func (h *Handler) handleClockIn() error { cb := update.CallbackQuery
last, err := h.DB.GetLastEvent() chatID := cb.Message.Chat.ID
if err != nil && err != sql.ErrNoRows { msgID := cb.Message.MessageID
return err switch cb.Data {
} case "clockin":
if last != nil && last.EventType == "in" { h.clockInCallback(chatID, msgID, cb.ID)
return errors.New(h.Translator.Get("already_clocked_in", "en")) case "clockout":
} h.clockOutCallback(chatID, msgID, cb.ID)
// Set default remote flag after first clockin. case "remote":
if _, err := h.DB.GetSetting("remote_flag"); err != nil { h.toggleRemoteCallback(chatID, msgID, cb.ID)
_ = h.DB.SetSetting("remote_flag", "onsite") case "report":
} h.reportCallback(chatID, msgID, cb.ID)
return h.DB.CreateEvent("in", time.Now().Unix(), "") case "export":
} h.exportCallback(chatID, msgID, cb.ID)
case "dayoff":
// handleClockOut validates and records a clockout event. h.dayOffCallback(chatID, msgID, cb.ID)
func (h *Handler) handleClockOut() error { case "back_menu":
last, err := h.DB.GetLastEvent() h.backToMenu(chatID, msgID, cb.ID)
if err != nil { }
return err }
}
if last == nil { // --- /start with inline keyboard ---
return errors.New(h.Translator.Get("error", "en"))
} func (h *Handler) handleStart(msg *tgbotapi.Message) {
switch last.EventType { text := "Welcome! Choose an action:"
case "in": reply := tgbotapi.NewMessage(msg.Chat.ID, text)
return h.DB.CreateEvent("out", time.Now().Unix(), "") reply.ReplyMarkup = mainKeyboard()
case "out": _, _ = h.Bot.Send(reply)
return errors.New(h.Translator.Get("already_clocked_out", "en")) }
default:
return errors.New(h.Translator.Get("error", "en")) 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) { func (h *Handler) toggleRemote(msg *tgbotapi.Message) {
current, err := h.DB.GetSetting("remote_flag") current, err := h.DB.GetSetting("remote_flag")
if err != nil { if err != nil {
@@ -107,7 +138,6 @@ func (h *Handler) toggleRemote(msg *tgbotapi.Message) {
h.replyWithText(msg.Chat.ID, key) h.replyWithText(msg.Chat.ID, key)
} }
// handleReport sends daily work time summary.
func (h *Handler) handleReport(msg *tgbotapi.Message) { func (h *Handler) handleReport(msg *tgbotapi.Message) {
today := time.Now().Format("2006-01-02") today := time.Now().Format("2006-01-02")
events, err := h.DB.EventsForDay(today) events, err := h.DB.EventsForDay(today)
@@ -127,17 +157,6 @@ func (h *Handler) handleReport(msg *tgbotapi.Message) {
_, _ = h.Bot.Send(reply) _, _ = h.Bot.Send(reply)
} }
// formatDuration converts seconds to a humanreadable 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) { func (h *Handler) handleExport(msg *tgbotapi.Message) {
now := time.Now() now := time.Now()
year, month := now.Year(), now.Month() 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) { func (h *Handler) handleDayOff(msg *tgbotapi.Message) {
today := time.Now().Format("2006-01-02") today := time.Now().Format("2006-01-02")
isOff, err := h.DB.IsDayOff(today) 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) { func (h *Handler) replyWithText(chatID int64, key string) {
text := h.Translator.Get(key, "en") text := h.Translator.Get(key, "en")
reply := tgbotapi.NewMessage(chatID, text) reply := tgbotapi.NewMessage(chatID, text)