refactor: migrate from go-telegram-bot-api/v5 to go-telegram/bot

- Replaced tgbotapi with go-telegram/bot (zero-dependency, context-aware, Bot API 10.0)
- All handler methods now accept context.Context; threading ctx through all sends/edits
- Changed from function-based (NewMessage/NewEditMessageText/NewInlineKeyboardMarkup) to struct-param API (SendMessageParams/EditMessageTextParams/InlineKeyboardMarkup)
- Added colored buttons: DEL buttons in calendar use Style: 'danger' (red)
- Both polling and webhook modes preserved with new library patterns
- Context-based shutdown (signal.NotifyContext) replaces stop channel
This commit is contained in:
2026-06-24 14:56:50 +03:30
parent fb2d0ef7d1
commit 49c0e82bac
9 changed files with 581 additions and 580 deletions

View File

@@ -1,17 +1,17 @@
package main package main
import ( import (
"context"
"log/slog" "log/slog"
"net/http"
"os" "os"
"os/signal" "os/signal"
"strconv" "strconv"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "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" "github.com/joho/godotenv"
"worktimeBot/internal/bot" "worktimeBot/internal/bot"
@@ -28,14 +28,6 @@ func main() {
token := os.Getenv("BOT_TOKEN") token := os.Getenv("BOT_TOKEN")
dbPath := getEnvDefault("DB_PATH", "db.sqlite3") 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) dbStore, err := db.NewStore(dbPath)
if err != nil { if err != nil {
slog.Error("failed to open database", "path", dbPath, "error", err) slog.Error("failed to open database", "path", dbPath, "error", err)
@@ -43,108 +35,61 @@ func main() {
} }
allowedUsers := parseAllowedUsers(os.Getenv("BOT_ALLOWED_USERS")) allowedUsers := parseAllowedUsers(os.Getenv("BOT_ALLOWED_USERS"))
handler := bot.NewHandler(botAPI, dbStore, allowedUsers)
stop := make(chan struct{}) var h *bot.Handler
var wg sync.WaitGroup
if url := os.Getenv("BOT_WEBHOOK_URL"); url != "" { dispatch := func(ctx context.Context, b *tgbot.Bot, update *models.Update) {
wg.Add(1) if update.Message != nil {
go func() { h.HandleMessage(ctx, update.Message)
defer wg.Done() }
startWebhook(botAPI, handler, url) if update.CallbackQuery != nil {
}() h.HandleCallback(ctx, update.CallbackQuery)
} else {
if _, err := botAPI.Request(tgbotapi.DeleteWebhookConfig{}); err != nil {
slog.Error("delete webhook", "error", err)
} }
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", b, err := tgbot.New(token, tgbot.WithDefaultHandler(dispatch))
"bot_user", botAPI.Self.UserName, if err != nil {
"allowed_users", len(allowedUsers), slog.Error("failed to create bot", "error", err)
"mode", map[bool]string{true: "webhook", false: "polling"}[os.Getenv("BOT_WEBHOOK_URL") != ""], 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() 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. // 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) ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-stop: case <-ctx.Done():
return return
case <-ticker.C: 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. // 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() users, err := store.GetAllUsers()
if err != nil { if err != nil {
slog.Error("report scheduler: get users", "error", err) 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", slog.Info("sending daily report",
"user_id", u.ID, "chat_id", u.ChatID, "timezone", u.Timezone, "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. // getEnvDefault returns the environment variable value or a default if unset or empty.
func getEnvDefault(key, defaultValue string) string { func getEnvDefault(key, defaultValue string) string {
if val := os.Getenv(key); val != "" { if val := os.Getenv(key); val != "" {

View File

@@ -1,34 +1,25 @@
package main package main
import ( import (
"context"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" tgbot "github.com/go-telegram/bot"
"worktimeBot/internal/bot" "worktimeBot/internal/bot"
) )
// startWebhook registers the webhook with Telegram and starts the HTTPS/HTTP listener loop. // startWebhook registers the webhook with Telegram and starts the HTTPS/HTTP listener loop.
func startWebhook(botAPI *tgbotapi.BotAPI, handler *bot.Handler, webhookURL string) { func startWebhook(ctx context.Context, b *tgbot.Bot, h *bot.Handler, webhookURL string) {
if _, err := botAPI.Request(tgbotapi.DeleteWebhookConfig{}); err != nil { b.DeleteWebhook(ctx, &tgbot.DeleteWebhookParams{})
slog.Error("delete webhook", "error", err)
os.Exit(1)
}
wh, err := tgbotapi.NewWebhook(webhookURL) if _, err := b.SetWebhook(ctx, &tgbot.SetWebhookParams{URL: webhookURL}); err != nil {
if err != nil {
slog.Error("new webhook", "error", err)
os.Exit(1)
}
if _, err := botAPI.Request(wh); err != nil {
slog.Error("set webhook", "error", err) slog.Error("set webhook", "error", err)
os.Exit(1) os.Exit(1)
} }
updates := botAPI.ListenForWebhook("/webhook")
listenAddr := getEnvDefault("BOT_LISTEN", ":8080") listenAddr := getEnvDefault("BOT_LISTEN", ":8080")
tlsCert := os.Getenv("BOT_TLS_CERT") tlsCert := os.Getenv("BOT_TLS_CERT")
tlsKey := os.Getenv("BOT_TLS_KEY") tlsKey := os.Getenv("BOT_TLS_KEY")
@@ -36,7 +27,7 @@ func startWebhook(botAPI *tgbotapi.BotAPI, handler *bot.Handler, webhookURL stri
if tlsCert != "" && tlsKey != "" { if tlsCert != "" && tlsKey != "" {
slog.Info("starting HTTPS webhook", "addr", listenAddr) slog.Info("starting HTTPS webhook", "addr", listenAddr)
go func() { 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) slog.Error("HTTPS server", "error", err)
os.Exit(1) os.Exit(1)
} }
@@ -44,7 +35,7 @@ func startWebhook(botAPI *tgbotapi.BotAPI, handler *bot.Handler, webhookURL stri
} else { } else {
slog.Info("starting HTTP webhook (TLS by reverse proxy)", "addr", listenAddr) slog.Info("starting HTTP webhook (TLS by reverse proxy)", "addr", listenAddr)
go func() { 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) slog.Error("HTTP server", "error", err)
os.Exit(1) os.Exit(1)
} }
@@ -52,13 +43,5 @@ func startWebhook(botAPI *tgbotapi.BotAPI, handler *bot.Handler, webhookURL stri
} }
slog.Info("webhook registered", "url", webhookURL) slog.Info("webhook registered", "url", webhookURL)
b.StartWebhook(ctx)
for update := range updates {
if update.Message != nil {
handler.HandleMessage(update)
}
if update.CallbackQuery != nil {
handler.HandleCallback(update)
}
}
} }

2
go.mod
View File

@@ -3,7 +3,7 @@ module worktimeBot
go 1.26.4 go 1.26.4
require ( 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/joho/godotenv v1.5.1
github.com/xuri/excelize/v2 v2.10.1 github.com/xuri/excelize/v2 v2.10.1
modernc.org/sqlite v1.53.0 modernc.org/sqlite v1.53.0

4
go.sum
View File

@@ -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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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 v1.21.0 h1:Va/PbGc2vBDdv57GCUEEVV6ROlHWiC6SklJY9Hvhzps=
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/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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=

View File

@@ -1,55 +1,58 @@
package bot package bot
import ( import (
"context"
"database/sql" "database/sql"
"fmt" "fmt"
"strings"
"time" "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" "worktimeBot/internal/db"
) )
// handleHistoryMsg shows the calendar view (new message). // handleHistoryMsg shows the calendar view (new message).
func (h *Handler) handleHistoryMsg(msg *tgbotapi.Message) { func (h *Handler) handleHistoryMsg(ctx context.Context, msg *models.Message) {
h.sendCalendar(msg.Chat.ID, 0, 0, 0) h.sendCalendar(ctx, msg.Chat.ID, 0, 0, 0)
} }
// historyCallback opens or refreshes the calendar view (inline). // historyCallback opens or refreshes the calendar view (inline).
func (h *Handler) historyCallback(chatID int64, msgID int, callbackID string) { func (h *Handler) historyCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) defer h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID})
h.editCalendar(chatID, msgID, 0, 0) h.editCalendar(ctx, chatID, msgID, 0, 0)
} }
// handleEditMsg parses a /edit YYYY-MM-DD command and shows that day's events. // handleEditMsg parses a /edit YYYY-MM-DD command and shows that day's events.
func (h *Handler) handleEditMsg(msg *tgbotapi.Message) { func (h *Handler) handleEditMsg(ctx context.Context, msg *models.Message) {
date := msg.CommandArguments() date := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/edit"))
if date == "" { 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 return
} }
if len(date) != 10 || date[4] != '-' || date[7] != '-' { 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 return
} }
_, m, d := parseGregorianDateKey(date) _, m, d := parseGregorianDateKey(date)
if m < 1 || m > 12 || d < 1 || d > 31 { 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 return
} }
user, err := h.getOrCreateUser(msg.Chat.ID) user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil { if err != nil {
h.sendText(msg.Chat.ID, "Error loading profile") h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return return
} }
loc := loadLocation(user.Timezone) loc := loadLocation(user.Timezone)
date = userDateToGregorian(date, user.Calendar) 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. // handleHistoryCallback routes calendar-related callback data to the right handler.
func (h *Handler) handleHistoryCallback(chatID int64, msgID int, callbackID, data string) { func (h *Handler) handleHistoryCallback(ctx context.Context, chatID int64, msgID int, callbackID, data string) {
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) defer h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID})
user, err := h.getOrCreateUser(chatID) user, err := h.getOrCreateUser(chatID)
if err != nil { if err != nil {
return return
@@ -57,7 +60,7 @@ func (h *Handler) handleHistoryCallback(chatID int64, msgID int, callbackID, dat
loc := loadLocation(user.Timezone) loc := loadLocation(user.Timezone)
if data == "history" { if data == "history" {
h.editCalendar(chatID, msgID, 0, 0) h.editCalendar(ctx, chatID, msgID, 0, 0)
return return
} }
@@ -67,70 +70,70 @@ func (h *Handler) handleHistoryCallback(chatID int64, msgID int, callbackID, dat
// Navigate to previous month // Navigate to previous month
if n, _ := fmt.Sscanf(data, "cal_prev_%d_%d", &y, &m); n == 2 { 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 return
} }
// Navigate to next month // Navigate to next month
if n, _ := fmt.Sscanf(data, "cal_next_%d_%d", &y, &m); n == 2 { 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 return
} }
// Open a specific day // Open a specific day
if n, _ := fmt.Sscanf(data, "cal_day_%s", &date); n == 1 && len(date) == 10 { 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 return
} }
// Back to day view from event editing // Back to day view from event editing
if n, _ := fmt.Sscanf(data, "back_day_%d", &eid); n == 1 { 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 return
} }
// Set event work type // Set event work type
var wtid int64 var wtid int64
if n, _ := fmt.Sscanf(data, "settype_%d_%d", &eid, &wtid); n == 2 { 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 return
} }
// Show work type picker for an event // Show work type picker for an event
if n, _ := fmt.Sscanf(data, "edit_type_%d", &eid); n == 1 { 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 return
} }
// Delete event confirmation prompt // Delete event confirmation prompt
if n, _ := fmt.Sscanf(data, "delete_%d", &eid); n == 1 { 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 return
} }
// Confirm event deletion // Confirm event deletion
if n, _ := fmt.Sscanf(data, "delconf_%d", &eid); n == 1 { 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 return
} }
// Show hour picker for event time // Show hour picker for event time
if n, _ := fmt.Sscanf(data, "edit_time_%d", &eid); n == 1 { 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 return
} }
// Set event time (hour+minute) // Set event time (hour+minute)
var hh, mm int var hh, mm int
if n, _ := fmt.Sscanf(data, "edittm_%d_%d_%d", &eid, &hh, &mm); n == 3 { 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 return
} }
// Show minute picker (hour already chosen) // Show minute picker (hour already chosen)
if n, _ := fmt.Sscanf(data, "edittm_%d_%d", &eid, &hh); n == 2 { 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 return
} }
} }
// sendCalendar sends a calendar view as a new message. // sendCalendar sends a calendar view as a new message.
func (h *Handler) sendCalendar(chatID int64, msgID int, year, month int) { func (h *Handler) sendCalendar(ctx context.Context, chatID int64, msgID int, year, month int) {
h.editCalendar(chatID, msgID, year, month) h.editCalendar(ctx, chatID, msgID, year, month)
} }
// editCalendar renders a monthly calendar grid with event indicators and navigation. // 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) user, err := h.getOrCreateUser(chatID)
if err != nil { if err != nil {
return return
@@ -172,20 +175,20 @@ func (h *Handler) editCalendar(chatID int64, msgID int, year, month int) {
rows.Close() rows.Close()
} }
kbRows := [][]tgbotapi.InlineKeyboardButton{} kbRows := [][]models.InlineKeyboardButton{}
// Weekday header row // Weekday header row
headerRow := []tgbotapi.InlineKeyboardButton{} headerRow := []models.InlineKeyboardButton{}
for _, wn := range cm.weekDays { 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) kbRows = append(kbRows, headerRow)
// Day cells // Day cells
row := []tgbotapi.InlineKeyboardButton{} row := []models.InlineKeyboardButton{}
for i, d := range cm.days { for i, d := range cm.days {
if d.dayNum == 0 { if d.dayNum == 0 {
row = append(row, tgbotapi.NewInlineKeyboardButtonData(" ", "noop")) row = append(row, models.InlineKeyboardButton{Text: " ", CallbackData: "noop"})
} else { } else {
label := fmt.Sprintf("%d", d.dayNum) label := fmt.Sprintf("%d", d.dayNum)
if d.date == todayKey { if d.date == todayKey {
@@ -193,12 +196,12 @@ func (h *Handler) editCalendar(chatID int64, msgID int, year, month int) {
} else if hasEvent[d.date] { } else if hasEvent[d.date] {
label = fmt.Sprintf("%d*", d.dayNum) 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 { if len(row) == 7 || i == len(cm.days)-1 {
// Pad the last row to 7 columns so buttons render evenly // Pad the last row to 7 columns so buttons render evenly
for len(row) < 7 { for len(row) < 7 {
row = append(row, tgbotapi.NewInlineKeyboardButtonData(" ", "noop")) row = append(row, models.InlineKeyboardButton{Text: " ", CallbackData: "noop"})
} }
kbRows = append(kbRows, row) kbRows = append(kbRows, row)
row = nil row = nil
@@ -208,31 +211,31 @@ func (h *Handler) editCalendar(chatID int64, msgID int, year, month int) {
prevY, prevM, nextY, nextM := navMonth(year, month) prevY, prevM, nextY, nextM := navMonth(year, month)
// Navigation row // Navigation row
kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( kbRows = append(kbRows, []models.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("<", fmt.Sprintf("cal_prev_%d_%d", prevY, prevM)), {Text: "<", CallbackData: fmt.Sprintf("cal_prev_%d_%d", prevY, prevM)},
tgbotapi.NewInlineKeyboardButtonData("Today", "history"), {Text: "Today", CallbackData: "history"},
tgbotapi.NewInlineKeyboardButtonData(">", fmt.Sprintf("cal_next_%d_%d", nextY, nextM)), {Text: ">", CallbackData: fmt.Sprintf("cal_next_%d_%d", nextY, nextM)},
)) })
kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( kbRows = append(kbRows, []models.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), {Text: "Back to Menu", CallbackData: "back_menu"},
)) })
kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
h.sendOrEdit(chatID, msgID, text, &kb) h.sendOrEdit(ctx, chatID, msgID, text, &kb)
} }
// editDayView opens an existing message as a day view. // 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) { func (h *Handler) editDayView(ctx context.Context, chatID int64, msgID int, user *db.User, date string, loc *time.Location) {
h.sendDayView(chatID, msgID, user, date, loc, false) h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
} }
// sendDayView displays all events for a given date with inline edit/delete buttons. // 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) events, err := h.DB.EventsForDayByDate(user.ID, date)
if err != nil { if err != nil {
if isNewMsg { if isNewMsg {
h.sendText(chatID, "Error loading events") h.sendText(ctx, chatID, "Error loading events")
} }
return 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)) text := fmt.Sprintf("Date: %s", formatDateForCalendar(date, user.Calendar))
if len(events) == 0 { if len(events) == 0 {
text += "\nNo events for this day." text += "\nNo events for this day."
kb := tgbotapi.NewInlineKeyboardMarkup( kb := models.InlineKeyboardMarkup{
tgbotapi.NewInlineKeyboardRow( InlineKeyboard: [][]models.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("Back to Calendar", "history"), {{Text: "Back to Calendar", CallbackData: "history"}},
), },
) }
h.sendOrEdit(chatID, msgID, text, &kb) h.sendOrEdit(ctx, chatID, msgID, text, &kb)
return return
} }
text += "\n\nEvents:" text += "\n\nEvents:"
kbRows := [][]tgbotapi.InlineKeyboardButton{} kbRows := [][]models.InlineKeyboardButton{}
for _, e := range events { for _, e := range events {
t := time.Unix(e.OccurredAt, 0).In(loc).Format("15:04") t := time.Unix(e.OccurredAt, 0).In(loc).Format("15:04")
label := "IN" 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) text += fmt.Sprintf("\n%s %s%s%s", label, t, wt, note)
kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( kbRows = append(kbRows, []models.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Time %s", t), fmt.Sprintf("edit_time_%d", e.ID)), {Text: fmt.Sprintf("Time %s", t), CallbackData: fmt.Sprintf("edit_time_%d", e.ID)},
tgbotapi.NewInlineKeyboardButtonData("Type", fmt.Sprintf("edit_type_%d", e.ID)), {Text: "Type", CallbackData: fmt.Sprintf("edit_type_%d", e.ID)},
tgbotapi.NewInlineKeyboardButtonData("DEL", fmt.Sprintf("delete_%d", e.ID)), {Text: "DEL", CallbackData: fmt.Sprintf("delete_%d", e.ID), Style: "danger"},
)) })
} }
kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( kbRows = append(kbRows, []models.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("Back to Calendar", "history"), {Text: "Back to Calendar", CallbackData: "history"},
)) })
kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
h.sendOrEdit(chatID, msgID, text, &kb) h.sendOrEdit(ctx, chatID, msgID, text, &kb)
} }
// editEventType shows a work type picker for a specific event. // 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() wts, err := h.DB.GetWorkTypes()
if err != nil { if err != nil {
return return
} }
text := "Select new work type:" text := "Select new work type:"
kbRows := [][]tgbotapi.InlineKeyboardButton{} kbRows := [][]models.InlineKeyboardButton{}
for _, wt := range wts { for _, wt := range wts {
kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( kbRows = append(kbRows, []models.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData(wt.Name, fmt.Sprintf("settype_%d_%d", eventID, wt.ID)), {Text: wt.Name, CallbackData: fmt.Sprintf("settype_%d_%d", eventID, wt.ID)},
)) })
} }
kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( kbRows = append(kbRows, []models.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("Back", fmt.Sprintf("back_day_%d", eventID)), {Text: "Back", CallbackData: fmt.Sprintf("back_day_%d", eventID)},
)) })
kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
edit := tgbotapi.NewEditMessageText(chatID, msgID, text) h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: text, ReplyMarkup: &kb})
edit.ReplyMarkup = &kb
h.Bot.Send(edit)
} }
// setEventWorkType updates an event's work type and returns to the day view. // 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) h.DB.DB().Exec("UPDATE events SET work_type_id=? WHERE id=?", workTypeID, eventID)
event, err := h.getEventByID(user.ID, eventID) event, err := h.getEventByID(user.ID, eventID)
if err != nil { if err != nil {
return return
} }
t := time.Unix(event.OccurredAt, 0).In(loc) 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. // 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:" text := "Select hour:"
kbRows := [][]tgbotapi.InlineKeyboardButton{} kbRows := [][]models.InlineKeyboardButton{}
hourRow := []tgbotapi.InlineKeyboardButton{} hourRow := []models.InlineKeyboardButton{}
for hh := 0; hh < 24; hh++ { for hh := 0; hh < 24; hh++ {
hourRow = append(hourRow, tgbotapi.NewInlineKeyboardButtonData( hourRow = append(hourRow, models.InlineKeyboardButton{
fmt.Sprintf("%02d", hh), Text: fmt.Sprintf("%02d", hh),
fmt.Sprintf("edittm_%d_%02d", eventID, hh), CallbackData: fmt.Sprintf("edittm_%d_%02d", eventID, hh),
)) })
if len(hourRow) == 6 || hh == 23 { if len(hourRow) == 6 || hh == 23 {
kbRows = append(kbRows, hourRow) kbRows = append(kbRows, hourRow)
hourRow = nil hourRow = nil
} }
} }
kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( kbRows = append(kbRows, []models.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("Back", fmt.Sprintf("back_day_%d", eventID)), {Text: "Back", CallbackData: fmt.Sprintf("back_day_%d", eventID)},
)) })
kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
edit := tgbotapi.NewEditMessageText(chatID, msgID, text) h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: text, ReplyMarkup: &kb})
edit.ReplyMarkup = &kb
h.Bot.Send(edit)
} }
// editEventTimeMin shows a minute picker (15-min intervals) after hour selection. // 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) text := fmt.Sprintf("Select minute for hour %02d:", hh)
kbRows := [][]tgbotapi.InlineKeyboardButton{} kbRows := [][]models.InlineKeyboardButton{}
minRow := []tgbotapi.InlineKeyboardButton{} minRow := []models.InlineKeyboardButton{}
for _, mm := range []int{0, 15, 30, 45} { for _, mm := range []int{0, 15, 30, 45} {
minRow = append(minRow, tgbotapi.NewInlineKeyboardButtonData( minRow = append(minRow, models.InlineKeyboardButton{
fmt.Sprintf("%02d", mm), Text: fmt.Sprintf("%02d", mm),
fmt.Sprintf("edittm_%d_%02d_%02d", eventID, hh, mm), CallbackData: fmt.Sprintf("edittm_%d_%02d_%02d", eventID, hh, mm),
)) })
} }
kbRows = append(kbRows, minRow) kbRows = append(kbRows, minRow)
kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( kbRows = append(kbRows, []models.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("Back", fmt.Sprintf("edit_time_%d", eventID)), {Text: "Back", CallbackData: fmt.Sprintf("edit_time_%d", eventID)},
)) })
kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
edit := tgbotapi.NewEditMessageText(chatID, msgID, text) h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: text, ReplyMarkup: &kb})
edit.ReplyMarkup = &kb
h.Bot.Send(edit)
} }
// editEventTimeSet applies a new time to an event, checking for overlaps. // 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) event, err := h.getEventByID(user.ID, eventID)
if err != nil { if err != nil {
return return
@@ -380,8 +377,8 @@ func (h *Handler) editEventTimeSet(chatID int64, msgID int, user *db.User, event
} }
eTime := time.Unix(e.OccurredAt, 0) eTime := time.Unix(e.OccurredAt, 0)
if newTime.Unix() == eTime.Unix() { if newTime.Unix() == eTime.Unix() {
h.acknowledgeCallback(chatID, msgID) h.acknowledgeCallback(ctx, chatID, msgID)
h.sendDayView(chatID, msgID, user, date, loc, false) h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
return return
} }
} }
@@ -410,37 +407,36 @@ func (h *Handler) editEventTimeSet(chatID int64, msgID int, user *db.User, event
} }
for i := 1; i < len(sorted); i++ { for i := 1; i < len(sorted); i++ {
if sorted[i].typ == sorted[i-1].typ { 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.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: "Edit rejected: overlapping events. Two consecutive events must be different types (in/out)."})
h.Bot.Send(edit)
return return
} }
} }
} }
h.DB.DB().Exec("UPDATE events SET occurred_at=? WHERE id=?", newTime.Unix(), eventID) 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. // acknowledgeCallback sends an empty acknowledgement for a callback query.
func (h *Handler) acknowledgeCallback(chatID int64, msgID int) { func (h *Handler) acknowledgeCallback(ctx context.Context, chatID int64, msgID int) {
h.Bot.Request(tgbotapi.NewCallback(fmt.Sprintf("cb_%d_%d", chatID, msgID), "")) h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: fmt.Sprintf("cb_%d_%d", chatID, msgID)})
} }
// deleteEventPrompt asks the user to confirm event deletion. // deleteEventPrompt asks the user to confirm event deletion.
func (h *Handler) deleteEventPrompt(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { func (h *Handler) deleteEventPrompt(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) {
edit := tgbotapi.NewEditMessageText(chatID, msgID, "Delete this event?") kb := models.InlineKeyboardMarkup{
kb := tgbotapi.NewInlineKeyboardMarkup( InlineKeyboard: [][]models.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardRow( {
tgbotapi.NewInlineKeyboardButtonData("Yes, DEL", fmt.Sprintf("delconf_%d", eventID)), {Text: "Yes, DEL", CallbackData: fmt.Sprintf("delconf_%d", eventID), Style: "danger"},
tgbotapi.NewInlineKeyboardButtonData("Cancel", fmt.Sprintf("back_day_%d", eventID)), {Text: "Cancel", CallbackData: fmt.Sprintf("back_day_%d", eventID)},
), },
) },
edit.ReplyMarkup = &kb }
h.Bot.Send(edit) 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. // 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) event, err := h.getEventByID(user.ID, eventID)
if err != nil { if err != nil {
return return
@@ -461,21 +457,21 @@ func (h *Handler) deleteEventConfirm(chatID int64, msgID int, user *db.User, eve
} }
} }
if hasIssue { 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. // 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) event, err := h.getEventByID(user.ID, eventID)
if err != nil { if err != nil {
return return
} }
t := time.Unix(event.OccurredAt, 0).In(loc) 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. // getEventByID fetches a single event by ID, scoped to the user.

View File

@@ -1,11 +1,15 @@
package bot package bot
import ( import (
"bytes"
"context"
"fmt" "fmt"
"log/slog" "log/slog"
"strings"
"time" "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" "github.com/xuri/excelize/v2"
"worktimeBot/internal/db" "worktimeBot/internal/db"
@@ -224,10 +228,10 @@ func fmtDDHHMM(seconds int64) string {
} }
// handleExport processes the /export command, showing the month picker or exporting directly. // 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) user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil { if err != nil {
h.sendText(msg.Chat.ID, "Error loading profile") h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return return
} }
loc := loadLocation(user.Timezone) loc := loadLocation(user.Timezone)
@@ -241,23 +245,27 @@ func (h *Handler) handleExport(msg *tgbotapi.Message) {
cy, cm, _ = gregorianToHijri(cy, cm, now.Day()) cy, cm, _ = gregorianToHijri(cy, cm, now.Day())
} }
if len(msg.Text) > 7 { args := strings.Fields(msg.Text)
if n, _ := fmt.Sscanf(msg.Text[7:], "%d-%d", &cy, &cm); n == 2 { 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 { 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 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. // 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) last, err := h.DB.GetLastEvent(user.ID)
if err == nil && last != nil && last.EventType == "in" { 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 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) data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, calYear, calMonth, start, end)
if err != nil { if err != nil {
slog.Error("generate report", "error", err) slog.Error("generate report", "error", err)
h.sendText(chatID, "Error generating report") h.sendText(ctx, chatID, "Error generating report")
return return
} }
slog.Info("report generated", "bytes", len(data)) slog.Info("report generated", "bytes", len(data))
doc := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{ if _, err := h.Bot.SendDocument(ctx, &bot.SendDocumentParams{
Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", calYear, calMonth)), ChatID: chatID,
Bytes: data, Document: &models.InputFileUpload{
}) Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", calYear, calMonth)),
if _, err := h.Bot.Send(doc); err != nil { Data: bytes.NewReader(data),
},
}); err != nil {
slog.Error("send document", "error", err) slog.Error("send document", "error", err)
} }
} }
// exportCallback opens the export month picker. // exportCallback opens the export month picker.
func (h *Handler) exportCallback(chatID int64, msgID int, callbackID string) { func (h *Handler) exportCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) defer h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID})
user, err := h.getOrCreateUser(chatID) user, err := h.getOrCreateUser(chatID)
if err != nil { if err != nil {
return return
@@ -306,16 +316,16 @@ func (h *Handler) exportCallback(chatID int64, msgID int, callbackID string) {
case "hijri": case "hijri":
cy, cm, _ = gregorianToHijri(cy, cm, now.Day()) 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. // sendExportMonthPicker sends the export month picker as a new message.
func (h *Handler) sendExportMonthPicker(chatID int64, msgID int, year, month int, user *db.User) { func (h *Handler) sendExportMonthPicker(ctx context.Context, chatID int64, msgID int, year, month int, user *db.User) {
h.editExportMonthPicker(chatID, msgID, year, month, user) h.editExportMonthPicker(ctx, chatID, msgID, year, month, user)
} }
// editExportMonthPicker renders a year-based month picker with 12 month buttons. // 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 months := gregMonthNames
switch user.Calendar { switch user.Calendar {
case "jalali": case "jalali":
@@ -324,33 +334,36 @@ func (h *Handler) editExportMonthPicker(chatID int64, msgID int, year, _ int, us
months = hijriMonthNames months = hijriMonthNames
} }
kbRows := [][]tgbotapi.InlineKeyboardButton{ kbRows := [][]models.InlineKeyboardButton{
// Year navigation // Year navigation
{ {
tgbotapi.NewInlineKeyboardButtonData("<", fmt.Sprintf("exp_year_prev_%d", year)), {Text: "<", CallbackData: fmt.Sprintf("exp_year_prev_%d", year)},
tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("%d", year), "noop"), {Text: fmt.Sprintf("%d", year), CallbackData: "noop"},
tgbotapi.NewInlineKeyboardButtonData(">", fmt.Sprintf("exp_year_next_%d", year)), {Text: ">", CallbackData: fmt.Sprintf("exp_year_next_%d", year)},
}, },
} }
// 4 columns x 3 rows of month buttons // 4 columns x 3 rows of month buttons
for i := 0; i < 12; i += 4 { for i := 0; i < 12; i += 4 {
row := []tgbotapi.InlineKeyboardButton{} row := []models.InlineKeyboardButton{}
for j := i; j < i+4 && j < 12; j++ { for j := i; j < i+4 && j < 12; j++ {
row = append(row, tgbotapi.NewInlineKeyboardButtonData( row = append(row, models.InlineKeyboardButton{
months[j], Text: months[j], CallbackData: fmt.Sprintf("exp_do_%d_%d", year, j+1),
fmt.Sprintf("exp_do_%d_%d", year, j+1), })
))
} }
kbRows = append(kbRows, row) kbRows = append(kbRows, row)
} }
kbRows = append(kbRows, []tgbotapi.InlineKeyboardButton{ kbRows = append(kbRows, []models.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), {Text: "Back to Menu", CallbackData: "back_menu"},
}) })
kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
h.sendOrEdit(chatID, msgID, "Select month to export:", &kb) 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. // 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). // handleExportCallback routes export inline button presses (year nav, month select).
func (h *Handler) handleExportCallback(chatID int64, msgID int, callbackID, data string) { func (h *Handler) handleExportCallback(ctx context.Context, chatID int64, msgID int, callbackID, data string) {
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) defer h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID})
user, err := h.getOrCreateUser(chatID) user, err := h.getOrCreateUser(chatID)
if err != nil { if err != nil {
return return
@@ -377,12 +390,12 @@ func (h *Handler) handleExportCallback(chatID int64, msgID int, callbackID, data
// Navigate to previous year // Navigate to previous year
if n, _ := fmt.Sscanf(data, "exp_year_prev_%d", &y); n == 1 { 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 return
} }
// Navigate to next year // Navigate to next year
if n, _ := fmt.Sscanf(data, "exp_year_next_%d", &y); n == 1 { 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 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 { if n, _ := fmt.Sscanf(data, "exp_do_%d_%d", &y, &m); n == 2 {
last, err := h.DB.GetLastEvent(user.ID) last, err := h.DB.GetLastEvent(user.ID)
if err == nil && last != nil && last.EventType == "in" { if err == nil && last != nil && last.EventType == "in" {
kb := tgbotapi.NewInlineKeyboardMarkup( h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{
tgbotapi.NewInlineKeyboardRow( ChatID: chatID,
tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), MessageID: msgID,
), Text: "You are currently clocked in. Please clock out first, then try again.",
) ReplyMarkup: &models.InlineKeyboardMarkup{
edit := tgbotapi.NewEditMessageText(chatID, msgID, "You are currently clocked in. Please clock out first, then try again.") InlineKeyboard: [][]models.InlineKeyboardButton{
edit.ReplyMarkup = &kb {
h.Bot.Send(edit) {Text: "Back to Menu", CallbackData: "back_menu"},
},
},
},
})
return return
} }
startStr, endStr := monthGregorianRange(user.Calendar, y, m) startStr, endStr := monthGregorianRange(user.Calendar, y, m)
start, err := time.Parse("2006-01-02", startStr) start, err := time.Parse("2006-01-02", startStr)
if err != nil { if err != nil {
h.sendText(chatID, "Error processing date") h.sendText(ctx, chatID, "Error processing date")
return return
} }
end, err := time.Parse("2006-01-02", endStr) end, err := time.Parse("2006-01-02", endStr)
if err != nil { if err != nil {
h.sendText(chatID, "Error processing date") h.sendText(ctx, chatID, "Error processing date")
return 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) data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, y, m, start, end)
if err != nil { if err != nil {
slog.Error("generate report", "error", err) slog.Error("generate report", "error", err)
edit := tgbotapi.NewEditMessageText(chatID, msgID, "Error generating report") h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{
kb := backKeyboard() ChatID: chatID,
edit.ReplyMarkup = &kb MessageID: msgID,
h.Bot.Send(edit) Text: "Error generating report",
ReplyMarkup: &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{
{Text: "Back to Menu", CallbackData: "back_menu"},
},
},
},
})
return return
} }
slog.Info("report generated", "bytes", len(data)) slog.Info("report generated", "bytes", len(data))
edit := tgbotapi.NewEditMessageText(chatID, msgID, "Report ready:") h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{
kb := backKeyboard() ChatID: chatID,
edit.ReplyMarkup = &kb MessageID: msgID,
h.Bot.Send(edit) Text: "Report ready:",
ReplyMarkup: &models.InlineKeyboardMarkup{
doc := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{ InlineKeyboard: [][]models.InlineKeyboardButton{
Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", y, m)), {
Bytes: data, {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) slog.Error("send document", "error", err)
} }
} }

View File

@@ -1,12 +1,14 @@
package bot package bot
import ( import (
"context"
"fmt" "fmt"
"log/slog" "log/slog"
"sync" "sync"
"time" "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" "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. // Handler holds the bot instance, database store, user allowlist, and rate-limit state.
type Handler struct { type Handler struct {
Bot *tgbotapi.BotAPI Bot *bot.Bot
DB *db.Store DB *db.Store
AllowedUsers map[int64]bool AllowedUsers map[int64]bool
lastMsgTime map[int64]time.Time lastMsgTime map[int64]time.Time
@@ -23,7 +25,7 @@ type Handler struct {
} }
// NewHandler creates a Handler and starts the rate-limit cleanup goroutine. // 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{ h := &Handler{
Bot: bot, Bot: bot,
DB: store, 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. // 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) text, err := fn(msg.Chat.ID)
if err != nil { if err != nil {
h.sendText(msg.Chat.ID, err.Error()) h.sendText(ctx, msg.Chat.ID, err.Error())
return return
} }
reply := tgbotapi.NewMessage(msg.Chat.ID, text) kb := mainKeyboard()
reply.ReplyMarkup = mainKeyboard() h.Bot.SendMessage(ctx, &bot.SendMessageParams{ChatID: msg.Chat.ID, Text: text, ReplyMarkup: kb})
h.Bot.Send(reply)
} }
// handleActionCallback runs an action function and edits the callback message with the result. // 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) { func (h *Handler) handleActionCallback(ctx context.Context, chatID int64, msgID int, callbackID string, fn actionFunc) {
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) defer h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID})
text, err := fn(chatID) text, err := fn(chatID)
if err != nil { if err != nil {
text = err.Error() text = err.Error()
} }
edit := tgbotapi.NewEditMessageText(chatID, msgID, text)
kb := backKeyboard() kb := backKeyboard()
edit.ReplyMarkup = &kb h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: text, ReplyMarkup: kb})
h.Bot.Send(edit)
} }
// HandleMessage routes incoming text messages to the appropriate handler. // HandleMessage routes incoming text messages to the appropriate handler.
func (h *Handler) HandleMessage(update tgbotapi.Update) { func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
msg := update.Message
if len(h.AllowedUsers) > 0 && !h.AllowedUsers[msg.Chat.ID] { if len(h.AllowedUsers) > 0 && !h.AllowedUsers[msg.Chat.ID] {
return return
} }
@@ -104,110 +102,109 @@ func (h *Handler) HandleMessage(update tgbotapi.Update) {
slog.Info("message", "chat_id", msg.Chat.ID, "text", msg.Text) slog.Info("message", "chat_id", msg.Chat.ID, "text", msg.Text)
switch msg.Text { switch msg.Text {
case "/start": case "/start":
h.handleStart(msg) h.handleStart(ctx, msg)
case "/help": case "/help":
h.handleHelp(msg) h.handleHelp(ctx, msg)
case "/clockin": case "/clockin":
h.handleActionMsg(msg, h.clockIn) h.handleActionMsg(ctx, msg, h.clockIn)
case "/clockout": case "/clockout":
h.handleActionMsg(msg, h.clockOut) h.handleActionMsg(ctx, msg, h.clockOut)
case "/worktype": case "/worktype":
h.handleWorkTypeMsg(msg) h.handleWorkTypeMsg(ctx, msg)
case "/report": case "/report":
h.handleActionMsg(msg, h.report) h.handleActionMsg(ctx, msg, h.report)
case "/export": case "/export":
h.handleExport(msg) h.handleExport(ctx, msg)
case "/dayoff": case "/dayoff":
h.handleActionMsg(msg, h.dayOff) h.handleActionMsg(ctx, msg, h.dayOff)
case "/history": case "/history":
h.handleHistoryMsg(msg) h.handleHistoryMsg(ctx, msg)
case "/edit": case "/edit":
h.handleEditMsg(msg) h.handleEditMsg(ctx, msg)
case "/reporttoggle": case "/reporttoggle":
h.handleActionMsg(msg, h.toggleReport) h.handleActionMsg(ctx, msg, h.toggleReport)
case "/setreporttime": case "/setreporttime":
h.handleSetReportTime(msg) h.handleSetReportTime(ctx, msg)
case "/setaccent": case "/setaccent":
h.handleAccentMsg(msg) h.handleAccentMsg(ctx, msg)
case "/settimezone": case "/settimezone":
h.handleSetTimezone(msg) h.handleSetTimezone(ctx, msg)
default: 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. // HandleCallback routes inline callback queries to the appropriate handler.
func (h *Handler) HandleCallback(update tgbotapi.Update) { func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery) {
cb := update.CallbackQuery if len(h.AllowedUsers) > 0 && !h.AllowedUsers[cb.Message.Message.Chat.ID] {
if len(h.AllowedUsers) > 0 && !h.AllowedUsers[cb.Message.Chat.ID] { h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: cb.ID, Text: "Unauthorized"})
h.Bot.Request(tgbotapi.NewCallback(cb.ID, "Unauthorized"))
return return
} }
user, err := h.getOrCreateUser(cb.Message.Chat.ID) user, err := h.getOrCreateUser(cb.Message.Message.Chat.ID)
if err != nil { if err != nil {
h.Bot.Request(tgbotapi.NewCallback(cb.ID, "")) h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: cb.ID})
return return
} }
if h.isRateLimited(user.ID) { if h.isRateLimited(user.ID) {
h.Bot.Request(tgbotapi.NewCallback(cb.ID, "")) h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: cb.ID})
return return
} }
slog.Info("callback", "chat_id", cb.Message.Chat.ID, "data", cb.Data) slog.Info("callback", "chat_id", cb.Message.Message.Chat.ID, "data", cb.Data)
chatID := cb.Message.Chat.ID chatID := cb.Message.Message.Chat.ID
msgID := cb.Message.MessageID msgID := cb.Message.Message.ID
switch cb.Data { switch cb.Data {
case "clockin": case "clockin":
h.handleActionCallback(chatID, msgID, cb.ID, h.clockIn) h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.clockIn)
case "clockout": case "clockout":
h.handleActionCallback(chatID, msgID, cb.ID, h.clockOut) h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.clockOut)
case "worktype": case "worktype":
h.workTypeCallback(chatID, msgID, cb.ID) h.workTypeCallback(ctx, chatID, msgID, cb.ID)
case "report": case "report":
h.handleActionCallback(chatID, msgID, cb.ID, h.report) h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.report)
case "export": case "export":
h.exportCallback(chatID, msgID, cb.ID) h.exportCallback(ctx, chatID, msgID, cb.ID)
case "noop": case "noop":
h.Bot.Request(tgbotapi.NewCallback(cb.ID, "")) h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: cb.ID})
case "dayoff": case "dayoff":
h.handleActionCallback(chatID, msgID, cb.ID, h.dayOff) h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.dayOff)
case "settings": case "settings":
h.settingsCallback(chatID, msgID, cb.ID) h.settingsCallback(ctx, chatID, msgID, cb.ID)
case "caltype": case "caltype":
h.calTypeCallback(chatID, msgID, cb.ID) h.calTypeCallback(ctx, chatID, msgID, cb.ID)
case "accent": case "accent":
h.accentCallback(chatID, msgID, cb.ID) h.accentCallback(ctx, chatID, msgID, cb.ID)
case "timezone": case "timezone":
h.timezoneCallback(chatID, msgID, cb.ID) h.timezoneCallback(ctx, chatID, msgID, cb.ID)
case "history": case "history":
h.historyCallback(chatID, msgID, cb.ID) h.historyCallback(ctx, chatID, msgID, cb.ID)
case "reporttoggle": case "reporttoggle":
h.handleActionCallback(chatID, msgID, cb.ID, h.toggleReport) h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.toggleReport)
case "reporttime": case "reporttime":
h.reportTimeCallback(chatID, msgID, cb.ID) h.reportTimeCallback(ctx, chatID, msgID, cb.ID)
case "back_menu": case "back_menu":
h.backToMenu(chatID, msgID, cb.ID) h.backToMenu(ctx, chatID, msgID, cb.ID)
case "back_settings": case "back_settings":
h.backToSettings(chatID, msgID, cb.ID) h.backToSettings(ctx, chatID, msgID, cb.ID)
default: default:
// Delegate prefixed callbacks to the appropriate sub-handler // Delegate prefixed callbacks to the appropriate sub-handler
if len(cb.Data) > 9 && cb.Data[:9] == "worktype_" { 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 return
} }
if len(cb.Data) > 7 && cb.Data[:7] == "accent_" { 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 return
} }
if len(cb.Data) > 8 && cb.Data[:8] == "caltype_" { 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 return
} }
if len(cb.Data) > 4 && cb.Data[:4] == "exp_" { 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 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. // 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) user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil { if err != nil {
slog.Error("start: create user", "error", err) 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 return
} }
slog.Info("start", "user_id", user.ID, "chat_id", msg.Chat.ID) slog.Info("start", "user_id", user.ID, "chat_id", msg.Chat.ID)
menu := tgbotapi.NewMessage(msg.Chat.ID, h.buildStatusText(msg.Chat.ID)) kb := mainKeyboard()
menu.ReplyMarkup = mainKeyboard() h.Bot.SendMessage(ctx, &bot.SendMessageParams{ChatID: msg.Chat.ID, Text: h.buildStatusText(msg.Chat.ID), ReplyMarkup: kb})
h.Bot.Send(menu)
} }
// handleHelp shows the list of available commands. // 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: text := `Commands:
/start - Show main menu /start - Show main menu
/clockin - Clock in /clockin - Clock in
@@ -267,64 +263,58 @@ func (h *Handler) handleHelp(msg *tgbotapi.Message) {
Dates use your calendar type (Gregorian/Jalali/Hijri). Dates use your calendar type (Gregorian/Jalali/Hijri).
Buttons on the main menu also work.` 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. // 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) user, day, err := h.getUserToday(msg.Chat.ID)
if err != nil { if err != nil {
h.sendText(msg.Chat.ID, "Error loading work types") h.sendText(ctx, msg.Chat.ID, "Error loading work types")
return return
} }
text, kb := h.buildWorkTypeKeyboard(user, day) text, kb := h.buildWorkTypeKeyboard(user, day)
reply := tgbotapi.NewMessage(msg.Chat.ID, text) h.Bot.SendMessage(ctx, &bot.SendMessageParams{ChatID: msg.Chat.ID, Text: text, ReplyMarkup: kb})
reply.ReplyMarkup = &kb
h.Bot.Send(reply)
} }
// workTypeCallback shows the work type picker (inline edit). // workTypeCallback shows the work type picker (inline edit).
func (h *Handler) workTypeCallback(chatID int64, msgID int, callbackID string) { func (h *Handler) workTypeCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) defer h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID})
user, day, err := h.getUserToday(chatID) user, day, err := h.getUserToday(chatID)
if err != nil { if err != nil {
edit := tgbotapi.NewEditMessageText(chatID, msgID, "Error loading work types")
kb := backKeyboard() kb := backKeyboard()
edit.ReplyMarkup = &kb h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: "Error loading work types", ReplyMarkup: kb})
h.Bot.Send(edit)
return return
} }
text, kb := h.buildWorkTypeKeyboard(user, day) text, kb := h.buildWorkTypeKeyboard(user, day)
edit := tgbotapi.NewEditMessageText(chatID, msgID, text) h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: text, ReplyMarkup: kb})
edit.ReplyMarkup = &kb
h.Bot.Send(edit)
} }
// buildWorkTypeKeyboard returns the work type selection keyboard. // 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() wts, err := h.DB.GetWorkTypes()
if err != nil || len(wts) == 0 { if err != nil || len(wts) == 0 {
return "No work types available.", backKeyboard() return "No work types available.", backKeyboard()
} }
rows := [][]tgbotapi.InlineKeyboardButton{} rows := [][]models.InlineKeyboardButton{}
for _, wt := range wts { for _, wt := range wts {
label := wt.Name label := wt.Name
if wt.ID == day.CurrentWorkTypeID { if wt.ID == day.CurrentWorkTypeID {
label = "> " + wt.Name label = "> " + wt.Name
} }
rows = append(rows, tgbotapi.NewInlineKeyboardRow( rows = append(rows, []models.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData(label, fmt.Sprintf("worktype_%d", wt.ID)), {Text: label, CallbackData: fmt.Sprintf("worktype_%d", wt.ID)},
)) })
} }
rows = append(rows, tgbotapi.NewInlineKeyboardRow( rows = append(rows, []models.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), {Text: "Back to Menu", CallbackData: "back_menu"},
)) })
return "Select work type:", tgbotapi.NewInlineKeyboardMarkup(rows...) return "Select work type:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
} }
// selectWorkType updates the day's work type from a callback. // selectWorkType updates the day's work type from a callback.
func (h *Handler) selectWorkType(chatID int64, msgID int, callbackID, idStr string) { func (h *Handler) selectWorkType(ctx context.Context, chatID int64, msgID int, callbackID, idStr string) {
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) defer h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID})
_, day, err := h.getUserToday(chatID) _, day, err := h.getUserToday(chatID)
if err != nil { if err != nil {
return return
@@ -341,40 +331,40 @@ func (h *Handler) selectWorkType(chatID int64, msgID int, callbackID, idStr stri
if err := h.DB.UpdateDay(day); err != nil { if err := h.DB.UpdateDay(day); err != nil {
return return
} }
edit := tgbotapi.NewEditMessageText(chatID, msgID, fmt.Sprintf("Work type set to: %s", wt.Name))
kb := backKeyboard() kb := backKeyboard()
edit.ReplyMarkup = &kb h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: fmt.Sprintf("Work type set to: %s", wt.Name), ReplyMarkup: kb})
h.Bot.Send(edit)
} }
// mainKeyboard returns the persistent inline menu. // mainKeyboard returns the persistent inline menu.
func mainKeyboard() tgbotapi.InlineKeyboardMarkup { func mainKeyboard() models.InlineKeyboardMarkup {
return tgbotapi.NewInlineKeyboardMarkup( return models.InlineKeyboardMarkup{
tgbotapi.NewInlineKeyboardRow( InlineKeyboard: [][]models.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("Clock In", "clockin"), {
tgbotapi.NewInlineKeyboardButtonData("Clock Out", "clockout"), {Text: "Clock In", CallbackData: "clockin"},
), {Text: "Clock Out", CallbackData: "clockout"},
tgbotapi.NewInlineKeyboardRow( },
tgbotapi.NewInlineKeyboardButtonData("Work Type", "worktype"), {
tgbotapi.NewInlineKeyboardButtonData("Day Off", "dayoff"), {Text: "Work Type", CallbackData: "worktype"},
), {Text: "Day Off", CallbackData: "dayoff"},
tgbotapi.NewInlineKeyboardRow( },
tgbotapi.NewInlineKeyboardButtonData("Report", "report"), {
tgbotapi.NewInlineKeyboardButtonData("Export", "export"), {Text: "Report", CallbackData: "report"},
), {Text: "Export", CallbackData: "export"},
tgbotapi.NewInlineKeyboardRow( },
tgbotapi.NewInlineKeyboardButtonData("Settings", "settings"), {
), {Text: "Settings", CallbackData: "settings"},
) },
},
}
} }
// backKeyboard returns a simple "Back to Menu" button. // backKeyboard returns a simple "Back to Menu" button.
func backKeyboard() tgbotapi.InlineKeyboardMarkup { func backKeyboard() models.InlineKeyboardMarkup {
return tgbotapi.NewInlineKeyboardMarkup( return models.InlineKeyboardMarkup{
tgbotapi.NewInlineKeyboardRow( InlineKeyboard: [][]models.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), {{Text: "Back to Menu", CallbackData: "back_menu"}},
), },
) }
} }
// formatDuration formats seconds as a human-readable duration (e.g. "7h 30m"). // 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. // sendText sends a plain text message to the given chat.
func (h *Handler) sendText(chatID int64, text string) { func (h *Handler) sendText(ctx context.Context, chatID int64, text string) {
h.Bot.Send(tgbotapi.NewMessage(chatID, text)) h.Bot.SendMessage(ctx, &bot.SendMessageParams{ChatID: chatID, Text: text})
} }
// navMonth returns the (prevY, prevM, nextY, nextM) for month navigation. // 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. // 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 { if msgID == 0 {
msg := tgbotapi.NewMessage(chatID, text) h.Bot.SendMessage(ctx, &bot.SendMessageParams{ChatID: chatID, Text: text, ReplyMarkup: kb})
msg.ReplyMarkup = kb
h.Bot.Send(msg)
} else { } else {
edit := tgbotapi.NewEditMessageText(chatID, msgID, text) h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: text, ReplyMarkup: kb})
edit.ReplyMarkup = kb
h.Bot.Send(edit)
} }
} }

View File

@@ -1,11 +1,13 @@
package bot package bot
import ( import (
"context"
"fmt" "fmt"
"log/slog" "log/slog"
"time" "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" "worktimeBot/internal/db"
) )
@@ -54,13 +56,33 @@ func (h *Handler) buildStatusText(chatID int64) string {
} }
// backToMenu returns to the main menu from any sub-menu. // backToMenu returns to the main menu from any sub-menu.
func (h *Handler) backToMenu(chatID int64, msgID int, callbackID string) { func (h *Handler) backToMenu(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) defer h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID})
text := h.buildStatusText(chatID) text := h.buildStatusText(chatID)
edit := tgbotapi.NewEditMessageText(chatID, msgID, text) h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{
kb := mainKeyboard() ChatID: chatID,
edit.ReplyMarkup = &kb MessageID: msgID,
h.Bot.Send(edit) 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. // 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. // 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) user, err := h.DB.GetUserByChatID(chatID)
if err != nil { if err != nil {
slog.Error("daily report: user not found", "user_id", userID) slog.Error("daily report: user not found", "user_id", userID)
@@ -143,7 +165,6 @@ func (h *Handler) SendDailyReport(userID int64, chatID int64) {
return return
} }
text := h.buildDailyReport(user, day, events) text := h.buildDailyReport(user, day, events)
reply := tgbotapi.NewMessage(chatID, text) h.Bot.SendMessage(ctx, &bot.SendMessageParams{ChatID: chatID, Text: text})
h.Bot.Send(reply)
h.DB.MarkReportSent(user.ID, today) h.DB.MarkReportSent(user.ID, today)
} }

View File

@@ -1,172 +1,174 @@
package bot package bot
import ( import (
"context"
"fmt" "fmt"
"strconv" "strconv"
"strings"
"time" "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" "worktimeBot/internal/db"
) )
// handleSetReportTime parses a HH:MM argument and updates the user's report time. // handleSetReportTime parses a HH:MM argument and updates the user's report time.
func (h *Handler) handleSetReportTime(msg *tgbotapi.Message) { func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message) {
arg := msg.CommandArguments() args := ""
if arg == "" { if len(msg.Text) > 16 { // "/setreporttime " is 15 chars
args = strings.TrimSpace(msg.Text[15:])
}
if args == "" {
user, err := h.getOrCreateUser(msg.Chat.ID) user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil { if err != nil {
h.sendText(msg.Chat.ID, "Error loading profile") h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return 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 return
} }
if len(arg) != 5 || arg[2] != ':' { if len(args) != 5 || args[2] != ':' {
h.sendText(msg.Chat.ID, "Invalid format. Usage: /setreporttime HH:MM (24-hour)") h.sendText(ctx, msg.Chat.ID, "Invalid format. Usage: /setreporttime HH:MM (24-hour)")
return return
} }
hours, err1 := strconv.Atoi(arg[:2]) hours, err1 := strconv.Atoi(args[:2])
mins, err2 := strconv.Atoi(arg[3:]) mins, err2 := strconv.Atoi(args[3:])
if err1 != nil || err2 != nil || hours < 0 || hours > 23 || mins < 0 || mins > 59 { 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 return
} }
user, err := h.getOrCreateUser(msg.Chat.ID) user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil { if err != nil {
h.sendText(msg.Chat.ID, "Error updating report time") h.sendText(ctx, msg.Chat.ID, "Error updating report time")
return return
} }
user.ReportTime = arg user.ReportTime = args
if err := h.DB.UpdateUser(user); err != nil { 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 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. // 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) user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil { if err != nil {
h.sendText(msg.Chat.ID, "Error loading profile") h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return return
} }
text, kb := h.buildAccentKeyboard(user) text, kb := h.buildAccentKeyboard(user)
reply := tgbotapi.NewMessage(msg.Chat.ID, text) h.Bot.SendMessage(ctx, &bot.SendMessageParams{ChatID: msg.Chat.ID, Text: text, ReplyMarkup: &kb})
reply.ReplyMarkup = &kb
h.Bot.Send(reply)
} }
// handleSetTimezone validates and updates the user's IANA timezone. // handleSetTimezone validates and updates the user's IANA timezone.
func (h *Handler) handleSetTimezone(msg *tgbotapi.Message) { func (h *Handler) handleSetTimezone(ctx context.Context, msg *models.Message) {
arg := msg.CommandArguments() args := ""
if arg == "" { if len(msg.Text) > 13 { // "/settimezone " is 12 chars
args = strings.TrimSpace(msg.Text[13:])
}
if args == "" {
user, err := h.getOrCreateUser(msg.Chat.ID) user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil { if err != nil {
h.sendText(msg.Chat.ID, "Error loading profile") h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return return
} }
h.sendText(msg.Chat.ID, fmt.Sprintf("Current timezone: %s\nUsage: /settimezone <IANA timezone>\nExamples: Asia/Tehran, Europe/London, America/New_York", user.Timezone)) h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current timezone: %s\nUsage: /settimezone <IANA timezone>\nExamples: Asia/Tehran, Europe/London, America/New_York", user.Timezone))
return return
} }
loc, err := time.LoadLocation(arg) loc, err := time.LoadLocation(args)
if err != nil { 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 return
} }
user, err := h.getOrCreateUser(msg.Chat.ID) user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil { if err != nil {
h.sendText(msg.Chat.ID, "Error loading profile") h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return return
} }
user.Timezone = arg user.Timezone = args
if err := h.DB.UpdateUser(user); err != nil { 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 return
} }
now := time.Now().In(loc) 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. // settingsCallback shows the settings inline menu.
func (h *Handler) settingsCallback(chatID int64, msgID int, callbackID string) { func (h *Handler) settingsCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID})
user, err := h.getOrCreateUser(chatID) user, err := h.getOrCreateUser(chatID)
if err != nil { if err != nil {
return return
} }
text, kb := h.buildSettingsKeyboard(user) text, kb := h.buildSettingsKeyboard(user)
edit := tgbotapi.NewEditMessageText(chatID, msgID, text) h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{
edit.ReplyMarkup = &kb ChatID: chatID,
h.Bot.Send(edit) MessageID: msgID,
Text: text,
ReplyMarkup: &kb,
})
} }
// buildSettingsKeyboard returns the settings menu text and inline keyboard. // 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" reportStatus := "disabled"
if user.ReportEnabled { if user.ReportEnabled {
reportStatus = "enabled" reportStatus = "enabled"
} }
rows := [][]tgbotapi.InlineKeyboardButton{ rows := [][]models.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardRow( {{Text: fmt.Sprintf("Timezone: %s", user.Timezone), CallbackData: "timezone"}},
tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Timezone: %s", user.Timezone), "timezone"), {{Text: fmt.Sprintf("Accent: %s", user.ExportAccent), CallbackData: "accent"}},
), {{Text: fmt.Sprintf("Calendar: %s", user.Calendar), CallbackData: "caltype"}},
tgbotapi.NewInlineKeyboardRow( {{Text: fmt.Sprintf("Report: %s", reportStatus), CallbackData: "reporttoggle"}},
tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Accent: %s", user.ExportAccent), "accent"), {{Text: fmt.Sprintf("Report time: %s", user.ReportTime), CallbackData: "reporttime"}},
), {{Text: "History", CallbackData: "history"}},
tgbotapi.NewInlineKeyboardRow( {{Text: "Back to Menu", CallbackData: "back_menu"}},
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"),
),
} }
return "Settings:", tgbotapi.NewInlineKeyboardMarkup(rows...) return "Settings:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
} }
// accentCallback shows the accent color picker (inline edit). // accentCallback shows the accent color picker (inline edit).
func (h *Handler) accentCallback(chatID int64, msgID int, callbackID string) { func (h *Handler) accentCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID})
user, err := h.getOrCreateUser(chatID) user, err := h.getOrCreateUser(chatID)
if err != nil { if err != nil {
return return
} }
text, kb := h.buildAccentKeyboard(user) text, kb := h.buildAccentKeyboard(user)
edit := tgbotapi.NewEditMessageText(chatID, msgID, text) h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{
edit.ReplyMarkup = &kb ChatID: chatID,
h.Bot.Send(edit) MessageID: msgID,
Text: text,
ReplyMarkup: &kb,
})
} }
// timezoneCallback shows the current timezone and instructions to change it. // timezoneCallback shows the current timezone and instructions to change it.
func (h *Handler) timezoneCallback(chatID int64, msgID int, callbackID string) { func (h *Handler) timezoneCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID})
user, err := h.getOrCreateUser(chatID) user, err := h.getOrCreateUser(chatID)
if err != nil { if err != nil {
return return
} }
text := fmt.Sprintf("Current timezone: %s\n\nUse /settimezone <name> to change it.\nExamples: Asia/Tehran, Europe/London, America/New_York", user.Timezone) text := fmt.Sprintf("Current timezone: %s\n\nUse /settimezone <name> to change it.\nExamples: Asia/Tehran, Europe/London, America/New_York", user.Timezone)
kb := tgbotapi.NewInlineKeyboardMarkup( kb := models.InlineKeyboardMarkup{
tgbotapi.NewInlineKeyboardRow( InlineKeyboard: [][]models.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), {{Text: "Back to Settings", CallbackData: "back_settings"}},
), },
) }
edit := tgbotapi.NewEditMessageText(chatID, msgID, text) h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{
edit.ReplyMarkup = &kb ChatID: chatID,
h.Bot.Send(edit) MessageID: msgID,
Text: text,
ReplyMarkup: &kb,
})
} }
// buildAccentKeyboard returns the accent color picker keyboard. // 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 { type accentOption struct {
name string name string
color string color string
@@ -177,25 +179,25 @@ func (h *Handler) buildAccentKeyboard(user *db.User) (string, tgbotapi.InlineKey
{"rose", "Pink"}, {"rose", "Pink"},
{"catppuccin", "Purple"}, {"catppuccin", "Purple"},
} }
rows := [][]tgbotapi.InlineKeyboardButton{} rows := [][]models.InlineKeyboardButton{}
for _, a := range accents { for _, a := range accents {
label := fmt.Sprintf("%s (%s)", a.name, a.color) label := fmt.Sprintf("%s (%s)", a.name, a.color)
if a.name == user.ExportAccent { if a.name == user.ExportAccent {
label = "> " + label label = "> " + label
} }
rows = append(rows, tgbotapi.NewInlineKeyboardRow( rows = append(rows, []models.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData(label, "accent_"+a.name), {Text: label, CallbackData: "accent_" + a.name},
)) })
} }
rows = append(rows, tgbotapi.NewInlineKeyboardRow( rows = append(rows, []models.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), {Text: "Back to Settings", CallbackData: "back_settings"},
)) })
return "Select accent color:", tgbotapi.NewInlineKeyboardMarkup(rows...) return "Select accent color:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
} }
// selectAccent sets the user's export accent color after validation. // selectAccent sets the user's export accent color after validation.
func (h *Handler) selectAccent(chatID int64, msgID int, callbackID, name string) { func (h *Handler) selectAccent(ctx context.Context, chatID int64, msgID int, callbackID, name string) {
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID})
user, err := h.getOrCreateUser(chatID) user, err := h.getOrCreateUser(chatID)
if err != nil { if err != nil {
return return
@@ -208,87 +210,99 @@ func (h *Handler) selectAccent(chatID int64, msgID int, callbackID, name string)
if err := h.DB.UpdateUser(user); err != nil { if err := h.DB.UpdateUser(user); err != nil {
return return
} }
edit := tgbotapi.NewEditMessageText(chatID, msgID, fmt.Sprintf("Accent set to: %s", name)) kb := models.InlineKeyboardMarkup{
kb := tgbotapi.NewInlineKeyboardMarkup( InlineKeyboard: [][]models.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardRow( {{Text: "Back to Settings", CallbackData: "back_settings"}},
tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), },
), }
) h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{
edit.ReplyMarkup = &kb ChatID: chatID,
h.Bot.Send(edit) MessageID: msgID,
Text: fmt.Sprintf("Accent set to: %s", name),
ReplyMarkup: &kb,
})
} }
// reportTimeCallback shows the current report time setting. // reportTimeCallback shows the current report time setting.
func (h *Handler) reportTimeCallback(chatID int64, msgID int, callbackID string) { func (h *Handler) reportTimeCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID})
user, err := h.getOrCreateUser(chatID) user, err := h.getOrCreateUser(chatID)
if err != nil { if err != nil {
return return
} }
text := fmt.Sprintf("Current report time: %s\nUse /setreporttime HH:MM to change it.", user.ReportTime) text := fmt.Sprintf("Current report time: %s\nUse /setreporttime HH:MM to change it.", user.ReportTime)
edit := tgbotapi.NewEditMessageText(chatID, msgID, text) kb := models.InlineKeyboardMarkup{
kb := tgbotapi.NewInlineKeyboardMarkup( InlineKeyboard: [][]models.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardRow( {{Text: "Back to Settings", CallbackData: "back_settings"}},
tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), },
), }
) h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{
edit.ReplyMarkup = &kb ChatID: chatID,
h.Bot.Send(edit) MessageID: msgID,
Text: text,
ReplyMarkup: &kb,
})
} }
// backToSettings returns to the main settings menu. // backToSettings returns to the main settings menu.
func (h *Handler) backToSettings(chatID int64, msgID int, callbackID string) { func (h *Handler) backToSettings(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID})
user, err := h.getOrCreateUser(chatID) user, err := h.getOrCreateUser(chatID)
if err != nil { if err != nil {
return return
} }
text, kb := h.buildSettingsKeyboard(user) text, kb := h.buildSettingsKeyboard(user)
edit := tgbotapi.NewEditMessageText(chatID, msgID, text) h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{
edit.ReplyMarkup = &kb ChatID: chatID,
h.Bot.Send(edit) MessageID: msgID,
Text: text,
ReplyMarkup: &kb,
})
} }
// calTypeCallback shows the calendar type selector. // calTypeCallback shows the calendar type selector.
func (h *Handler) calTypeCallback(chatID int64, msgID int, callbackID string) { func (h *Handler) calTypeCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID})
user, err := h.getOrCreateUser(chatID) user, err := h.getOrCreateUser(chatID)
if err != nil { if err != nil {
return return
} }
text, kb := h.buildCalendarKeyboard(user) text, kb := h.buildCalendarKeyboard(user)
edit := tgbotapi.NewEditMessageText(chatID, msgID, text) h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{
edit.ReplyMarkup = &kb ChatID: chatID,
h.Bot.Send(edit) MessageID: msgID,
Text: text,
ReplyMarkup: &kb,
})
} }
// buildCalendarKeyboard returns the calendar type selector keyboard. // 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"} cals := []string{"gregorian", "jalali", "hijri"}
calLabels := map[string]string{ calLabels := map[string]string{
"gregorian": "Gregorian", "gregorian": "Gregorian",
"jalali": "Jalali", "jalali": "Jalali",
"hijri": "Hijri", "hijri": "Hijri",
} }
rows := [][]tgbotapi.InlineKeyboardButton{} rows := [][]models.InlineKeyboardButton{}
for _, c := range cals { for _, c := range cals {
label := calLabels[c] label := calLabels[c]
if c == user.Calendar { if c == user.Calendar {
label = "> " + label label = "> " + label
} }
rows = append(rows, tgbotapi.NewInlineKeyboardRow( rows = append(rows, []models.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData(label, "caltype_"+c), {Text: label, CallbackData: "caltype_" + c},
)) })
} }
rows = append(rows, tgbotapi.NewInlineKeyboardRow( rows = append(rows, []models.InlineKeyboardButton{
tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), {Text: "Back to Settings", CallbackData: "back_settings"},
)) })
return "Select calendar type:", tgbotapi.NewInlineKeyboardMarkup(rows...) return "Select calendar type:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
} }
// selectCalendar sets the user's calendar after validation. // selectCalendar sets the user's calendar after validation.
func (h *Handler) selectCalendar(chatID int64, msgID int, callbackID, name string) { func (h *Handler) selectCalendar(ctx context.Context, chatID int64, msgID int, callbackID, name string) {
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) h.Bot.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: callbackID})
valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true} valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true}
if !valid[name] { if !valid[name] {
return return
@@ -302,7 +316,10 @@ func (h *Handler) selectCalendar(chatID int64, msgID int, callbackID, name strin
return return
} }
text, kb := h.buildSettingsKeyboard(user) text, kb := h.buildSettingsKeyboard(user)
edit := tgbotapi.NewEditMessageText(chatID, msgID, text) h.Bot.EditMessageText(ctx, &bot.EditMessageTextParams{
edit.ReplyMarkup = &kb ChatID: chatID,
h.Bot.Send(edit) MessageID: msgID,
Text: text,
ReplyMarkup: &kb,
})
} }