diff --git a/cmd/bot/main.go b/cmd/bot/main.go index e02c207..b870ea1 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -1,17 +1,17 @@ package main import ( + "context" "log/slog" - "net/http" "os" "os/signal" "strconv" "strings" - "sync" "syscall" "time" - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + tgbot "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" "github.com/joho/godotenv" "worktimeBot/internal/bot" @@ -28,14 +28,6 @@ func main() { token := os.Getenv("BOT_TOKEN") dbPath := getEnvDefault("DB_PATH", "db.sqlite3") - httpClient := http.DefaultClient - - botAPI, err := tgbotapi.NewBotAPIWithClient(token, tgbotapi.APIEndpoint, httpClient) - if err != nil { - slog.Error("failed to create bot API", "error", err) - os.Exit(1) - } - dbStore, err := db.NewStore(dbPath) if err != nil { slog.Error("failed to open database", "path", dbPath, "error", err) @@ -43,108 +35,61 @@ func main() { } allowedUsers := parseAllowedUsers(os.Getenv("BOT_ALLOWED_USERS")) - handler := bot.NewHandler(botAPI, dbStore, allowedUsers) - stop := make(chan struct{}) - var wg sync.WaitGroup + var h *bot.Handler - if url := os.Getenv("BOT_WEBHOOK_URL"); url != "" { - wg.Add(1) - go func() { - defer wg.Done() - startWebhook(botAPI, handler, url) - }() - } else { - if _, err := botAPI.Request(tgbotapi.DeleteWebhookConfig{}); err != nil { - slog.Error("delete webhook", "error", err) + dispatch := func(ctx context.Context, b *tgbot.Bot, update *models.Update) { + if update.Message != nil { + h.HandleMessage(ctx, update.Message) + } + if update.CallbackQuery != nil { + h.HandleCallback(ctx, update.CallbackQuery) } - - wg.Add(1) - go func() { - defer wg.Done() - dailyReportScheduler(handler, dbStore, stop) - }() - - wg.Add(1) - go func() { - defer wg.Done() - startPolling(botAPI, handler, stop) - }() } - slog.Info("bot started", - "bot_user", botAPI.Self.UserName, - "allowed_users", len(allowedUsers), - "mode", map[bool]string{true: "webhook", false: "polling"}[os.Getenv("BOT_WEBHOOK_URL") != ""], - ) + b, err := tgbot.New(token, tgbot.WithDefaultHandler(dispatch)) + if err != nil { + slog.Error("failed to create bot", "error", err) + os.Exit(1) + } + + h = bot.NewHandler(b, dbStore, allowedUsers) + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + if url := os.Getenv("BOT_WEBHOOK_URL"); url != "" { + startWebhook(ctx, b, h, url) + } else { + b.DeleteWebhook(ctx, &tgbot.DeleteWebhookParams{}) + go dailyReportScheduler(ctx, h, dbStore) + slog.Info("bot started", + "allowed_users", len(allowedUsers), + "mode", "polling", + ) + b.Start(ctx) + } - sig := make(chan os.Signal, 1) - signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) - s := <-sig - slog.Info("shutting down", "signal", s.String()) - close(stop) - wg.Wait() dbStore.Close() } -// startPolling runs the long-polling update loop, dispatching messages and callbacks to the handler. -func startPolling(botAPI *tgbotapi.BotAPI, handler *bot.Handler, stop chan struct{}) { - u := tgbotapi.NewUpdate(0) - u.Timeout = 30 - updates := botAPI.GetUpdatesChan(u) - for { - select { - case <-stop: - return - case update, ok := <-updates: - if !ok { - return - } - if update.Message != nil { - handler.HandleMessage(update) - } - if update.CallbackQuery != nil { - handler.HandleCallback(update) - } - } - } -} - -// parseAllowedUsers parses a comma-separated list of Telegram user IDs into a set. -func parseAllowedUsers(raw string) map[int64]bool { - m := make(map[int64]bool) - for _, s := range strings.Split(raw, ",") { - s = strings.TrimSpace(s) - if s == "" { - continue - } - id, err := strconv.ParseInt(s, 10, 64) - if err == nil { - m[id] = true - } else { - slog.Warn("invalid user ID in BOT_ALLOWED_USERS", "value", s) - } - } - return m -} - // dailyReportScheduler runs a periodic ticker that triggers daily report checks every 30 seconds. -func dailyReportScheduler(h *bot.Handler, store *db.Store, stop chan struct{}) { +func dailyReportScheduler(ctx context.Context, h *bot.Handler, store *db.Store) { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for { select { - case <-stop: + case <-ctx.Done(): return case <-ticker.C: - checkDailyReports(h, store) + checkDailyReports(ctx, h, store) } } } // checkDailyReports iterates all users and sends a daily report to those whose report time has arrived. -func checkDailyReports(h *bot.Handler, store *db.Store) { +func checkDailyReports(ctx context.Context, h *bot.Handler, store *db.Store) { users, err := store.GetAllUsers() if err != nil { slog.Error("report scheduler: get users", "error", err) @@ -177,10 +122,28 @@ func checkDailyReports(h *bot.Handler, store *db.Store) { slog.Info("sending daily report", "user_id", u.ID, "chat_id", u.ChatID, "timezone", u.Timezone, ) - h.SendDailyReport(u.ID, u.ChatID) + h.SendDailyReport(ctx, u.ID, u.ChatID) } } +// parseAllowedUsers parses a comma-separated list of Telegram user IDs into a set. +func parseAllowedUsers(raw string) map[int64]bool { + m := make(map[int64]bool) + for _, s := range strings.Split(raw, ",") { + s = strings.TrimSpace(s) + if s == "" { + continue + } + id, err := strconv.ParseInt(s, 10, 64) + if err == nil { + m[id] = true + } else { + slog.Warn("invalid user ID in BOT_ALLOWED_USERS", "value", s) + } + } + return m +} + // getEnvDefault returns the environment variable value or a default if unset or empty. func getEnvDefault(key, defaultValue string) string { if val := os.Getenv(key); val != "" { diff --git a/cmd/bot/webhook.go b/cmd/bot/webhook.go index 4941db8..8ab7091 100644 --- a/cmd/bot/webhook.go +++ b/cmd/bot/webhook.go @@ -1,34 +1,25 @@ package main import ( + "context" "log/slog" "net/http" "os" - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + tgbot "github.com/go-telegram/bot" "worktimeBot/internal/bot" ) // startWebhook registers the webhook with Telegram and starts the HTTPS/HTTP listener loop. -func startWebhook(botAPI *tgbotapi.BotAPI, handler *bot.Handler, webhookURL string) { - if _, err := botAPI.Request(tgbotapi.DeleteWebhookConfig{}); err != nil { - slog.Error("delete webhook", "error", err) - os.Exit(1) - } +func startWebhook(ctx context.Context, b *tgbot.Bot, h *bot.Handler, webhookURL string) { + b.DeleteWebhook(ctx, &tgbot.DeleteWebhookParams{}) - wh, err := tgbotapi.NewWebhook(webhookURL) - if err != nil { - slog.Error("new webhook", "error", err) - os.Exit(1) - } - if _, err := botAPI.Request(wh); err != nil { + if _, err := b.SetWebhook(ctx, &tgbot.SetWebhookParams{URL: webhookURL}); err != nil { slog.Error("set webhook", "error", err) os.Exit(1) } - updates := botAPI.ListenForWebhook("/webhook") - listenAddr := getEnvDefault("BOT_LISTEN", ":8080") tlsCert := os.Getenv("BOT_TLS_CERT") tlsKey := os.Getenv("BOT_TLS_KEY") @@ -36,7 +27,7 @@ func startWebhook(botAPI *tgbotapi.BotAPI, handler *bot.Handler, webhookURL stri if tlsCert != "" && tlsKey != "" { slog.Info("starting HTTPS webhook", "addr", listenAddr) go func() { - if err := http.ListenAndServeTLS(listenAddr, tlsCert, tlsKey, nil); err != nil { + if err := http.ListenAndServeTLS(listenAddr, tlsCert, tlsKey, b.WebhookHandler()); err != nil { slog.Error("HTTPS server", "error", err) os.Exit(1) } @@ -44,7 +35,7 @@ func startWebhook(botAPI *tgbotapi.BotAPI, handler *bot.Handler, webhookURL stri } else { slog.Info("starting HTTP webhook (TLS by reverse proxy)", "addr", listenAddr) go func() { - if err := http.ListenAndServe(listenAddr, nil); err != nil { + if err := http.ListenAndServe(listenAddr, b.WebhookHandler()); err != nil { slog.Error("HTTP server", "error", err) os.Exit(1) } @@ -52,13 +43,5 @@ func startWebhook(botAPI *tgbotapi.BotAPI, handler *bot.Handler, webhookURL stri } slog.Info("webhook registered", "url", webhookURL) - - for update := range updates { - if update.Message != nil { - handler.HandleMessage(update) - } - if update.CallbackQuery != nil { - handler.HandleCallback(update) - } - } + b.StartWebhook(ctx) } diff --git a/go.mod b/go.mod index f76f3bf..4c3d762 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module worktimeBot go 1.26.4 require ( - github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 + github.com/go-telegram/bot v1.21.0 github.com/joho/godotenv v1.5.1 github.com/xuri/excelize/v2 v2.10.1 modernc.org/sqlite v1.53.0 diff --git a/go.sum b/go.sum index a57f63d..91b3a3d 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= -github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= +github.com/go-telegram/bot v1.21.0 h1:Va/PbGc2vBDdv57GCUEEVV6ROlHWiC6SklJY9Hvhzps= +github.com/go-telegram/bot v1.21.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/internal/bot/calendar.go b/internal/bot/calendar.go index 229f10b..4f4a6ba 100644 --- a/internal/bot/calendar.go +++ b/internal/bot/calendar.go @@ -1,55 +1,58 @@ package bot import ( + "context" "database/sql" "fmt" + "strings" "time" - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + bot "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" "worktimeBot/internal/db" ) // handleHistoryMsg shows the calendar view (new message). -func (h *Handler) handleHistoryMsg(msg *tgbotapi.Message) { - h.sendCalendar(msg.Chat.ID, 0, 0, 0) +func (h *Handler) handleHistoryMsg(ctx context.Context, msg *models.Message) { + h.sendCalendar(ctx, msg.Chat.ID, 0, 0, 0) } // historyCallback opens or refreshes the calendar view (inline). -func (h *Handler) historyCallback(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) - h.editCalendar(chatID, msgID, 0, 0) +func (h *Handler) historyCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { + defer h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}) + h.editCalendar(ctx, chatID, msgID, 0, 0) } // handleEditMsg parses a /edit YYYY-MM-DD command and shows that day's events. -func (h *Handler) handleEditMsg(msg *tgbotapi.Message) { - date := msg.CommandArguments() +func (h *Handler) handleEditMsg(ctx context.Context, msg *models.Message) { + date := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/edit")) if date == "" { - h.sendText(msg.Chat.ID, "Usage: /edit YYYY-MM-DD (your calendar type)") + h.sendText(ctx, msg.Chat.ID, "Usage: /edit YYYY-MM-DD (your calendar type)") return } if len(date) != 10 || date[4] != '-' || date[7] != '-' { - h.sendText(msg.Chat.ID, "Invalid date. Use: /edit YYYY-MM-DD (your calendar type)") + h.sendText(ctx, msg.Chat.ID, "Invalid date. Use: /edit YYYY-MM-DD (your calendar type)") return } _, m, d := parseGregorianDateKey(date) if m < 1 || m > 12 || d < 1 || d > 31 { - h.sendText(msg.Chat.ID, "Invalid date.") + h.sendText(ctx, msg.Chat.ID, "Invalid date.") return } user, err := h.getOrCreateUser(msg.Chat.ID) if err != nil { - h.sendText(msg.Chat.ID, "Error loading profile") + h.sendText(ctx, msg.Chat.ID, "Error loading profile") return } loc := loadLocation(user.Timezone) date = userDateToGregorian(date, user.Calendar) - h.sendDayView(msg.Chat.ID, 0, user, date, loc, true) + h.sendDayView(ctx, msg.Chat.ID, 0, user, date, loc, true) } // handleHistoryCallback routes calendar-related callback data to the right handler. -func (h *Handler) handleHistoryCallback(chatID int64, msgID int, callbackID, data string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) +func (h *Handler) handleHistoryCallback(ctx context.Context, chatID int64, msgID int, callbackID, data string) { + defer h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}) user, err := h.getOrCreateUser(chatID) if err != nil { return @@ -57,7 +60,7 @@ func (h *Handler) handleHistoryCallback(chatID int64, msgID int, callbackID, dat loc := loadLocation(user.Timezone) if data == "history" { - h.editCalendar(chatID, msgID, 0, 0) + h.editCalendar(ctx, chatID, msgID, 0, 0) return } @@ -67,70 +70,70 @@ func (h *Handler) handleHistoryCallback(chatID int64, msgID int, callbackID, dat // Navigate to previous month if n, _ := fmt.Sscanf(data, "cal_prev_%d_%d", &y, &m); n == 2 { - h.editCalendar(chatID, msgID, y, m) + h.editCalendar(ctx, chatID, msgID, y, m) return } // Navigate to next month if n, _ := fmt.Sscanf(data, "cal_next_%d_%d", &y, &m); n == 2 { - h.editCalendar(chatID, msgID, y, m) + h.editCalendar(ctx, chatID, msgID, y, m) return } // Open a specific day if n, _ := fmt.Sscanf(data, "cal_day_%s", &date); n == 1 && len(date) == 10 { - h.editDayView(chatID, msgID, user, date, loc) + h.editDayView(ctx, chatID, msgID, user, date, loc) return } // Back to day view from event editing if n, _ := fmt.Sscanf(data, "back_day_%d", &eid); n == 1 { - h.backToDayView(chatID, msgID, user, eid, loc) + h.backToDayView(ctx, chatID, msgID, user, eid, loc) return } // Set event work type var wtid int64 if n, _ := fmt.Sscanf(data, "settype_%d_%d", &eid, &wtid); n == 2 { - h.setEventWorkType(chatID, msgID, user, eid, wtid, loc) + h.setEventWorkType(ctx, chatID, msgID, user, eid, wtid, loc) return } // Show work type picker for an event if n, _ := fmt.Sscanf(data, "edit_type_%d", &eid); n == 1 { - h.editEventType(chatID, msgID, user, eid, loc) + h.editEventType(ctx, chatID, msgID, user, eid, loc) return } // Delete event confirmation prompt if n, _ := fmt.Sscanf(data, "delete_%d", &eid); n == 1 { - h.deleteEventPrompt(chatID, msgID, user, eid, loc) + h.deleteEventPrompt(ctx, chatID, msgID, user, eid, loc) return } // Confirm event deletion if n, _ := fmt.Sscanf(data, "delconf_%d", &eid); n == 1 { - h.deleteEventConfirm(chatID, msgID, user, eid, loc) + h.deleteEventConfirm(ctx, chatID, msgID, user, eid, loc) return } // Show hour picker for event time if n, _ := fmt.Sscanf(data, "edit_time_%d", &eid); n == 1 { - h.editEventTime(chatID, msgID, user, eid, loc) + h.editEventTime(ctx, chatID, msgID, user, eid, loc) return } // Set event time (hour+minute) var hh, mm int if n, _ := fmt.Sscanf(data, "edittm_%d_%d_%d", &eid, &hh, &mm); n == 3 { - h.editEventTimeSet(chatID, msgID, user, eid, hh, mm, loc) + h.editEventTimeSet(ctx, chatID, msgID, user, eid, hh, mm, loc) return } // Show minute picker (hour already chosen) if n, _ := fmt.Sscanf(data, "edittm_%d_%d", &eid, &hh); n == 2 { - h.editEventTimeMin(chatID, msgID, user, eid, hh, loc) + h.editEventTimeMin(ctx, chatID, msgID, user, eid, hh, loc) return } } // sendCalendar sends a calendar view as a new message. -func (h *Handler) sendCalendar(chatID int64, msgID int, year, month int) { - h.editCalendar(chatID, msgID, year, month) +func (h *Handler) sendCalendar(ctx context.Context, chatID int64, msgID int, year, month int) { + h.editCalendar(ctx, chatID, msgID, year, month) } // editCalendar renders a monthly calendar grid with event indicators and navigation. -func (h *Handler) editCalendar(chatID int64, msgID int, year, month int) { +func (h *Handler) editCalendar(ctx context.Context, chatID int64, msgID int, year, month int) { user, err := h.getOrCreateUser(chatID) if err != nil { return @@ -172,20 +175,20 @@ func (h *Handler) editCalendar(chatID int64, msgID int, year, month int) { rows.Close() } - kbRows := [][]tgbotapi.InlineKeyboardButton{} + kbRows := [][]models.InlineKeyboardButton{} // Weekday header row - headerRow := []tgbotapi.InlineKeyboardButton{} + headerRow := []models.InlineKeyboardButton{} for _, wn := range cm.weekDays { - headerRow = append(headerRow, tgbotapi.NewInlineKeyboardButtonData(wn, "noop")) + headerRow = append(headerRow, models.InlineKeyboardButton{Text: wn, CallbackData: "noop"}) } kbRows = append(kbRows, headerRow) // Day cells - row := []tgbotapi.InlineKeyboardButton{} + row := []models.InlineKeyboardButton{} for i, d := range cm.days { if d.dayNum == 0 { - row = append(row, tgbotapi.NewInlineKeyboardButtonData(" ", "noop")) + row = append(row, models.InlineKeyboardButton{Text: " ", CallbackData: "noop"}) } else { label := fmt.Sprintf("%d", d.dayNum) if d.date == todayKey { @@ -193,12 +196,12 @@ func (h *Handler) editCalendar(chatID int64, msgID int, year, month int) { } else if hasEvent[d.date] { label = fmt.Sprintf("%d*", d.dayNum) } - row = append(row, tgbotapi.NewInlineKeyboardButtonData(label, "cal_day_"+d.date)) + row = append(row, models.InlineKeyboardButton{Text: label, CallbackData: "cal_day_" + d.date}) } if len(row) == 7 || i == len(cm.days)-1 { // Pad the last row to 7 columns so buttons render evenly for len(row) < 7 { - row = append(row, tgbotapi.NewInlineKeyboardButtonData(" ", "noop")) + row = append(row, models.InlineKeyboardButton{Text: " ", CallbackData: "noop"}) } kbRows = append(kbRows, row) row = nil @@ -208,31 +211,31 @@ func (h *Handler) editCalendar(chatID int64, msgID int, year, month int) { prevY, prevM, nextY, nextM := navMonth(year, month) // Navigation row - kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("<", fmt.Sprintf("cal_prev_%d_%d", prevY, prevM)), - tgbotapi.NewInlineKeyboardButtonData("Today", "history"), - tgbotapi.NewInlineKeyboardButtonData(">", fmt.Sprintf("cal_next_%d_%d", nextY, nextM)), - )) + kbRows = append(kbRows, []models.InlineKeyboardButton{ + {Text: "<", CallbackData: fmt.Sprintf("cal_prev_%d_%d", prevY, prevM)}, + {Text: "Today", CallbackData: "history"}, + {Text: ">", CallbackData: fmt.Sprintf("cal_next_%d_%d", nextY, nextM)}, + }) - kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), - )) + kbRows = append(kbRows, []models.InlineKeyboardButton{ + {Text: "Back to Menu", CallbackData: "back_menu"}, + }) - kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) - h.sendOrEdit(chatID, msgID, text, &kb) + kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows} + h.sendOrEdit(ctx, chatID, msgID, text, &kb) } // editDayView opens an existing message as a day view. -func (h *Handler) editDayView(chatID int64, msgID int, user *db.User, date string, loc *time.Location) { - h.sendDayView(chatID, msgID, user, date, loc, false) +func (h *Handler) editDayView(ctx context.Context, chatID int64, msgID int, user *db.User, date string, loc *time.Location) { + h.sendDayView(ctx, chatID, msgID, user, date, loc, false) } // sendDayView displays all events for a given date with inline edit/delete buttons. -func (h *Handler) sendDayView(chatID int64, msgID int, user *db.User, date string, loc *time.Location, isNewMsg bool) { +func (h *Handler) sendDayView(ctx context.Context, chatID int64, msgID int, user *db.User, date string, loc *time.Location, isNewMsg bool) { events, err := h.DB.EventsForDayByDate(user.ID, date) if err != nil { if isNewMsg { - h.sendText(chatID, "Error loading events") + h.sendText(ctx, chatID, "Error loading events") } return } @@ -240,17 +243,17 @@ func (h *Handler) sendDayView(chatID int64, msgID int, user *db.User, date strin text := fmt.Sprintf("Date: %s", formatDateForCalendar(date, user.Calendar)) if len(events) == 0 { text += "\nNo events for this day." - kb := tgbotapi.NewInlineKeyboardMarkup( - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back to Calendar", "history"), - ), - ) - h.sendOrEdit(chatID, msgID, text, &kb) + kb := models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Back to Calendar", CallbackData: "history"}}, + }, + } + h.sendOrEdit(ctx, chatID, msgID, text, &kb) return } text += "\n\nEvents:" - kbRows := [][]tgbotapi.InlineKeyboardButton{} + kbRows := [][]models.InlineKeyboardButton{} for _, e := range events { t := time.Unix(e.OccurredAt, 0).In(loc).Format("15:04") label := "IN" @@ -269,100 +272,94 @@ func (h *Handler) sendDayView(chatID int64, msgID int, user *db.User, date strin } text += fmt.Sprintf("\n%s %s%s%s", label, t, wt, note) - kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Time %s", t), fmt.Sprintf("edit_time_%d", e.ID)), - tgbotapi.NewInlineKeyboardButtonData("Type", fmt.Sprintf("edit_type_%d", e.ID)), - tgbotapi.NewInlineKeyboardButtonData("DEL", fmt.Sprintf("delete_%d", e.ID)), - )) + kbRows = append(kbRows, []models.InlineKeyboardButton{ + {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: "DEL", CallbackData: fmt.Sprintf("delete_%d", e.ID), Style: "danger"}, + }) } - kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back to Calendar", "history"), - )) - kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) - h.sendOrEdit(chatID, msgID, text, &kb) + kbRows = append(kbRows, []models.InlineKeyboardButton{ + {Text: "Back to Calendar", CallbackData: "history"}, + }) + kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows} + h.sendOrEdit(ctx, chatID, msgID, text, &kb) } // editEventType shows a work type picker for a specific event. -func (h *Handler) editEventType(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { +func (h *Handler) editEventType(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { wts, err := h.DB.GetWorkTypes() if err != nil { return } text := "Select new work type:" - kbRows := [][]tgbotapi.InlineKeyboardButton{} + kbRows := [][]models.InlineKeyboardButton{} for _, wt := range wts { - kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(wt.Name, fmt.Sprintf("settype_%d_%d", eventID, wt.ID)), - )) + kbRows = append(kbRows, []models.InlineKeyboardButton{ + {Text: wt.Name, CallbackData: fmt.Sprintf("settype_%d_%d", eventID, wt.ID)}, + }) } - kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back", fmt.Sprintf("back_day_%d", eventID)), - )) - kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) + kbRows = append(kbRows, []models.InlineKeyboardButton{ + {Text: "Back", CallbackData: fmt.Sprintf("back_day_%d", eventID)}, + }) + kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows} + h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: text, ReplyMarkup: &kb}) } // setEventWorkType updates an event's work type and returns to the day view. -func (h *Handler) setEventWorkType(chatID int64, msgID int, user *db.User, eventID, workTypeID int64, loc *time.Location) { +func (h *Handler) setEventWorkType(ctx context.Context, chatID int64, msgID int, user *db.User, eventID, workTypeID int64, loc *time.Location) { h.DB.DB().Exec("UPDATE events SET work_type_id=? WHERE id=?", workTypeID, eventID) event, err := h.getEventByID(user.ID, eventID) if err != nil { return } t := time.Unix(event.OccurredAt, 0).In(loc) - h.sendDayView(chatID, msgID, user, t.Format("2006-01-02"), loc, false) + h.sendDayView(ctx, chatID, msgID, user, t.Format("2006-01-02"), loc, false) } // editEventTime shows an hour picker for changing an event's time. -func (h *Handler) editEventTime(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { +func (h *Handler) editEventTime(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { text := "Select hour:" - kbRows := [][]tgbotapi.InlineKeyboardButton{} - hourRow := []tgbotapi.InlineKeyboardButton{} + kbRows := [][]models.InlineKeyboardButton{} + hourRow := []models.InlineKeyboardButton{} for hh := 0; hh < 24; hh++ { - hourRow = append(hourRow, tgbotapi.NewInlineKeyboardButtonData( - fmt.Sprintf("%02d", hh), - fmt.Sprintf("edittm_%d_%02d", eventID, hh), - )) + hourRow = append(hourRow, models.InlineKeyboardButton{ + Text: fmt.Sprintf("%02d", hh), + CallbackData: fmt.Sprintf("edittm_%d_%02d", eventID, hh), + }) if len(hourRow) == 6 || hh == 23 { kbRows = append(kbRows, hourRow) hourRow = nil } } - kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back", fmt.Sprintf("back_day_%d", eventID)), - )) - kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) + kbRows = append(kbRows, []models.InlineKeyboardButton{ + {Text: "Back", CallbackData: fmt.Sprintf("back_day_%d", eventID)}, + }) + kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows} + h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: text, ReplyMarkup: &kb}) } // editEventTimeMin shows a minute picker (15-min intervals) after hour selection. -func (h *Handler) editEventTimeMin(chatID int64, msgID int, user *db.User, eventID int64, hh int, loc *time.Location) { +func (h *Handler) editEventTimeMin(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, hh int, loc *time.Location) { text := fmt.Sprintf("Select minute for hour %02d:", hh) - kbRows := [][]tgbotapi.InlineKeyboardButton{} - minRow := []tgbotapi.InlineKeyboardButton{} + kbRows := [][]models.InlineKeyboardButton{} + minRow := []models.InlineKeyboardButton{} for _, mm := range []int{0, 15, 30, 45} { - minRow = append(minRow, tgbotapi.NewInlineKeyboardButtonData( - fmt.Sprintf("%02d", mm), - fmt.Sprintf("edittm_%d_%02d_%02d", eventID, hh, mm), - )) + minRow = append(minRow, models.InlineKeyboardButton{ + Text: fmt.Sprintf("%02d", mm), + CallbackData: fmt.Sprintf("edittm_%d_%02d_%02d", eventID, hh, mm), + }) } kbRows = append(kbRows, minRow) - kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back", fmt.Sprintf("edit_time_%d", eventID)), - )) - kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) + kbRows = append(kbRows, []models.InlineKeyboardButton{ + {Text: "Back", CallbackData: fmt.Sprintf("edit_time_%d", eventID)}, + }) + kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows} + h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: text, ReplyMarkup: &kb}) } // editEventTimeSet applies a new time to an event, checking for overlaps. -func (h *Handler) editEventTimeSet(chatID int64, msgID int, user *db.User, eventID int64, hh, mm int, loc *time.Location) { +func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, hh, mm int, loc *time.Location) { event, err := h.getEventByID(user.ID, eventID) if err != nil { return @@ -380,8 +377,8 @@ func (h *Handler) editEventTimeSet(chatID int64, msgID int, user *db.User, event } eTime := time.Unix(e.OccurredAt, 0) if newTime.Unix() == eTime.Unix() { - h.acknowledgeCallback(chatID, msgID) - h.sendDayView(chatID, msgID, user, date, loc, false) + h.acknowledgeCallback(ctx, chatID, msgID) + h.sendDayView(ctx, chatID, msgID, user, date, loc, false) return } } @@ -410,37 +407,36 @@ func (h *Handler) editEventTimeSet(chatID int64, msgID int, user *db.User, event } for i := 1; i < len(sorted); i++ { if sorted[i].typ == sorted[i-1].typ { - edit := tgbotapi.NewEditMessageText(chatID, msgID, "Edit rejected: overlapping events. Two consecutive events must be different types (in/out).") - h.Bot.Send(edit) + h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: "Edit rejected: overlapping events. Two consecutive events must be different types (in/out)."}) return } } } h.DB.DB().Exec("UPDATE events SET occurred_at=? WHERE id=?", newTime.Unix(), eventID) - h.sendDayView(chatID, msgID, user, date, loc, false) + h.sendDayView(ctx, chatID, msgID, user, date, loc, false) } // acknowledgeCallback sends an empty acknowledgement for a callback query. -func (h *Handler) acknowledgeCallback(chatID int64, msgID int) { - h.Bot.Request(tgbotapi.NewCallback(fmt.Sprintf("cb_%d_%d", chatID, msgID), "")) +func (h *Handler) acknowledgeCallback(ctx context.Context, chatID int64, msgID int) { + h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: fmt.Sprintf("cb_%d_%d", chatID, msgID)}) } // deleteEventPrompt asks the user to confirm event deletion. -func (h *Handler) deleteEventPrompt(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { - edit := tgbotapi.NewEditMessageText(chatID, msgID, "Delete this event?") - kb := tgbotapi.NewInlineKeyboardMarkup( - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Yes, DEL", fmt.Sprintf("delconf_%d", eventID)), - tgbotapi.NewInlineKeyboardButtonData("Cancel", fmt.Sprintf("back_day_%d", eventID)), - ), - ) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) +func (h *Handler) deleteEventPrompt(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { + kb := models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + { + {Text: "Yes, DEL", CallbackData: fmt.Sprintf("delconf_%d", eventID), Style: "danger"}, + {Text: "Cancel", CallbackData: fmt.Sprintf("back_day_%d", eventID)}, + }, + }, + } + h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: "Delete this event?", ReplyMarkup: &kb}) } // deleteEventConfirm deletes an event and warns if the timeline becomes invalid. -func (h *Handler) deleteEventConfirm(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { +func (h *Handler) deleteEventConfirm(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 @@ -461,21 +457,21 @@ func (h *Handler) deleteEventConfirm(chatID int64, msgID int, user *db.User, eve } } if hasIssue { - h.sendText(chatID, "Warning: Timeline has consecutive events of same type after deletion. Manual repair needed.") + h.sendText(ctx, chatID, "Warning: Timeline has consecutive events of same type after deletion. Manual repair needed.") } } - h.sendDayView(chatID, msgID, user, date, loc, false) + h.sendDayView(ctx, chatID, msgID, user, date, loc, false) } // backToDayView returns from event editing to the day view. -func (h *Handler) backToDayView(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { +func (h *Handler) backToDayView(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) - h.sendDayView(chatID, msgID, user, t.Format("2006-01-02"), loc, false) + h.sendDayView(ctx, chatID, msgID, user, t.Format("2006-01-02"), loc, false) } // getEventByID fetches a single event by ID, scoped to the user. diff --git a/internal/bot/export.go b/internal/bot/export.go index 4c8abc6..719066d 100644 --- a/internal/bot/export.go +++ b/internal/bot/export.go @@ -1,11 +1,15 @@ package bot import ( + "bytes" + "context" "fmt" "log/slog" + "strings" "time" - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + bot "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" "github.com/xuri/excelize/v2" "worktimeBot/internal/db" @@ -224,10 +228,10 @@ func fmtDDHHMM(seconds int64) string { } // handleExport processes the /export command, showing the month picker or exporting directly. -func (h *Handler) handleExport(msg *tgbotapi.Message) { +func (h *Handler) handleExport(ctx context.Context, msg *models.Message) { user, err := h.getOrCreateUser(msg.Chat.ID) if err != nil { - h.sendText(msg.Chat.ID, "Error loading profile") + h.sendText(ctx, msg.Chat.ID, "Error loading profile") return } loc := loadLocation(user.Timezone) @@ -241,23 +245,27 @@ func (h *Handler) handleExport(msg *tgbotapi.Message) { cy, cm, _ = gregorianToHijri(cy, cm, now.Day()) } - if len(msg.Text) > 7 { - if n, _ := fmt.Sscanf(msg.Text[7:], "%d-%d", &cy, &cm); n == 2 { + args := strings.Fields(msg.Text) + if len(args) > 0 && args[0][0] == '/' { + args = args[1:] + } + if len(args) >= 1 { + if n, _ := fmt.Sscanf(args[0], "%d-%d", &cy, &cm); n == 2 { if cy < 0 || cm < 1 || cm > 12 { - h.sendText(msg.Chat.ID, "Invalid date. Use: /export YYYY-MM") + h.sendText(ctx, msg.Chat.ID, "Invalid date. Use: /export YYYY-MM") return } } } - h.sendExportDirect(msg.Chat.ID, user, cy, cm) + h.sendExportDirect(ctx, msg.Chat.ID, user, cy, cm) } // sendExportDirect generates and sends an Excel report for the given calendar month. -func (h *Handler) sendExportDirect(chatID int64, user *db.User, calYear, calMonth int) { +func (h *Handler) sendExportDirect(ctx context.Context, chatID int64, user *db.User, calYear, calMonth int) { last, err := h.DB.GetLastEvent(user.ID) if err == nil && last != nil && last.EventType == "in" { - h.sendText(chatID, "You are currently clocked in. Please clock out first, then try again.") + h.sendText(ctx, chatID, "You are currently clocked in. Please clock out first, then try again.") return } @@ -277,22 +285,24 @@ func (h *Handler) sendExportDirect(chatID int64, user *db.User, calYear, calMont data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, calYear, calMonth, start, end) if err != nil { slog.Error("generate report", "error", err) - h.sendText(chatID, "Error generating report") + h.sendText(ctx, chatID, "Error generating report") return } slog.Info("report generated", "bytes", len(data)) - doc := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{ - Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", calYear, calMonth)), - Bytes: data, - }) - if _, err := h.Bot.Send(doc); err != nil { + if _, err := h.Bot.SendDocument(ctx, &bot.SendDocumentParams{ + ChatID: chatID, + Document: &models.InputFileUpload{ + Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", calYear, calMonth)), + Data: bytes.NewReader(data), + }, + }); err != nil { slog.Error("send document", "error", err) } } // exportCallback opens the export month picker. -func (h *Handler) exportCallback(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) +func (h *Handler) exportCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { + defer h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}) user, err := h.getOrCreateUser(chatID) if err != nil { return @@ -306,16 +316,16 @@ func (h *Handler) exportCallback(chatID int64, msgID int, callbackID string) { case "hijri": cy, cm, _ = gregorianToHijri(cy, cm, now.Day()) } - h.editExportMonthPicker(chatID, msgID, cy, cm, user) + h.editExportMonthPicker(ctx, chatID, msgID, cy, cm, user) } // sendExportMonthPicker sends the export month picker as a new message. -func (h *Handler) sendExportMonthPicker(chatID int64, msgID int, year, month int, user *db.User) { - h.editExportMonthPicker(chatID, msgID, year, month, user) +func (h *Handler) sendExportMonthPicker(ctx context.Context, chatID int64, msgID int, year, month int, user *db.User) { + h.editExportMonthPicker(ctx, chatID, msgID, year, month, user) } // editExportMonthPicker renders a year-based month picker with 12 month buttons. -func (h *Handler) editExportMonthPicker(chatID int64, msgID int, year, _ int, user *db.User) { +func (h *Handler) editExportMonthPicker(ctx context.Context, chatID int64, msgID int, year, _ int, user *db.User) { months := gregMonthNames switch user.Calendar { case "jalali": @@ -324,33 +334,36 @@ func (h *Handler) editExportMonthPicker(chatID int64, msgID int, year, _ int, us months = hijriMonthNames } - kbRows := [][]tgbotapi.InlineKeyboardButton{ + kbRows := [][]models.InlineKeyboardButton{ // Year navigation { - tgbotapi.NewInlineKeyboardButtonData("<", fmt.Sprintf("exp_year_prev_%d", year)), - tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("%d", year), "noop"), - tgbotapi.NewInlineKeyboardButtonData(">", fmt.Sprintf("exp_year_next_%d", year)), + {Text: "<", CallbackData: fmt.Sprintf("exp_year_prev_%d", year)}, + {Text: fmt.Sprintf("%d", year), CallbackData: "noop"}, + {Text: ">", CallbackData: fmt.Sprintf("exp_year_next_%d", year)}, }, } // 4 columns x 3 rows of month buttons for i := 0; i < 12; i += 4 { - row := []tgbotapi.InlineKeyboardButton{} + row := []models.InlineKeyboardButton{} for j := i; j < i+4 && j < 12; j++ { - row = append(row, tgbotapi.NewInlineKeyboardButtonData( - months[j], - fmt.Sprintf("exp_do_%d_%d", year, j+1), - )) + row = append(row, models.InlineKeyboardButton{ + Text: months[j], CallbackData: fmt.Sprintf("exp_do_%d_%d", year, j+1), + }) } kbRows = append(kbRows, row) } - kbRows = append(kbRows, []tgbotapi.InlineKeyboardButton{ - tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), + kbRows = append(kbRows, []models.InlineKeyboardButton{ + {Text: "Back to Menu", CallbackData: "back_menu"}, }) - kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) - h.sendOrEdit(chatID, msgID, "Select month to export:", &kb) + kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows} + if msgID == 0 { + h.Bot.SendMessage(ctx, &bot.SendMessageParams{ChatID: chatID, Text: "Select month to export:", ReplyMarkup: &kb}) + } else { + h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: "Select month to export:", ReplyMarkup: &kb}) + } } // formatMonthTitle returns the localized month name and year for a given calendar. @@ -366,8 +379,8 @@ func formatMonthTitle(cal string, year, month int) string { } // handleExportCallback routes export inline button presses (year nav, month select). -func (h *Handler) handleExportCallback(chatID int64, msgID int, callbackID, data string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) +func (h *Handler) handleExportCallback(ctx context.Context, chatID int64, msgID int, callbackID, data string) { + defer h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}) user, err := h.getOrCreateUser(chatID) if err != nil { return @@ -377,12 +390,12 @@ func (h *Handler) handleExportCallback(chatID int64, msgID int, callbackID, data // Navigate to previous year if n, _ := fmt.Sscanf(data, "exp_year_prev_%d", &y); n == 1 { - h.editExportMonthPicker(chatID, msgID, y-1, 0, user) + h.editExportMonthPicker(ctx, chatID, msgID, y-1, 0, user) return } // Navigate to next year if n, _ := fmt.Sscanf(data, "exp_year_next_%d", &y); n == 1 { - h.editExportMonthPicker(chatID, msgID, y+1, 0, user) + h.editExportMonthPicker(ctx, chatID, msgID, y+1, 0, user) return } @@ -390,26 +403,30 @@ func (h *Handler) handleExportCallback(chatID int64, msgID int, callbackID, data if n, _ := fmt.Sscanf(data, "exp_do_%d_%d", &y, &m); n == 2 { last, err := h.DB.GetLastEvent(user.ID) if err == nil && last != nil && last.EventType == "in" { - kb := tgbotapi.NewInlineKeyboardMarkup( - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), - ), - ) - edit := tgbotapi.NewEditMessageText(chatID, msgID, "You are currently clocked in. Please clock out first, then try again.") - edit.ReplyMarkup = &kb - h.Bot.Send(edit) + h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: chatID, + MessageID: msgID, + Text: "You are currently clocked in. Please clock out first, then try again.", + ReplyMarkup: &models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + { + {Text: "Back to Menu", CallbackData: "back_menu"}, + }, + }, + }, + }) return } startStr, endStr := monthGregorianRange(user.Calendar, y, m) start, err := time.Parse("2006-01-02", startStr) if err != nil { - h.sendText(chatID, "Error processing date") + h.sendText(ctx, chatID, "Error processing date") return } end, err := time.Parse("2006-01-02", endStr) if err != nil { - h.sendText(chatID, "Error processing date") + h.sendText(ctx, chatID, "Error processing date") return } @@ -417,24 +434,42 @@ func (h *Handler) handleExportCallback(chatID int64, msgID int, callbackID, data data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, y, m, start, end) if err != nil { slog.Error("generate report", "error", err) - edit := tgbotapi.NewEditMessageText(chatID, msgID, "Error generating report") - kb := backKeyboard() - edit.ReplyMarkup = &kb - h.Bot.Send(edit) + h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: chatID, + MessageID: msgID, + Text: "Error generating report", + ReplyMarkup: &models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + { + {Text: "Back to Menu", CallbackData: "back_menu"}, + }, + }, + }, + }) return } slog.Info("report generated", "bytes", len(data)) - edit := tgbotapi.NewEditMessageText(chatID, msgID, "Report ready:") - kb := backKeyboard() - edit.ReplyMarkup = &kb - h.Bot.Send(edit) - - doc := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{ - Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", y, m)), - Bytes: data, + h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: chatID, + MessageID: msgID, + Text: "Report ready:", + ReplyMarkup: &models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + { + {Text: "Back to Menu", CallbackData: "back_menu"}, + }, + }, + }, }) - if _, err := h.Bot.Send(doc); err != nil { + + if _, err := h.Bot.SendDocument(ctx, &bot.SendDocumentParams{ + ChatID: chatID, + Document: &models.InputFileUpload{ + Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", y, m)), + Data: bytes.NewReader(data), + }, + }); err != nil { slog.Error("send document", "error", err) } } diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index 64c0ebd..d19037a 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -1,12 +1,14 @@ package bot import ( + "context" "fmt" "log/slog" "sync" "time" - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + bot "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" "worktimeBot/internal/db" ) @@ -15,7 +17,7 @@ const rateLimitInterval = 1 * time.Second // Handler holds the bot instance, database store, user allowlist, and rate-limit state. type Handler struct { - Bot *tgbotapi.BotAPI + Bot *bot.Bot DB *db.Store AllowedUsers map[int64]bool lastMsgTime map[int64]time.Time @@ -23,7 +25,7 @@ type Handler struct { } // NewHandler creates a Handler and starts the rate-limit cleanup goroutine. -func NewHandler(bot *tgbotapi.BotAPI, store *db.Store, allowed map[int64]bool) *Handler { +func NewHandler(bot *bot.Bot, store *db.Store, allowed map[int64]bool) *Handler { h := &Handler{ Bot: bot, DB: store, @@ -64,33 +66,29 @@ func (h *Handler) isRateLimited(userID int64) bool { } // handleActionMsg runs an action function and sends the result as a new message. -func (h *Handler) handleActionMsg(msg *tgbotapi.Message, fn actionFunc) { +func (h *Handler) handleActionMsg(ctx context.Context, msg *models.Message, fn actionFunc) { text, err := fn(msg.Chat.ID) if err != nil { - h.sendText(msg.Chat.ID, err.Error()) + h.sendText(ctx, msg.Chat.ID, err.Error()) return } - reply := tgbotapi.NewMessage(msg.Chat.ID, text) - reply.ReplyMarkup = mainKeyboard() - h.Bot.Send(reply) + kb := mainKeyboard() + h.Bot.SendMessage(ctx, &bot.SendMessageParams{ChatID: msg.Chat.ID, Text: text, ReplyMarkup: kb}) } // handleActionCallback runs an action function and edits the callback message with the result. -func (h *Handler) handleActionCallback(chatID int64, msgID int, callbackID string, fn actionFunc) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) +func (h *Handler) handleActionCallback(ctx context.Context, chatID int64, msgID int, callbackID string, fn actionFunc) { + defer h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}) text, err := fn(chatID) if err != nil { text = err.Error() } - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) kb := backKeyboard() - edit.ReplyMarkup = &kb - h.Bot.Send(edit) + h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: text, ReplyMarkup: kb}) } // HandleMessage routes incoming text messages to the appropriate handler. -func (h *Handler) HandleMessage(update tgbotapi.Update) { - msg := update.Message +func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) { if len(h.AllowedUsers) > 0 && !h.AllowedUsers[msg.Chat.ID] { return } @@ -104,110 +102,109 @@ func (h *Handler) HandleMessage(update tgbotapi.Update) { slog.Info("message", "chat_id", msg.Chat.ID, "text", msg.Text) switch msg.Text { case "/start": - h.handleStart(msg) + h.handleStart(ctx, msg) case "/help": - h.handleHelp(msg) + h.handleHelp(ctx, msg) case "/clockin": - h.handleActionMsg(msg, h.clockIn) + h.handleActionMsg(ctx, msg, h.clockIn) case "/clockout": - h.handleActionMsg(msg, h.clockOut) + h.handleActionMsg(ctx, msg, h.clockOut) case "/worktype": - h.handleWorkTypeMsg(msg) + h.handleWorkTypeMsg(ctx, msg) case "/report": - h.handleActionMsg(msg, h.report) + h.handleActionMsg(ctx, msg, h.report) case "/export": - h.handleExport(msg) + h.handleExport(ctx, msg) case "/dayoff": - h.handleActionMsg(msg, h.dayOff) + h.handleActionMsg(ctx, msg, h.dayOff) case "/history": - h.handleHistoryMsg(msg) + h.handleHistoryMsg(ctx, msg) case "/edit": - h.handleEditMsg(msg) + h.handleEditMsg(ctx, msg) case "/reporttoggle": - h.handleActionMsg(msg, h.toggleReport) + h.handleActionMsg(ctx, msg, h.toggleReport) case "/setreporttime": - h.handleSetReportTime(msg) + h.handleSetReportTime(ctx, msg) case "/setaccent": - h.handleAccentMsg(msg) + h.handleAccentMsg(ctx, msg) case "/settimezone": - h.handleSetTimezone(msg) + h.handleSetTimezone(ctx, msg) default: - h.sendText(msg.Chat.ID, "Unknown command") + h.sendText(ctx, msg.Chat.ID, "Unknown command") } } // HandleCallback routes inline callback queries to the appropriate handler. -func (h *Handler) HandleCallback(update tgbotapi.Update) { - cb := update.CallbackQuery - if len(h.AllowedUsers) > 0 && !h.AllowedUsers[cb.Message.Chat.ID] { - h.Bot.Request(tgbotapi.NewCallback(cb.ID, "Unauthorized")) +func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery) { + if len(h.AllowedUsers) > 0 && !h.AllowedUsers[cb.Message.Message.Chat.ID] { + h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: cb.ID, Text: "Unauthorized"}) return } - user, err := h.getOrCreateUser(cb.Message.Chat.ID) + user, err := h.getOrCreateUser(cb.Message.Message.Chat.ID) if err != nil { - h.Bot.Request(tgbotapi.NewCallback(cb.ID, "")) + h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: cb.ID}) return } if h.isRateLimited(user.ID) { - h.Bot.Request(tgbotapi.NewCallback(cb.ID, "")) + h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: cb.ID}) return } - slog.Info("callback", "chat_id", cb.Message.Chat.ID, "data", cb.Data) - chatID := cb.Message.Chat.ID - msgID := cb.Message.MessageID + slog.Info("callback", "chat_id", cb.Message.Message.Chat.ID, "data", cb.Data) + chatID := cb.Message.Message.Chat.ID + msgID := cb.Message.Message.ID switch cb.Data { case "clockin": - h.handleActionCallback(chatID, msgID, cb.ID, h.clockIn) + h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.clockIn) case "clockout": - h.handleActionCallback(chatID, msgID, cb.ID, h.clockOut) + h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.clockOut) case "worktype": - h.workTypeCallback(chatID, msgID, cb.ID) + h.workTypeCallback(ctx, chatID, msgID, cb.ID) case "report": - h.handleActionCallback(chatID, msgID, cb.ID, h.report) + h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.report) case "export": - h.exportCallback(chatID, msgID, cb.ID) + h.exportCallback(ctx, chatID, msgID, cb.ID) case "noop": - h.Bot.Request(tgbotapi.NewCallback(cb.ID, "")) + h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: cb.ID}) case "dayoff": - h.handleActionCallback(chatID, msgID, cb.ID, h.dayOff) + h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.dayOff) case "settings": - h.settingsCallback(chatID, msgID, cb.ID) + h.settingsCallback(ctx, chatID, msgID, cb.ID) case "caltype": - h.calTypeCallback(chatID, msgID, cb.ID) + h.calTypeCallback(ctx, chatID, msgID, cb.ID) case "accent": - h.accentCallback(chatID, msgID, cb.ID) + h.accentCallback(ctx, chatID, msgID, cb.ID) case "timezone": - h.timezoneCallback(chatID, msgID, cb.ID) + h.timezoneCallback(ctx, chatID, msgID, cb.ID) case "history": - h.historyCallback(chatID, msgID, cb.ID) + h.historyCallback(ctx, chatID, msgID, cb.ID) case "reporttoggle": - h.handleActionCallback(chatID, msgID, cb.ID, h.toggleReport) + h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.toggleReport) case "reporttime": - h.reportTimeCallback(chatID, msgID, cb.ID) + h.reportTimeCallback(ctx, chatID, msgID, cb.ID) case "back_menu": - h.backToMenu(chatID, msgID, cb.ID) + h.backToMenu(ctx, chatID, msgID, cb.ID) case "back_settings": - h.backToSettings(chatID, msgID, cb.ID) + h.backToSettings(ctx, chatID, msgID, cb.ID) default: // Delegate prefixed callbacks to the appropriate sub-handler if len(cb.Data) > 9 && cb.Data[:9] == "worktype_" { - h.selectWorkType(chatID, msgID, cb.ID, cb.Data[9:]) + h.selectWorkType(ctx, chatID, msgID, cb.ID, cb.Data[9:]) return } if len(cb.Data) > 7 && cb.Data[:7] == "accent_" { - h.selectAccent(chatID, msgID, cb.ID, cb.Data[7:]) + h.selectAccent(ctx, chatID, msgID, cb.ID, cb.Data[7:]) return } if len(cb.Data) > 8 && cb.Data[:8] == "caltype_" { - h.selectCalendar(chatID, msgID, cb.ID, cb.Data[8:]) + h.selectCalendar(ctx, chatID, msgID, cb.ID, cb.Data[8:]) return } if len(cb.Data) > 4 && cb.Data[:4] == "exp_" { - h.handleExportCallback(chatID, msgID, cb.ID, cb.Data) + h.handleExportCallback(ctx, chatID, msgID, cb.ID, cb.Data) return } - h.handleHistoryCallback(chatID, msgID, cb.ID, cb.Data) + h.handleHistoryCallback(ctx, chatID, msgID, cb.ID, cb.Data) } } @@ -235,21 +232,20 @@ func (h *Handler) getUserToday(chatID int64) (*db.User, *db.Day, error) { } // handleStart shows the main menu with the user's current status. -func (h *Handler) handleStart(msg *tgbotapi.Message) { +func (h *Handler) handleStart(ctx context.Context, msg *models.Message) { user, err := h.getOrCreateUser(msg.Chat.ID) if err != nil { slog.Error("start: create user", "error", err) - h.sendText(msg.Chat.ID, "Error setting up your profile") + h.sendText(ctx, msg.Chat.ID, "Error setting up your profile") return } slog.Info("start", "user_id", user.ID, "chat_id", msg.Chat.ID) - menu := tgbotapi.NewMessage(msg.Chat.ID, h.buildStatusText(msg.Chat.ID)) - menu.ReplyMarkup = mainKeyboard() - h.Bot.Send(menu) + kb := mainKeyboard() + h.Bot.SendMessage(ctx, &bot.SendMessageParams{ChatID: msg.Chat.ID, Text: h.buildStatusText(msg.Chat.ID), ReplyMarkup: kb}) } // handleHelp shows the list of available commands. -func (h *Handler) handleHelp(msg *tgbotapi.Message) { +func (h *Handler) handleHelp(ctx context.Context, msg *models.Message) { text := `Commands: /start - Show main menu /clockin - Clock in @@ -267,64 +263,58 @@ func (h *Handler) handleHelp(msg *tgbotapi.Message) { Dates use your calendar type (Gregorian/Jalali/Hijri). Buttons on the main menu also work.` - h.sendText(msg.Chat.ID, text) + h.sendText(ctx, msg.Chat.ID, text) } // handleWorkTypeMsg shows the work type picker for today. -func (h *Handler) handleWorkTypeMsg(msg *tgbotapi.Message) { +func (h *Handler) handleWorkTypeMsg(ctx context.Context, msg *models.Message) { user, day, err := h.getUserToday(msg.Chat.ID) if err != nil { - h.sendText(msg.Chat.ID, "Error loading work types") + h.sendText(ctx, msg.Chat.ID, "Error loading work types") return } text, kb := h.buildWorkTypeKeyboard(user, day) - reply := tgbotapi.NewMessage(msg.Chat.ID, text) - reply.ReplyMarkup = &kb - h.Bot.Send(reply) + h.Bot.SendMessage(ctx, &bot.SendMessageParams{ChatID: msg.Chat.ID, Text: text, ReplyMarkup: kb}) } // workTypeCallback shows the work type picker (inline edit). -func (h *Handler) workTypeCallback(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) +func (h *Handler) workTypeCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { + defer h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}) user, day, err := h.getUserToday(chatID) if err != nil { - edit := tgbotapi.NewEditMessageText(chatID, msgID, "Error loading work types") kb := backKeyboard() - edit.ReplyMarkup = &kb - h.Bot.Send(edit) + h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: "Error loading work types", ReplyMarkup: kb}) return } text, kb := h.buildWorkTypeKeyboard(user, day) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) + h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: text, ReplyMarkup: kb}) } // buildWorkTypeKeyboard returns the work type selection keyboard. -func (h *Handler) buildWorkTypeKeyboard(user *db.User, day *db.Day) (string, tgbotapi.InlineKeyboardMarkup) { +func (h *Handler) buildWorkTypeKeyboard(user *db.User, day *db.Day) (string, models.InlineKeyboardMarkup) { wts, err := h.DB.GetWorkTypes() if err != nil || len(wts) == 0 { return "No work types available.", backKeyboard() } - rows := [][]tgbotapi.InlineKeyboardButton{} + rows := [][]models.InlineKeyboardButton{} for _, wt := range wts { label := wt.Name if wt.ID == day.CurrentWorkTypeID { label = "> " + wt.Name } - rows = append(rows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(label, fmt.Sprintf("worktype_%d", wt.ID)), - )) + rows = append(rows, []models.InlineKeyboardButton{ + {Text: label, CallbackData: fmt.Sprintf("worktype_%d", wt.ID)}, + }) } - rows = append(rows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), - )) - return "Select work type:", tgbotapi.NewInlineKeyboardMarkup(rows...) + rows = append(rows, []models.InlineKeyboardButton{ + {Text: "Back to Menu", CallbackData: "back_menu"}, + }) + return "Select work type:", models.InlineKeyboardMarkup{InlineKeyboard: rows} } // selectWorkType updates the day's work type from a callback. -func (h *Handler) selectWorkType(chatID int64, msgID int, callbackID, idStr string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) +func (h *Handler) selectWorkType(ctx context.Context, chatID int64, msgID int, callbackID, idStr string) { + defer h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}) _, day, err := h.getUserToday(chatID) if err != nil { return @@ -341,40 +331,40 @@ func (h *Handler) selectWorkType(chatID int64, msgID int, callbackID, idStr stri if err := h.DB.UpdateDay(day); err != nil { return } - edit := tgbotapi.NewEditMessageText(chatID, msgID, fmt.Sprintf("Work type set to: %s", wt.Name)) kb := backKeyboard() - edit.ReplyMarkup = &kb - h.Bot.Send(edit) + h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: fmt.Sprintf("Work type set to: %s", wt.Name), ReplyMarkup: kb}) } // mainKeyboard returns the persistent inline menu. -func mainKeyboard() tgbotapi.InlineKeyboardMarkup { - return tgbotapi.NewInlineKeyboardMarkup( - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Clock In", "clockin"), - tgbotapi.NewInlineKeyboardButtonData("Clock Out", "clockout"), - ), - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Work Type", "worktype"), - tgbotapi.NewInlineKeyboardButtonData("Day Off", "dayoff"), - ), - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Report", "report"), - tgbotapi.NewInlineKeyboardButtonData("Export", "export"), - ), - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Settings", "settings"), - ), - ) +func mainKeyboard() models.InlineKeyboardMarkup { + return models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + { + {Text: "Clock In", CallbackData: "clockin"}, + {Text: "Clock Out", CallbackData: "clockout"}, + }, + { + {Text: "Work Type", CallbackData: "worktype"}, + {Text: "Day Off", CallbackData: "dayoff"}, + }, + { + {Text: "Report", CallbackData: "report"}, + {Text: "Export", CallbackData: "export"}, + }, + { + {Text: "Settings", CallbackData: "settings"}, + }, + }, + } } // backKeyboard returns a simple "Back to Menu" button. -func backKeyboard() tgbotapi.InlineKeyboardMarkup { - return tgbotapi.NewInlineKeyboardMarkup( - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), - ), - ) +func backKeyboard() models.InlineKeyboardMarkup { + return models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Back to Menu", CallbackData: "back_menu"}}, + }, + } } // formatDuration formats seconds as a human-readable duration (e.g. "7h 30m"). @@ -388,8 +378,8 @@ func formatDuration(seconds int64) string { } // sendText sends a plain text message to the given chat. -func (h *Handler) sendText(chatID int64, text string) { - h.Bot.Send(tgbotapi.NewMessage(chatID, text)) +func (h *Handler) sendText(ctx context.Context, chatID int64, text string) { + h.Bot.SendMessage(ctx, &bot.SendMessageParams{ChatID: chatID, Text: text}) } // navMonth returns the (prevY, prevM, nextY, nextM) for month navigation. @@ -408,14 +398,10 @@ func navMonth(year, month int) (prevY, prevM, nextY, nextM int) { } // sendOrEdit sends a new message or edits an existing one depending on msgID. -func (h *Handler) sendOrEdit(chatID int64, msgID int, text string, kb *tgbotapi.InlineKeyboardMarkup) { +func (h *Handler) sendOrEdit(ctx context.Context, chatID int64, msgID int, text string, kb *models.InlineKeyboardMarkup) { if msgID == 0 { - msg := tgbotapi.NewMessage(chatID, text) - msg.ReplyMarkup = kb - h.Bot.Send(msg) + h.Bot.SendMessage(ctx, &bot.SendMessageParams{ChatID: chatID, Text: text, ReplyMarkup: kb}) } else { - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - edit.ReplyMarkup = kb - h.Bot.Send(edit) + h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: text, ReplyMarkup: kb}) } } diff --git a/internal/bot/report.go b/internal/bot/report.go index d27e593..010d453 100644 --- a/internal/bot/report.go +++ b/internal/bot/report.go @@ -1,11 +1,13 @@ package bot import ( + "context" "fmt" "log/slog" "time" - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + bot "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" "worktimeBot/internal/db" ) @@ -54,13 +56,33 @@ func (h *Handler) buildStatusText(chatID int64) string { } // backToMenu returns to the main menu from any sub-menu. -func (h *Handler) backToMenu(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) +func (h *Handler) backToMenu(ctx context.Context, chatID int64, msgID int, callbackID string) { + defer h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}) text := h.buildStatusText(chatID) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - kb := mainKeyboard() - edit.ReplyMarkup = &kb - h.Bot.Send(edit) + h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: chatID, + MessageID: msgID, + Text: text, + ReplyMarkup: &models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + { + {Text: "Clock In", CallbackData: "clockin"}, + {Text: "Clock Out", CallbackData: "clockout"}, + }, + { + {Text: "Work Type", CallbackData: "worktype"}, + {Text: "Day Off", CallbackData: "dayoff"}, + }, + { + {Text: "Report", CallbackData: "report"}, + {Text: "Export", CallbackData: "export"}, + }, + { + {Text: "Settings", CallbackData: "settings"}, + }, + }, + }, + }) } // buildDailyReport builds a formatted daily report string for a given user/day. @@ -119,7 +141,7 @@ func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event } // SendDailyReport sends the automated daily report to the user. -func (h *Handler) SendDailyReport(userID int64, chatID int64) { +func (h *Handler) SendDailyReport(ctx context.Context, userID int64, chatID int64) { user, err := h.DB.GetUserByChatID(chatID) if err != nil { slog.Error("daily report: user not found", "user_id", userID) @@ -143,7 +165,6 @@ func (h *Handler) SendDailyReport(userID int64, chatID int64) { return } text := h.buildDailyReport(user, day, events) - reply := tgbotapi.NewMessage(chatID, text) - h.Bot.Send(reply) + h.Bot.SendMessage(ctx, &bot.SendMessageParams{ChatID: chatID, Text: text}) h.DB.MarkReportSent(user.ID, today) } diff --git a/internal/bot/settings.go b/internal/bot/settings.go index 07829ba..77913bd 100644 --- a/internal/bot/settings.go +++ b/internal/bot/settings.go @@ -1,172 +1,174 @@ package bot import ( + "context" "fmt" "strconv" + "strings" "time" - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + bot "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" "worktimeBot/internal/db" ) // handleSetReportTime parses a HH:MM argument and updates the user's report time. -func (h *Handler) handleSetReportTime(msg *tgbotapi.Message) { - arg := msg.CommandArguments() - if arg == "" { +func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message) { + args := "" + if len(msg.Text) > 16 { // "/setreporttime " is 15 chars + args = strings.TrimSpace(msg.Text[15:]) + } + if args == "" { user, err := h.getOrCreateUser(msg.Chat.ID) if err != nil { - h.sendText(msg.Chat.ID, "Error loading profile") + h.sendText(ctx, msg.Chat.ID, "Error loading profile") return } - h.sendText(msg.Chat.ID, fmt.Sprintf("Current report time: %s\nUsage: /setreporttime HH:MM (24-hour)", user.ReportTime)) + h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current report time: %s\nUsage: /setreporttime HH:MM (24-hour)", user.ReportTime)) return } - if len(arg) != 5 || arg[2] != ':' { - h.sendText(msg.Chat.ID, "Invalid format. Usage: /setreporttime HH:MM (24-hour)") + if len(args) != 5 || args[2] != ':' { + h.sendText(ctx, msg.Chat.ID, "Invalid format. Usage: /setreporttime HH:MM (24-hour)") return } - hours, err1 := strconv.Atoi(arg[:2]) - mins, err2 := strconv.Atoi(arg[3:]) + hours, err1 := strconv.Atoi(args[:2]) + mins, err2 := strconv.Atoi(args[3:]) if err1 != nil || err2 != nil || hours < 0 || hours > 23 || mins < 0 || mins > 59 { - h.sendText(msg.Chat.ID, "Invalid time. Usage: /setreporttime HH:MM (24-hour)") + h.sendText(ctx, msg.Chat.ID, "Invalid time. Usage: /setreporttime HH:MM (24-hour)") return } user, err := h.getOrCreateUser(msg.Chat.ID) if err != nil { - h.sendText(msg.Chat.ID, "Error updating report time") + h.sendText(ctx, msg.Chat.ID, "Error updating report time") return } - user.ReportTime = arg + user.ReportTime = args if err := h.DB.UpdateUser(user); err != nil { - h.sendText(msg.Chat.ID, "Error updating report time") + h.sendText(ctx, msg.Chat.ID, "Error updating report time") return } - h.sendText(msg.Chat.ID, fmt.Sprintf("Report time set to %s", arg)) + h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Report time set to %s", args)) } // handleAccentMsg shows the accent color picker. -func (h *Handler) handleAccentMsg(msg *tgbotapi.Message) { +func (h *Handler) handleAccentMsg(ctx context.Context, msg *models.Message) { user, err := h.getOrCreateUser(msg.Chat.ID) if err != nil { - h.sendText(msg.Chat.ID, "Error loading profile") + h.sendText(ctx, msg.Chat.ID, "Error loading profile") return } text, kb := h.buildAccentKeyboard(user) - reply := tgbotapi.NewMessage(msg.Chat.ID, text) - reply.ReplyMarkup = &kb - h.Bot.Send(reply) + h.Bot.SendMessage(ctx, &bot.SendMessageParams{ChatID: msg.Chat.ID, Text: text, ReplyMarkup: &kb}) } // handleSetTimezone validates and updates the user's IANA timezone. -func (h *Handler) handleSetTimezone(msg *tgbotapi.Message) { - arg := msg.CommandArguments() - if arg == "" { +func (h *Handler) handleSetTimezone(ctx context.Context, msg *models.Message) { + args := "" + if len(msg.Text) > 13 { // "/settimezone " is 12 chars + args = strings.TrimSpace(msg.Text[13:]) + } + if args == "" { user, err := h.getOrCreateUser(msg.Chat.ID) if err != nil { - h.sendText(msg.Chat.ID, "Error loading profile") + h.sendText(ctx, msg.Chat.ID, "Error loading profile") return } - h.sendText(msg.Chat.ID, fmt.Sprintf("Current timezone: %s\nUsage: /settimezone \nExamples: Asia/Tehran, Europe/London, America/New_York", user.Timezone)) + h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current timezone: %s\nUsage: /settimezone \nExamples: Asia/Tehran, Europe/London, America/New_York", user.Timezone)) return } - loc, err := time.LoadLocation(arg) + loc, err := time.LoadLocation(args) if err != nil { - h.sendText(msg.Chat.ID, fmt.Sprintf("Invalid timezone: %s\nUse IANA format, e.g. Asia/Tehran, Europe/London", arg)) + h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Invalid timezone: %s\nUse IANA format, e.g. Asia/Tehran, Europe/London", args)) return } user, err := h.getOrCreateUser(msg.Chat.ID) if err != nil { - h.sendText(msg.Chat.ID, "Error loading profile") + h.sendText(ctx, msg.Chat.ID, "Error loading profile") return } - user.Timezone = arg + user.Timezone = args if err := h.DB.UpdateUser(user); err != nil { - h.sendText(msg.Chat.ID, "Error saving timezone") + h.sendText(ctx, msg.Chat.ID, "Error saving timezone") return } now := time.Now().In(loc) - h.sendText(msg.Chat.ID, fmt.Sprintf("Timezone set to %s (current time: %s)", arg, now.Format("15:04"))) + h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Timezone set to %s (current time: %s)", args, now.Format("15:04"))) } // settingsCallback shows the settings inline menu. -func (h *Handler) settingsCallback(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) +func (h *Handler) settingsCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { + h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}) user, err := h.getOrCreateUser(chatID) if err != nil { return } text, kb := h.buildSettingsKeyboard(user) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) + h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: chatID, + MessageID: msgID, + Text: text, + ReplyMarkup: &kb, + }) } // buildSettingsKeyboard returns the settings menu text and inline keyboard. -func (h *Handler) buildSettingsKeyboard(user *db.User) (string, tgbotapi.InlineKeyboardMarkup) { +func (h *Handler) buildSettingsKeyboard(user *db.User) (string, models.InlineKeyboardMarkup) { reportStatus := "disabled" if user.ReportEnabled { reportStatus = "enabled" } - rows := [][]tgbotapi.InlineKeyboardButton{ - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Timezone: %s", user.Timezone), "timezone"), - ), - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Accent: %s", user.ExportAccent), "accent"), - ), - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Calendar: %s", user.Calendar), "caltype"), - ), - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Report: %s", reportStatus), "reporttoggle"), - ), - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Report time: %s", user.ReportTime), "reporttime"), - ), - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("History", "history"), - ), - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), - ), + rows := [][]models.InlineKeyboardButton{ + {{Text: fmt.Sprintf("Timezone: %s", user.Timezone), CallbackData: "timezone"}}, + {{Text: fmt.Sprintf("Accent: %s", user.ExportAccent), CallbackData: "accent"}}, + {{Text: fmt.Sprintf("Calendar: %s", user.Calendar), CallbackData: "caltype"}}, + {{Text: fmt.Sprintf("Report: %s", reportStatus), CallbackData: "reporttoggle"}}, + {{Text: fmt.Sprintf("Report time: %s", user.ReportTime), CallbackData: "reporttime"}}, + {{Text: "History", CallbackData: "history"}}, + {{Text: "Back to Menu", CallbackData: "back_menu"}}, } - return "Settings:", tgbotapi.NewInlineKeyboardMarkup(rows...) + return "Settings:", models.InlineKeyboardMarkup{InlineKeyboard: rows} } // accentCallback shows the accent color picker (inline edit). -func (h *Handler) accentCallback(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) +func (h *Handler) accentCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { + h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}) user, err := h.getOrCreateUser(chatID) if err != nil { return } text, kb := h.buildAccentKeyboard(user) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) + h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: chatID, + MessageID: msgID, + Text: text, + ReplyMarkup: &kb, + }) } // timezoneCallback shows the current timezone and instructions to change it. -func (h *Handler) timezoneCallback(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) +func (h *Handler) timezoneCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { + h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}) user, err := h.getOrCreateUser(chatID) if err != nil { return } text := fmt.Sprintf("Current timezone: %s\n\nUse /settimezone to change it.\nExamples: Asia/Tehran, Europe/London, America/New_York", user.Timezone) - kb := tgbotapi.NewInlineKeyboardMarkup( - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), - ), - ) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) + kb := models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Back to Settings", CallbackData: "back_settings"}}, + }, + } + h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: chatID, + MessageID: msgID, + Text: text, + ReplyMarkup: &kb, + }) } // buildAccentKeyboard returns the accent color picker keyboard. -func (h *Handler) buildAccentKeyboard(user *db.User) (string, tgbotapi.InlineKeyboardMarkup) { +func (h *Handler) buildAccentKeyboard(user *db.User) (string, models.InlineKeyboardMarkup) { type accentOption struct { name string color string @@ -177,25 +179,25 @@ func (h *Handler) buildAccentKeyboard(user *db.User) (string, tgbotapi.InlineKey {"rose", "Pink"}, {"catppuccin", "Purple"}, } - rows := [][]tgbotapi.InlineKeyboardButton{} + rows := [][]models.InlineKeyboardButton{} for _, a := range accents { label := fmt.Sprintf("%s (%s)", a.name, a.color) if a.name == user.ExportAccent { label = "> " + label } - rows = append(rows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(label, "accent_"+a.name), - )) + rows = append(rows, []models.InlineKeyboardButton{ + {Text: label, CallbackData: "accent_" + a.name}, + }) } - rows = append(rows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), - )) - return "Select accent color:", tgbotapi.NewInlineKeyboardMarkup(rows...) + rows = append(rows, []models.InlineKeyboardButton{ + {Text: "Back to Settings", CallbackData: "back_settings"}, + }) + return "Select accent color:", models.InlineKeyboardMarkup{InlineKeyboard: rows} } // selectAccent sets the user's export accent color after validation. -func (h *Handler) selectAccent(chatID int64, msgID int, callbackID, name string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) +func (h *Handler) selectAccent(ctx context.Context, chatID int64, msgID int, callbackID, name string) { + h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}) user, err := h.getOrCreateUser(chatID) if err != nil { return @@ -208,87 +210,99 @@ func (h *Handler) selectAccent(chatID int64, msgID int, callbackID, name string) if err := h.DB.UpdateUser(user); err != nil { return } - edit := tgbotapi.NewEditMessageText(chatID, msgID, fmt.Sprintf("Accent set to: %s", name)) - kb := tgbotapi.NewInlineKeyboardMarkup( - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), - ), - ) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) + kb := models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Back to Settings", CallbackData: "back_settings"}}, + }, + } + h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: chatID, + MessageID: msgID, + Text: fmt.Sprintf("Accent set to: %s", name), + ReplyMarkup: &kb, + }) } // reportTimeCallback shows the current report time setting. -func (h *Handler) reportTimeCallback(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) +func (h *Handler) reportTimeCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { + h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}) user, err := h.getOrCreateUser(chatID) if err != nil { return } text := fmt.Sprintf("Current report time: %s\nUse /setreporttime HH:MM to change it.", user.ReportTime) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - kb := tgbotapi.NewInlineKeyboardMarkup( - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), - ), - ) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) + kb := models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + {{Text: "Back to Settings", CallbackData: "back_settings"}}, + }, + } + h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: chatID, + MessageID: msgID, + Text: text, + ReplyMarkup: &kb, + }) } // backToSettings returns to the main settings menu. -func (h *Handler) backToSettings(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) +func (h *Handler) backToSettings(ctx context.Context, chatID int64, msgID int, callbackID string) { + h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}) user, err := h.getOrCreateUser(chatID) if err != nil { return } text, kb := h.buildSettingsKeyboard(user) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) + h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: chatID, + MessageID: msgID, + Text: text, + ReplyMarkup: &kb, + }) } // calTypeCallback shows the calendar type selector. -func (h *Handler) calTypeCallback(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) +func (h *Handler) calTypeCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { + h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}) user, err := h.getOrCreateUser(chatID) if err != nil { return } text, kb := h.buildCalendarKeyboard(user) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) + h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: chatID, + MessageID: msgID, + Text: text, + ReplyMarkup: &kb, + }) } // buildCalendarKeyboard returns the calendar type selector keyboard. -func (h *Handler) buildCalendarKeyboard(user *db.User) (string, tgbotapi.InlineKeyboardMarkup) { +func (h *Handler) buildCalendarKeyboard(user *db.User) (string, models.InlineKeyboardMarkup) { cals := []string{"gregorian", "jalali", "hijri"} calLabels := map[string]string{ "gregorian": "Gregorian", "jalali": "Jalali", "hijri": "Hijri", } - rows := [][]tgbotapi.InlineKeyboardButton{} + rows := [][]models.InlineKeyboardButton{} for _, c := range cals { label := calLabels[c] if c == user.Calendar { label = "> " + label } - rows = append(rows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(label, "caltype_"+c), - )) + rows = append(rows, []models.InlineKeyboardButton{ + {Text: label, CallbackData: "caltype_" + c}, + }) } - rows = append(rows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), - )) - return "Select calendar type:", tgbotapi.NewInlineKeyboardMarkup(rows...) + rows = append(rows, []models.InlineKeyboardButton{ + {Text: "Back to Settings", CallbackData: "back_settings"}, + }) + return "Select calendar type:", models.InlineKeyboardMarkup{InlineKeyboard: rows} } // selectCalendar sets the user's calendar after validation. -func (h *Handler) selectCalendar(chatID int64, msgID int, callbackID, name string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) +func (h *Handler) selectCalendar(ctx context.Context, chatID int64, msgID int, callbackID, name string) { + h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}) valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true} if !valid[name] { return @@ -302,7 +316,10 @@ func (h *Handler) selectCalendar(chatID int64, msgID int, callbackID, name strin return } text, kb := h.buildSettingsKeyboard(user) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - edit.ReplyMarkup = &kb - h.Bot.Send(edit) + h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: chatID, + MessageID: msgID, + Text: text, + ReplyMarkup: &kb, + }) }