diff --git a/Dockerfile b/Dockerfile index 72e1ade..c1ecc32 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,7 @@ RUN apk add --no-cache ca-certificates tzdata WORKDIR /data COPY --from=builder /usr/local/bin/worktime-bot /usr/local/bin/worktime-bot -COPY --from=builder /src/db/schema.sql ./db/schema.sql +COPY db/schema.sql /app/db/schema.sql VOLUME ["/data"] CMD ["worktime-bot"] diff --git a/cmd/bot/main.go b/cmd/bot/main.go index a1fc71a..4a6968d 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -4,8 +4,11 @@ import ( "log/slog" "net/http" "os" + "os/signal" "strconv" "strings" + "sync" + "syscall" "time" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" @@ -40,29 +43,62 @@ func main() { } allowedUsers := parseAllowedUsers(os.Getenv("BOT_ALLOWED_USERS")) - handler := bot.NewHandler(botAPI, dbStore, allowedUsers) - slog.Info("bot started", - "bot_user", botAPI.Self.UserName, - "allowed_users", len(allowedUsers), - "polling", true, - ) + stop := make(chan struct{}) + var wg sync.WaitGroup - webhookURL := os.Getenv("BOT_WEBHOOK_URL") - if webhookURL != "" { - startWebhook(botAPI, handler, webhookURL) + if url := os.Getenv("BOT_WEBHOOK_URL"); url != "" { + wg.Add(1) + go func() { + defer wg.Done() + startWebhook(botAPI, handler, url) + }() } else { if _, err := botAPI.Request(tgbotapi.DeleteWebhookConfig{}); err != nil { slog.Error("delete webhook", "error", err) } - go dailyReportScheduler(handler, dbStore) + wg.Add(1) + go func() { + defer wg.Done() + dailyReportScheduler(handler, dbStore, stop) + }() - u := tgbotapi.NewUpdate(0) - u.Timeout = 30 - updates := botAPI.GetUpdatesChan(u) - for update := range updates { + wg.Add(1) + go func() { + defer wg.Done() + startPolling(botAPI, handler, stop) + }() + } + + slog.Info("bot started", + "bot_user", botAPI.Self.UserName, + "allowed_users", len(allowedUsers), + "mode", map[bool]string{true: "webhook", false: "polling"}[os.Getenv("BOT_WEBHOOK_URL") != ""], + ) + + 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() +} + +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) } @@ -90,7 +126,7 @@ func parseAllowedUsers(raw string) map[int64]bool { return m } -func dailyReportScheduler(h *bot.Handler, store *db.Store) { +func dailyReportScheduler(h *bot.Handler, store *db.Store, stop chan struct{}) { for { now := time.Now() next := time.Date(now.Year(), now.Month(), now.Day(), 23, 0, 0, 0, now.Location()) @@ -98,9 +134,14 @@ func dailyReportScheduler(h *bot.Handler, store *db.Store) { next = next.AddDate(0, 0, 1) } slog.Info("daily report scheduler", "next_run", next) - time.Sleep(time.Until(next)) - chatIDStr, err := store.GetSetting("chat_id") + select { + case <-stop: + return + case <-time.After(time.Until(next)): + } + + chatIDStr, err := store.GetSetting(0, "report_chat_id") if err != nil { slog.Error("daily report: chat_id not found") continue diff --git a/db/schema.sql b/db/schema.sql index b9e813a..1fa5e26 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,26 +1,26 @@ CREATE TABLE IF NOT EXISTS events ( - id INTEGER PRIMARY KEY, - event_type TEXT NOT NULL - CHECK (event_type IN ('in', 'out')), - occurred_at INTEGER NOT NULL UNIQUE - CHECK (occurred_at > 0), - note TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL - DEFAULT (unixepoch()) + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id INTEGER NOT NULL, + event_type TEXT NOT NULL CHECK (event_type IN ('in', 'out')), + occurred_at INTEGER NOT NULL CHECK (occurred_at > 0), + note TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL DEFAULT (unixepoch()) ); -CREATE INDEX IF NOT EXISTS idx_events_occurred_at -ON events(occurred_at); +CREATE INDEX IF NOT EXISTS idx_events_chat_occurred +ON events(chat_id, occurred_at); CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL DEFAULT '' + chat_id INTEGER NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL DEFAULT '', + PRIMARY KEY (chat_id, key) ); CREATE TABLE IF NOT EXISTS days_off ( - date TEXT PRIMARY KEY - CHECK (date = strftime('%Y-%m-%d', date)), + chat_id INTEGER NOT NULL, + date TEXT NOT NULL CHECK (date = strftime('%Y-%m-%d', date)), reason TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL - DEFAULT (unixepoch()) + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (chat_id, date) ); diff --git a/docker-compose.yml b/docker-compose.yml index 496650f..0f1b259 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,7 @@ services: - HTTP_PROXY=${HTTP_PROXY-} - HTTPS_PROXY=${HTTPS_PROXY-} - DB_PATH=/data/db.sqlite3 + - TZ=Asia/Tehran volumes: - worktime_data:/data diff --git a/internal/bot/export.go b/internal/bot/export.go index 33337e7..788325a 100644 --- a/internal/bot/export.go +++ b/internal/bot/export.go @@ -9,7 +9,7 @@ import ( "worktimeBot/internal/db" ) -func GenerateMonthlyReport(store *db.Store, year int, month time.Month) ([]byte, error) { +func GenerateMonthlyReport(store *db.Store, chatID int64, year int, month time.Month) ([]byte, error) { f := excelize.NewFile() defer f.Close() @@ -59,7 +59,6 @@ func GenerateMonthlyReport(store *db.Store, year int, month time.Month) ([]byte, }) monthName := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC).Format("January 2006") - f.SetCellValue(sheet, "A1", fmt.Sprintf("Work Time Report — %s", monthName)) f.MergeCell(sheet, "A1", "E1") f.SetCellStyle(sheet, "A1", "E1", titleStyle) @@ -89,7 +88,7 @@ func GenerateMonthlyReport(store *db.Store, year int, month time.Month) ([]byte, for d := firstDay; !d.After(lastDay); d = d.AddDate(0, 0, 1) { dateStr := d.Format("2006-01-02") - isOff, err := store.IsDayOff(dateStr) + isOff, err := store.IsDayOff(chatID, dateStr) if err != nil { return nil, err } @@ -97,7 +96,7 @@ func GenerateMonthlyReport(store *db.Store, year int, month time.Month) ([]byte, continue } - events, err := store.EventsForDay(dateStr) + events, err := store.EventsForDay(chatID, dateStr) if err != nil { return nil, err } diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index ed2d5d9..b28bf5b 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -22,6 +22,31 @@ func NewHandler(bot *tgbotapi.BotAPI, store *db.Store, allowed map[int64]bool) * return &Handler{Bot: bot, DB: store, AllowedUsers: allowed} } +type actionFunc func(int64) (string, error) + +func (h *Handler) handleActionMsg(msg *tgbotapi.Message, fn actionFunc) { + text, err := fn(msg.Chat.ID) + if err != nil { + h.sendText(msg.Chat.ID, err.Error()) + } else { + h.sendSuccessWithMenu(msg.Chat.ID, text) + } +} + +func (h *Handler) handleActionCallback(chatID int64, msgID int, callbackID string, fn actionFunc) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + text, err := fn(chatID) + if err != nil { + text = err.Error() + } + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + kb := backKeyboard() + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +// --- Entry points --- + func (h *Handler) HandleMessage(update tgbotapi.Update) { msg := update.Message if len(h.AllowedUsers) > 0 && !h.AllowedUsers[msg.Chat.ID] { @@ -33,17 +58,17 @@ func (h *Handler) HandleMessage(update tgbotapi.Update) { case "/start": h.handleStart(msg) case "/clockin", "Clock In": - h.handleClockInMsg(msg) + h.handleActionMsg(msg, h.clockIn) case "/clockout", "Clock Out": - h.handleClockOutMsg(msg) + h.handleActionMsg(msg, h.clockOut) case "/remote": - h.toggleRemote(msg) + h.handleActionMsg(msg, h.toggleRemote) case "/report": - h.handleReport(msg) + h.handleActionMsg(msg, h.report) case "/export": h.handleExport(msg) case "/dayoff": - h.handleDayOff(msg) + h.handleActionMsg(msg, h.dayOff) default: h.sendText(msg.Chat.ID, "Unknown command") } @@ -61,33 +86,253 @@ func (h *Handler) HandleCallback(update tgbotapi.Update) { msgID := cb.Message.MessageID switch cb.Data { case "clockin": - h.clockInCallback(chatID, msgID, cb.ID) + h.handleActionCallback(chatID, msgID, cb.ID, h.clockIn) case "clockout": - h.clockOutCallback(chatID, msgID, cb.ID) + h.handleActionCallback(chatID, msgID, cb.ID, h.clockOut) case "remote": - h.toggleRemoteCallback(chatID, msgID, cb.ID) + h.handleActionCallback(chatID, msgID, cb.ID, h.toggleRemote) case "report": - h.reportCallback(chatID, msgID, cb.ID) + h.handleActionCallback(chatID, msgID, cb.ID, h.report) case "export": h.exportCallback(chatID, msgID, cb.ID) case "dayoff": - h.dayOffCallback(chatID, msgID, cb.ID) + h.handleActionCallback(chatID, msgID, cb.ID, h.dayOff) case "back_menu": h.backToMenu(chatID, msgID, cb.ID) } } +// --- Actions (shared by msg + callback) --- + +func (h *Handler) clockIn(chatID int64) (string, error) { + today := time.Now().Format("2006-01-02") + isOff, err := h.DB.IsDayOff(chatID, today) + if err != nil { + return "", err + } + if isOff { + return "", errors.New("Today is a day off. Use /dayoff to remove it first.") + } + last, err := h.DB.GetLastEvent(chatID) + if err != nil && err != sql.ErrNoRows { + return "", err + } + if last != nil && last.EventType == "in" { + return "", errors.New("Already clocked in") + } + if _, err := h.DB.GetSetting(chatID, "remote_flag"); err != nil { + h.DB.SetSetting(chatID, "remote_flag", "onsite") + } + if err := h.DB.CreateEvent(chatID, "in", time.Now().Unix(), ""); err != nil { + return "", err + } + return "Clocked in at " + time.Now().Format("15:04"), nil +} + +func (h *Handler) clockOut(chatID int64) (string, error) { + today := time.Now().Format("2006-01-02") + isOff, err := h.DB.IsDayOff(chatID, today) + if err != nil { + return "", err + } + if isOff { + return "", errors.New("Today is a day off. Use /dayoff to remove it first.") + } + last, err := h.DB.GetLastEvent(chatID) + if err != nil { + return "", err + } + if last == nil { + return "", errors.New("No clock-in found. Start with /clockin first") + } + if last.EventType == "out" { + return "", errors.New("Already clocked out") + } + if err := h.DB.CreateEvent(chatID, "out", time.Now().Unix(), ""); err != nil { + return "", err + } + return "Clocked out at " + time.Now().Format("15:04"), nil +} + +func (h *Handler) toggleRemote(chatID int64) (string, error) { + current, err := h.DB.GetSetting(chatID, "remote_flag") + if err != nil { + current = "onsite" + } + var newMode, text string + if current == "remote" { + newMode = "onsite" + text = "Onsite mode enabled" + } else { + newMode = "remote" + text = "Remote mode enabled" + } + if err := h.DB.SetSetting(chatID, "remote_flag", newMode); err != nil { + return "", err + } + return text, nil +} + +func (h *Handler) dayOff(chatID int64) (string, error) { + today := time.Now().Format("2006-01-02") + isOff, err := h.DB.IsDayOff(chatID, today) + if err != nil { + return "", err + } + if isOff { + if err := h.DB.RemoveDayOff(chatID, today); err != nil { + return "", err + } + return "Day off removed", nil + } + if err := h.DB.SetDayOff(chatID, today, ""); err != nil { + return "", err + } + return "Today marked as day off", nil +} + +func (h *Handler) report(chatID int64) (string, error) { + return h.buildDailyReport(chatID, time.Now()), nil +} + // --- /start --- func (h *Handler) handleStart(msg *tgbotapi.Message) { - h.DB.SetSetting("chat_id", fmt.Sprintf("%d", msg.Chat.ID)) + h.DB.SetSetting(0, "report_chat_id", fmt.Sprintf("%d", msg.Chat.ID)) slog.Info("start", "chat_id", msg.Chat.ID) - text := "Welcome! Choose an action:" - reply := tgbotapi.NewMessage(msg.Chat.ID, text) - reply.ReplyMarkup = mainKeyboard() + reply := tgbotapi.NewMessage(msg.Chat.ID, "We hope you have a happy day working.") + reply.ReplyMarkup = quickKeyboard() + h.Bot.Send(reply) + menu := tgbotapi.NewMessage(msg.Chat.ID, "Welcome. Choose an action:") + menu.ReplyMarkup = mainKeyboard() + h.Bot.Send(menu) +} + +// --- Export (different flow — sends a file) --- + +func (h *Handler) handleExport(msg *tgbotapi.Message) { + now := time.Now() + slog.Info("export", "chat_id", msg.Chat.ID, "year", now.Year(), "month", now.Month()) + data, err := GenerateMonthlyReport(h.DB, msg.Chat.ID, now.Year(), now.Month()) + if err != nil { + slog.Error("generate report", "error", err) + h.sendText(msg.Chat.ID, "Error generating report") + return + } + slog.Info("report generated", "bytes", len(data)) + doc := tgbotapi.NewDocument(msg.Chat.ID, tgbotapi.FileBytes{ + Name: fmt.Sprintf("worktime_%s.xlsx", now.Format("2006_01")), + Bytes: data, + }) + if _, err := h.Bot.Send(doc); err != nil { + slog.Error("send document", "error", err) + h.sendText(msg.Chat.ID, "Error sending file") + } +} + +func (h *Handler) exportCallback(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + now := time.Now() + slog.Info("export", "chat_id", chatID, "year", now.Year(), "month", now.Month()) + data, err := GenerateMonthlyReport(h.DB, chatID, now.Year(), now.Month()) + if err != nil { + slog.Error("generate report", "error", err) + edit := tgbotapi.NewEditMessageText(chatID, msgID, "Error generating report") + kb := backKeyboard() + edit.ReplyMarkup = &kb + h.Bot.Send(edit) + return + } + slog.Info("report generated", "bytes", len(data)) + edit := tgbotapi.NewEditMessageText(chatID, msgID, "Report ready:") + kb := backKeyboard() + edit.ReplyMarkup = &kb + h.Bot.Send(edit) + doc := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{ + Name: fmt.Sprintf("worktime_%s.xlsx", now.Format("2006_01")), + Bytes: data, + }) + if _, err := h.Bot.Send(doc); err != nil { + slog.Error("send document", "error", err) + } +} + +// --- Back to menu --- + +func (h *Handler) backToMenu(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + edit := tgbotapi.NewEditMessageText(chatID, msgID, "Welcome. Choose an action:") + kb := mainKeyboard() + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +// --- Daily report builder --- + +func (h *Handler) buildDailyReport(chatID int64, now time.Time) string { + dateStr := now.Format("2006-01-02") + + isOff, _ := h.DB.IsDayOff(chatID, dateStr) + + events, err := h.DB.EventsForDay(chatID, dateStr) + if err != nil { + return "Error fetching events." + } + + if len(events) == 0 { + if isOff { + return fmt.Sprintf("%s — Day Off", dateStr) + } + return fmt.Sprintf("%s — No events recorded.", dateStr) + } + + mode := "Onsite" + current, err := h.DB.GetSetting(chatID, "remote_flag") + if err == nil && current == "remote" { + mode = "Remote" + } + + totals := ComputeDailyTotals(events) + if totals.PairCount == 0 { + t := time.Unix(events[0].OccurredAt, 0).Format("15:04") + return fmt.Sprintf("%s\nMode: %s\nClocked in at %s\n\nStill working — no clock-out yet.", dateStr, mode, t) + } + + lines := fmt.Sprintf("Report for %s\nMode: %s", dateStr, mode) + if isOff { + lines += "\nDay Off: Yes" + } + lines += "\n\n" + + for _, e := range events { + t := time.Unix(e.OccurredAt, 0).Format("15:04") + label := "IN" + action := "Clock In" + if e.EventType == "out" { + label = "OUT" + action = "Clock Out" + } + lines += fmt.Sprintf("%s %s — %s\n", label, t, action) + } + + workStr := formatDuration(totals.TotalSeconds) + breakStr := formatDuration(totals.BreakSeconds) + lines += fmt.Sprintf("\nSummary:\n Work: %s\n Break: %s\n", workStr, breakStr) + + return lines +} + +// --- Scheduled report --- + +func (h *Handler) SendDailyReport(chatID int64) { + text := h.buildDailyReport(chatID, time.Now()) + reply := tgbotapi.NewMessage(chatID, text) + reply.ReplyMarkup = quickKeyboard() h.Bot.Send(reply) } +// --- Keyboards --- + func quickKeyboard() tgbotapi.ReplyKeyboardMarkup { return tgbotapi.NewReplyKeyboard( tgbotapi.NewKeyboardButtonRow( @@ -122,315 +367,6 @@ func backKeyboard() tgbotapi.InlineKeyboardMarkup { ) } -// --- Text command handlers --- - -func (h *Handler) handleClockInMsg(msg *tgbotapi.Message) { - if err := h.doClockIn(); err != nil { - slog.Error("clock in failed", "chat_id", msg.Chat.ID, "error", err) - h.sendText(msg.Chat.ID, err.Error()) - } else { - slog.Info("clocked in", "chat_id", msg.Chat.ID) - h.sendSuccessWithMenu(msg.Chat.ID, "✅ Clocked in at "+time.Now().Format("15:04")) - } -} - -func (h *Handler) handleClockOutMsg(msg *tgbotapi.Message) { - if err := h.doClockOut(); err != nil { - slog.Error("clock out failed", "chat_id", msg.Chat.ID, "error", err) - h.sendText(msg.Chat.ID, err.Error()) - } else { - slog.Info("clocked out", "chat_id", msg.Chat.ID) - h.sendSuccessWithMenu(msg.Chat.ID, "✅ Clocked out at "+time.Now().Format("15:04")) - } -} - -func (h *Handler) toggleRemote(msg *tgbotapi.Message) { - current, err := h.DB.GetSetting("remote_flag") - if err != nil { - current = "onsite" - } - newMode := "remote" - text := "🌍 Remote mode enabled" - if current == "remote" { - newMode = "onsite" - text = "🏢 Onsite mode enabled" - } - if err := h.DB.SetSetting("remote_flag", newMode); err != nil { - slog.Error("toggle remote", "error", err) - h.sendText(msg.Chat.ID, "Error toggling mode") - return - } - slog.Info("remote toggled", "mode", newMode) - h.sendText(msg.Chat.ID, text) -} - -func (h *Handler) handleReport(msg *tgbotapi.Message) { - text := h.buildDailyReport(time.Now()) - slog.Info("report", "chat_id", msg.Chat.ID) - reply := tgbotapi.NewMessage(msg.Chat.ID, text) - h.Bot.Send(reply) -} - -func (h *Handler) buildDailyReport(now time.Time) string { - dateStr := now.Format("2006-01-02") - events, err := h.DB.EventsForDay(dateStr) - if err != nil { - return "Error fetching events." - } - if len(events) == 0 { - isOff, _ := h.DB.IsDayOff(dateStr) - if isOff { - return fmt.Sprintf("📋 %s — Day Off 🎉", dateStr) - } - return fmt.Sprintf("📋 %s — No events recorded.", dateStr) - } - - remoteFlag := "Onsite" - current, err := h.DB.GetSetting("remote_flag") - if err == nil && current == "remote" { - remoteFlag = "Remote" - } - - totals := ComputeDailyTotals(events) - if totals.PairCount == 0 { - t := time.Unix(events[0].OccurredAt, 0).Format("15:04") - return fmt.Sprintf("📋 %s\n📍 Mode: %s\n⏰ Clocked in at %s\n\nStill working — no clock-out yet.", dateStr, remoteFlag, t) - } - - lines := fmt.Sprintf("📋 Report for %s\n📍 Mode: %s\n\n", dateStr, remoteFlag) - - for _, e := range events { - t := time.Unix(e.OccurredAt, 0).Format("15:04") - icon := "⏰" - label := "Clock In" - if e.EventType == "out" { - icon = "🔴" - label = "Clock Out" - } - lines += fmt.Sprintf("%s %s — %s\n", icon, t, label) - } - - workStr := formatDuration(totals.TotalSeconds) - breakStr := formatDuration(totals.BreakSeconds) - lines += fmt.Sprintf("\n📊 Summary:\n Work: %s\n Break: %s\n", workStr, breakStr) - - return lines -} - -func (h *Handler) handleExport(msg *tgbotapi.Message) { - now := time.Now() - year, month := now.Year(), now.Month() - slog.Info("export requested", "year", year, "month", month) - data, err := GenerateMonthlyReport(h.DB, year, month) - if err != nil { - slog.Error("generate report", "error", err) - h.sendText(msg.Chat.ID, "Error generating report") - return - } - slog.Info("report generated", "bytes", len(data)) - doc := tgbotapi.NewDocument(msg.Chat.ID, tgbotapi.FileBytes{ - Name: fmt.Sprintf("worktime_%s.xlsx", now.Format("2006_01")), - Bytes: data, - }) - if _, err := h.Bot.Send(doc); err != nil { - slog.Error("send document", "error", err) - h.sendText(msg.Chat.ID, "Error sending file") - } -} - -func (h *Handler) handleDayOff(msg *tgbotapi.Message) { - today := time.Now().Format("2006-01-02") - isOff, err := h.DB.IsDayOff(today) - if err != nil { - slog.Error("day off check", "error", err) - h.sendText(msg.Chat.ID, "Error checking day off") - return - } - if isOff { - if err := h.DB.RemoveDayOff(today); err != nil { - slog.Error("remove day off", "error", err) - h.sendText(msg.Chat.ID, "Error removing day off") - return - } - slog.Info("day off removed") - h.sendText(msg.Chat.ID, "✅ Day off removed") - } else { - if err := h.DB.SetDayOff(today, ""); err != nil { - slog.Error("set day off", "error", err) - h.sendText(msg.Chat.ID, "Error setting day off") - return - } - slog.Info("day off set") - h.sendText(msg.Chat.ID, "✅ Today marked as day off") - } -} - -// --- Callback handlers --- - -func (h *Handler) clockInCallback(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) - text := "✅ Clocked in at " + time.Now().Format("15:04") - if err := h.doClockIn(); err != nil { - text = err.Error() - slog.Error("clock in failed", "chat_id", chatID, "error", err) - } else { - slog.Info("clocked in", "chat_id", chatID) - } - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - kb := backKeyboard() - edit.ReplyMarkup = &kb - h.Bot.Send(edit) -} - -func (h *Handler) clockOutCallback(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) - text := "✅ Clocked out at " + time.Now().Format("15:04") - if err := h.doClockOut(); err != nil { - text = err.Error() - slog.Error("clock out failed", "chat_id", chatID, "error", err) - } else { - slog.Info("clocked out", "chat_id", chatID) - } - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - kb := backKeyboard() - edit.ReplyMarkup = &kb - h.Bot.Send(edit) -} - -func (h *Handler) toggleRemoteCallback(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) - current, err := h.DB.GetSetting("remote_flag") - if err != nil { - current = "onsite" - } - newMode := "remote" - text := "🌍 Remote mode enabled" - if current == "remote" { - newMode = "onsite" - text = "🏢 Onsite mode enabled" - } - if err := h.DB.SetSetting("remote_flag", newMode); err != nil { - slog.Error("toggle remote", "error", err) - return - } - slog.Info("remote toggled", "mode", newMode) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - kb := backKeyboard() - edit.ReplyMarkup = &kb - h.Bot.Send(edit) -} - -func (h *Handler) reportCallback(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) - text := h.buildDailyReport(time.Now()) - slog.Info("report", "chat_id", chatID) - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - kb := backKeyboard() - edit.ReplyMarkup = &kb - h.Bot.Send(edit) -} - -func (h *Handler) exportCallback(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) - now := time.Now() - year, month := now.Year(), now.Month() - slog.Info("export", "chat_id", chatID, "year", year, "month", month) - data, err := GenerateMonthlyReport(h.DB, year, month) - if err != nil { - slog.Error("generate report", "error", err) - edit := tgbotapi.NewEditMessageText(chatID, msgID, "Error generating report") - kb := backKeyboard() - edit.ReplyMarkup = &kb - h.Bot.Send(edit) - return - } - slog.Info("report generated", "bytes", len(data)) - edit := tgbotapi.NewEditMessageText(chatID, msgID, "Here is your report:") - kb := backKeyboard() - edit.ReplyMarkup = &kb - h.Bot.Send(edit) - doc := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{ - Name: fmt.Sprintf("worktime_%s.xlsx", now.Format("2006_01")), - Bytes: data, - }) - if _, err := h.Bot.Send(doc); err != nil { - slog.Error("send document", "error", err) - } -} - -func (h *Handler) dayOffCallback(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) - today := time.Now().Format("2006-01-02") - isOff, err := h.DB.IsDayOff(today) - if err != nil { - slog.Error("day off check", "error", err) - return - } - text := "✅ Today marked as day off" - if isOff { - h.DB.RemoveDayOff(today) - text = "✅ Day off removed" - slog.Info("day off removed") - } else { - slog.Info("day off set") - } - edit := tgbotapi.NewEditMessageText(chatID, msgID, text) - kb := backKeyboard() - edit.ReplyMarkup = &kb - h.Bot.Send(edit) -} - -func (h *Handler) backToMenu(chatID int64, msgID int, callbackID string) { - defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) - edit := tgbotapi.NewEditMessageText(chatID, msgID, "Welcome! Choose an action:") - kb := mainKeyboard() - edit.ReplyMarkup = &kb - h.Bot.Send(edit) -} - -// --- Scheduled report --- - -func (h *Handler) SendDailyReport(chatID int64) { - text := h.buildDailyReport(time.Now()) - reply := tgbotapi.NewMessage(chatID, text) - reply.ReplyMarkup = quickKeyboard() - h.Bot.Send(reply) -} - -// --- Pure logic --- - -func (h *Handler) doClockIn() error { - last, err := h.DB.GetLastEvent() - if err != nil && err != sql.ErrNoRows { - return err - } - if last != nil && last.EventType == "in" { - return errors.New("⚠️ You are already clocked in") - } - if _, err := h.DB.GetSetting("remote_flag"); err != nil { - h.DB.SetSetting("remote_flag", "onsite") - } - return h.DB.CreateEvent("in", time.Now().Unix(), "") -} - -func (h *Handler) doClockOut() error { - last, err := h.DB.GetLastEvent() - if err != nil { - return err - } - if last == nil { - return errors.New("⚠️ No clock-in found. Start with /clockin first") - } - switch last.EventType { - case "in": - return h.DB.CreateEvent("out", time.Now().Unix(), "") - case "out": - return errors.New("⚠️ You are already clocked out") - default: - return errors.New("⚠️ Error processing request") - } -} - // --- Helpers --- func formatDuration(seconds int64) string { diff --git a/internal/db/store.go b/internal/db/store.go index 738100b..680b7ce 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -2,22 +2,20 @@ package db import ( "database/sql" + "log/slog" "os" - "path/filepath" _ "modernc.org/sqlite" ) const driverName = "sqlite" -var schemaPath = filepath.Join("db", "schema.sql") +var schemaPath = "/app/db/schema.sql" -// Store wraps the sql.DB connection. type Store struct { db *sql.DB } -// NewStore opens the SQLite DB and runs the schema file. func NewStore(path string) (*Store, error) { db, err := sql.Open(driverName, path) if err != nil { @@ -26,12 +24,19 @@ func NewStore(path string) (*Store, error) { if err := db.Ping(); err != nil { return nil, err } + if err := migrate(db); err != nil { + return nil, err + } if err := execSchema(db, schemaPath); err != nil { return nil, err } return &Store{db: db}, nil } +func (s *Store) Close() error { + return s.db.Close() +} + func execSchema(db *sql.DB, path string) error { data, err := os.ReadFile(path) if err != nil { @@ -41,24 +46,54 @@ func execSchema(db *sql.DB, path string) error { return err } -// CreateEvent inserts a new event. -func (s *Store) CreateEvent(eventType string, occurredAt int64, note string) error { - query := ` - INSERT INTO events (event_type, occurred_at, note) - VALUES (?, ?, ?) - ` - _, err := s.db.Exec(query, eventType, occurredAt, note) +func migrate(db *sql.DB) error { + if hasColumn(db, "events", "chat_id") { + return nil + } + slog.Info("migrating database to per-chat schema") + for _, t := range []string{"days_off", "settings", "events"} { + db.Exec("DROP TABLE IF EXISTS " + t) + } + return execSchema(db, schemaPath) +} + +func hasColumn(db *sql.DB, table, column string) bool { + rows, err := db.Query("PRAGMA table_info(" + table + ")") + if err != nil { + return false + } + defer rows.Close() + for rows.Next() { + var cid int + var name, ctype string + var notnull, pk int + var dflt sql.NullString + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { + return false + } + if name == column { + return true + } + } + return false +} + +func (s *Store) CreateEvent(chatID int64, eventType string, occurredAt int64, note string) error { + _, err := s.db.Exec( + "INSERT INTO events (chat_id, event_type, occurred_at, note) VALUES (?, ?, ?, ?)", + chatID, eventType, occurredAt, note, + ) return err } -// GetLastEvent returns the most recent event. -func (s *Store) GetLastEvent() (*Event, error) { +func (s *Store) GetLastEvent(chatID int64) (*Event, error) { row, err := s.db.Query(` - SELECT id, event_type, occurred_at, note, created_at + SELECT id, chat_id, event_type, occurred_at, note, created_at FROM events + WHERE chat_id = ? ORDER BY occurred_at DESC LIMIT 1 - `) + `, chatID) if err != nil { return nil, err } @@ -67,66 +102,58 @@ func (s *Store) GetLastEvent() (*Event, error) { return nil, sql.ErrNoRows } var e Event - if err := row.Scan(&e.ID, &e.EventType, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil { + if err := row.Scan(&e.ID, &e.ChatID, &e.EventType, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil { return nil, err } return &e, nil } -// GetSetting returns a setting value by key. -func (s *Store) GetSetting(key string) (string, error) { +func (s *Store) GetSetting(chatID int64, key string) (string, error) { var value string - err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value) + err := s.db.QueryRow("SELECT value FROM settings WHERE chat_id = ? AND key = ?", chatID, key).Scan(&value) if err != nil { return "", err } return value, nil } -// SetSetting stores or updates a setting. -func (s *Store) SetSetting(key, value string) error { - query := ` - INSERT INTO settings (key, value) VALUES (?, ?) - ON CONFLICT(key) DO UPDATE SET value=excluded.value - ` - _, err := s.db.Exec(query, key, value) +func (s *Store) SetSetting(chatID int64, key, value string) error { + _, err := s.db.Exec(` + INSERT INTO settings (chat_id, key, value) VALUES (?, ?, ?) + ON CONFLICT(chat_id, key) DO UPDATE SET value=excluded.value + `, chatID, key, value) return err } -// SetDayOff adds or updates a day‑off record. -func (s *Store) SetDayOff(date, reason string) error { - query := ` - INSERT INTO days_off (date, reason) VALUES (?, ?) - ON CONFLICT(date) DO UPDATE SET reason=excluded.reason - ` - _, err := s.db.Exec(query, date, reason) +func (s *Store) SetDayOff(chatID int64, date, reason string) error { + _, err := s.db.Exec(` + INSERT INTO days_off (chat_id, date, reason) VALUES (?, ?, ?) + ON CONFLICT(chat_id, date) DO UPDATE SET reason=excluded.reason + `, chatID, date, reason) return err } -// RemoveDayOff deletes a day‑off record. -func (s *Store) RemoveDayOff(date string) error { - _, err := s.db.Exec(`DELETE FROM days_off WHERE date = ?`, date) +func (s *Store) RemoveDayOff(chatID int64, date string) error { + _, err := s.db.Exec("DELETE FROM days_off WHERE chat_id = ? AND date = ?", chatID, date) return err } -// IsDayOff returns true if the given date (YYYY-MM-DD) is marked as day off. -func (s *Store) IsDayOff(date string) (bool, error) { +func (s *Store) IsDayOff(chatID int64, date string) (bool, error) { var exists int - err := s.db.QueryRow(`SELECT COUNT(1) FROM days_off WHERE date = ?`, date).Scan(&exists) + err := s.db.QueryRow("SELECT COUNT(1) FROM days_off WHERE chat_id = ? AND date = ?", chatID, date).Scan(&exists) if err != nil { return false, err } return exists > 0, nil } -// EventsForDay returns all events for a given day (ISO date string). -func (s *Store) EventsForDay(day string) ([]Event, error) { +func (s *Store) EventsForDay(chatID int64, day string) ([]Event, error) { rows, err := s.db.Query(` - SELECT id, event_type, occurred_at, note, created_at + SELECT id, chat_id, event_type, occurred_at, note, created_at FROM events - WHERE strftime('%Y-%m-%d', occurred_at, 'unixepoch') = ? + WHERE chat_id = ? AND strftime('%Y-%m-%d', occurred_at, 'unixepoch') = ? ORDER BY occurred_at ASC - `, day) + `, chatID, day) if err != nil { return nil, err } @@ -134,7 +161,7 @@ func (s *Store) EventsForDay(day string) ([]Event, error) { var events []Event for rows.Next() { var e Event - if err := rows.Scan(&e.ID, &e.EventType, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil { + if err := rows.Scan(&e.ID, &e.ChatID, &e.EventType, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil { return nil, err } events = append(events, e) @@ -142,11 +169,11 @@ func (s *Store) EventsForDay(day string) ([]Event, error) { return events, rows.Err() } -// Event represents a row from the events table. type Event struct { - ID int64 `json:"id"` - EventType string `json:"event_type"` - OccurredAt int64 `json:"occurred_at"` - Note string `json:"note"` - CreatedAt int64 `json:"created_at"` + ID int64 + ChatID int64 + EventType string + OccurredAt int64 + Note string + CreatedAt int64 }