refactor: export full Gregorian range, add timezone fallback, cleanup rate limit, configurable schema path

- GenerateMonthlyReport now takes explicit Gregorian start/end range,
  computed via monthGregorianRange, so Jalali/Hijri months spanning
  two Gregorian months are fully covered in exports.
- Added loadLocation helper (UTC fallback on error) and replaced all
  11 ignored-error call sites in handlers + export.
- Added periodicCleanup goroutine in NewHandler to prevent unbounded
  growth of the rateLimit map.
- schemaPath is now read from SCHEMA_PATH env var (init), defaulting
  to /app/db/schema.sql.
- Migrated sendDayView to use sendOrEdit helper, removing duplicated
  send/edit branching.
- Removed dead code: userYearMonthToGregorian (unused), the old
  startOffset function (replaced).
- Updated README with calendar docs, new env vars, full project tree.
- Updated .env.example and docker-compose.yml with SCHEMA_PATH.
- Simplified Makefile: added fmt, tidy, check, run-dev targets;
  removed broken Make-glob prerequisite pattern.
This commit is contained in:
2026-06-24 11:21:54 +03:30
parent f89f5607aa
commit d8599657f4
8 changed files with 193 additions and 122 deletions

View File

@@ -2,6 +2,7 @@ BOT_TOKEN=YOUR_TELEGRAM_BOT_TOKEN
HTTP_PROXY=
HTTPS_PROXY=
DB_PATH=db.sqlite3
SCHEMA_PATH=
BOT_WEBHOOK_URL=
BOT_LISTEN=:8080
BOT_TLS_CERT=

View File

@@ -1,24 +1,33 @@
.PHONY: build run test clean docker docker-run lint vet
BINARY=worktime-bot
BUILD_DIR=.build
build: $(BUILD_DIR)/$(BINARY)
.PHONY: all build run test clean fmt tidy vet check docker docker-run
$(BUILD_DIR)/$(BINARY): cmd/bot/*.go internal/**/*.go go.mod go.sum
mkdir -p $(BUILD_DIR)
all: build test check
build:
go build -o $(BUILD_DIR)/$(BINARY) ./cmd/bot/
run: build
$(BUILD_DIR)/$(BINARY)
run-dev:
go run ./cmd/bot/
test:
go test ./...
lint: vet
fmt:
go fmt ./...
tidy:
go mod tidy
vet:
go vet ./...
check: fmt vet
clean:
rm -rf $(BUILD_DIR)
@@ -27,6 +36,3 @@ docker:
docker-run: docker
docker compose up -d
.PHONY: all
all: build test lint

109
README.md
View File

@@ -1,6 +1,6 @@
# WorkTime Bot
A single-user Telegram time-tracking bot. Clock in/out, toggle remote/onsite, mark day off, get daily reports, and export monthly Excel reports.
A single-user Telegram time-tracking bot with **multi-calendar support** (Gregorian, Jalali/Persian, Hijri). Clock in/out, toggle remote/onsite, mark day off, get daily reports at a configurable time, browse history by month, and export monthly `.xlsx` reports.
## Quick Start
@@ -10,43 +10,69 @@ cp .env.example .env
docker compose up -d
```
### Environment Variables
## Environment Variables
| Variable | Purpose |
| ------------------- | ----------------------------------------------------------- |
| `BOT_TOKEN` | Telegram Bot API token (required) |
| `BOT_ALLOWED_USERS` | Comma-separated Telegram user IDs (empty = allow all) |
| `HTTPS_PROXY` | Outbound proxy for Telegram API (e.g. `http://proxy:10808`) |
| `DB_PATH` | Path to SQLite database (default: `db.sqlite3`) |
| `BOT_WEBHOOK_URL` | Set for webhook mode (omit for polling) |
| Variable | Default | Purpose |
| ------------------- | -------------------- | ----------------------------------------------------- |
| `BOT_TOKEN` | — | Telegram Bot API token **(required)** |
| `BOT_ALLOWED_USERS` | (all) | Comma-separated Telegram user IDs (empty = allow all) |
| `HTTP_PROXY` | — | Outbound HTTP proxy for Telegram API |
| `HTTPS_PROXY` | — | Outbound HTTPS proxy for Telegram API |
| `DB_PATH` | `db.sqlite3` | Path to SQLite database file |
| `SCHEMA_PATH` | `/app/db/schema.sql` | Path to SQL schema file |
| `BOT_WEBHOOK_URL` | — | Set for webhook mode (omit for long-polling) |
| `BOT_LISTEN` | `:8080` | Listen address for webhook mode |
| `BOT_TLS_CERT` | — | TLS certificate path (webhook HTTPS) |
| `BOT_TLS_KEY` | — | TLS key path (webhook HTTPS) |
## Calendar Support
Users can choose between three calendars via `/settings`:
- **Gregorian** — standard international calendar
- **Jalali** (Persian/Solar Hijri) — official calendar of Iran and Afghanistan
- **Hijri** (Islamic lunar) — used in many Muslim-majority countries
The history view, export month picker, and date displays all adapt to the user's chosen calendar. The `/export` and `/edit` commands accept dates in the user's calendar. Internally, all dates are stored as Gregorian.
## Usage
**Persistent reply keyboard** at the bottom: `Clock In` / `Clock Out`
**Persistent reply keyboard** at the bottom of the chat: `Clock In` / `Clock Out`
**Inline menu** on `/start`:
- Clock In / Clock Out
- Report / Export
- Toggle Remote/Onsite / Day Off
| Button | Action |
| -------------------- | --------------------------------- |
| Clock In / Clock Out | Record work event |
| Report | Today's work & break summary |
| Export | Pick a month → download `.xlsx` |
| History | Browse past days in calendar view |
| Toggle Remote/Onsite | Switch work type |
| Day Off | Mark today as day off |
| Settings | Timezone, accent, calendar type |
### Commands
| Command | Action |
| -------------------------- | ---------------------------- |
| `/start` | Show inline menu |
| `Clock In` or `/clockin` | Record clock-in |
| `Clock Out` or `/clockout` | Record clock-out |
| `/remote` | Toggle remote/onsite mode |
| `/report` | Today's work & break summary |
| `/export` | Monthly `.xlsx` report |
| `/dayoff` | Toggle today as day off |
| Command | Action |
| --------------------- | ------------------------------------------------------- |
| `/start` | Show inline menu |
| `/clockin` | Record clock-in |
| `/clockout` | Record clock-out |
| `/remote` | Toggle remote/onsite |
| `/report` | Today's work & break summary |
| `/export` | Export this month (optional `YYYY-MM` in your calendar) |
| `/dayoff` | Toggle today as day off |
| `/edit YYYY-MM-DD` | View/edit events for a specific date |
| `/settimezone <IANA>` | Set timezone (e.g. `Asia/Tehran`) |
| `/settings` | Open settings menu |
Daily report is sent automatically at 23:00.
## Daily Report
A daily summary is sent automatically at the user's configured report time (default `23:00`). The scheduler checks every 30 seconds.
## How Break Time Works
The gap between a clock-out and the next clock-in is counted as break:
The gap between a clock-out and the next clock-in is counted as break. Gaps shorter than the **min break threshold** (default 5 minutes) are absorbed into work time.
```
09:00 Clock In → work starts
@@ -56,20 +82,43 @@ The gap between a clock-out and the next clock-in is counted as break:
total: 7h work, 1h break
```
## Export
The `/export` command opens a month picker. Select any month in your calendar and download an `.xlsx` file with columns: Date, Clock In, Clock Out, Type, Work, Break.
Four color themes are available in settings: **Ocean**, **Beach**, **Rose**, **Catppuccin**.
## Project Structure
```
├── cmd/bot/ main.go, webhook.go
├── cmd/bot/
│ ├── main.go Entrypoint, polling/webhook, scheduler
│ └── webhook.go Webhook mode with TLS support
├── internal/
│ ├── bot/ handlers, export, totals
└── db/ SQLite store
├── db/schema.sql Database schema
│ ├── bot/
│ ├── handlers.go Message/callback routing, views, menu builders
│ │ ├── totals.go Break/work computation state machine
│ │ ├── export.go Excel (.xlsx) report generation
│ │ └── dateutil.go Gregorian/Jalali/Hijri conversions & calendars
│ └── db/
│ └── store.go SQLite store with auto-migration
├── db/
│ └── schema.sql Database schema
├── .gitea/workflows/
│ └── ci.yml Gitea Actions CI
├── Dockerfile
── docker-compose.yml
── docker-compose.yml
├── Makefile
├── .env.example
└── README.md
```
## Build
## Build & Run
```bash
# Local (requires Go 1.26+ and CGo-free SQLite)
make run
# Docker
docker compose up -d --build
```

View File

@@ -12,6 +12,7 @@ services:
- HTTP_PROXY=${HTTP_PROXY-}
- HTTPS_PROXY=${HTTPS_PROXY-}
- DB_PATH=/data/db.sqlite3
- SCHEMA_PATH=${SCHEMA_PATH:-/app/db/schema.sql}
- TZ=Asia/Tehran
volumes:
- worktime_data:/data

View File

@@ -1,6 +1,9 @@
package bot
import "fmt"
import (
"fmt"
"time"
)
var gregMonthDays = []int{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
var jalaliMonthDays = []int{31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29}
@@ -303,20 +306,6 @@ func userDateToGregorian(dateStr, cal string) string {
return fmt.Sprintf("%04d-%02d-%02d", y, m, d)
}
// userYearMonthToGregorian converts a (year, month) in the user's calendar
// to the corresponding Gregorian (year, month). The day is assumed to be 1.
func userYearMonthToGregorian(year, month int, cal string) (int, int) {
switch cal {
case "jalali":
gy, gm, _ := jalaliToGregorian(year, month, 1)
return gy, gm
case "hijri":
gy, gm, _ := hijriToGregorian(year, month, 1)
return gy, gm
}
return year, month
}
// monthGregorianRange returns the Gregorian start and end date strings
// for a given calendar month (cal=gregorian|jalali|hijri).
func monthGregorianRange(cal string, year, month int) (start, end string) {
@@ -340,3 +329,12 @@ func monthGregorianRange(cal string, year, month int) (start, end string) {
}
return
}
// loadLocation loads a timezone, falling back to UTC on error.
func loadLocation(tz string) *time.Location {
loc, err := time.LoadLocation(tz)
if err != nil {
return time.UTC
}
return loc
}

View File

@@ -22,7 +22,7 @@ var themes = map[string]themeColors{
"catppuccin": {headerFill: "B48DED", border: "D9D0E8", totalFill: "F0E8FC"},
}
func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, calendar string, year int, month time.Month) ([]byte, error) {
func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, calendar string, calYear, calMonth int, start, end time.Time) ([]byte, error) {
theme, ok := themes[accent]
if !ok {
theme = themes["ocean"]
@@ -76,16 +76,7 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
})
var jy, jm int
switch calendar {
case "jalali":
jy, jm, _ = gregorianToJalali(year, int(month), 1)
case "hijri":
jy, jm, _ = gregorianToHijri(year, int(month), 1)
default:
jy, jm = year, int(month)
}
monthName := formatMonthTitle(calendar, jy, jm)
monthName := formatMonthTitle(calendar, calYear, calMonth)
f.SetCellValue(sheet, "A1", fmt.Sprintf("Work Time Report - %s", monthName))
f.MergeCell(sheet, "A1", "F1")
f.SetCellStyle(sheet, "A1", "F1", titleStyle)
@@ -106,16 +97,11 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
f.SetColWidth(sheet, "E", "E", 10)
f.SetColWidth(sheet, "F", "F", 10)
loc, err := time.LoadLocation(timezone)
if err != nil {
loc = time.UTC
}
loc := loadLocation(timezone)
row := 3
firstDay := time.Date(year, month, 1, 0, 0, 0, 0, loc)
lastDay := firstDay.AddDate(0, 1, -1)
userDays, err := store.GetUserDaysInRange(userID, firstDay.Format("2006-01-02"), lastDay.Format("2006-01-02"))
userDays, err := store.GetUserDaysInRange(userID, start.Format("2006-01-02"), end.Format("2006-01-02"))
if err != nil {
return nil, err
}
@@ -127,7 +113,7 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
var totalWork, totalBreak int64
for d := firstDay; !d.After(lastDay); d = d.AddDate(0, 0, 1) {
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
dateStr := d.Format("2006-01-02")
displayDate := formatDateForCalendar(dateStr, calendar)

View File

@@ -25,12 +25,28 @@ type Handler struct {
}
func NewHandler(bot *tgbotapi.BotAPI, store *db.Store, allowed map[int64]bool) *Handler {
return &Handler{
h := &Handler{
Bot: bot,
DB: store,
AllowedUsers: allowed,
lastMsgTime: make(map[int64]time.Time),
}
go h.periodicCleanup()
return h
}
func (h *Handler) periodicCleanup() {
for {
time.Sleep(10 * time.Second)
h.mu.Lock()
cutoff := time.Now().Add(-rateLimitInterval * 2)
for uid, t := range h.lastMsgTime {
if t.Before(cutoff) {
delete(h.lastMsgTime, uid)
}
}
h.mu.Unlock()
}
}
type actionFunc func(int64) (string, error)
@@ -222,7 +238,7 @@ func (h *Handler) clockIn(chatID int64) (string, error) {
}
if last != nil && last.EventType == "in" {
loc, _ := time.LoadLocation(user.Timezone)
loc := loadLocation(user.Timezone)
now := time.Now().In(loc)
lastTime := time.Unix(last.OccurredAt, 0).In(loc)
if lastTime.Format("2006-01-02") == now.Format("2006-01-02") {
@@ -230,7 +246,7 @@ func (h *Handler) clockIn(chatID int64) (string, error) {
}
}
loc, _ := time.LoadLocation(user.Timezone)
loc := loadLocation(user.Timezone)
now := time.Now().In(loc)
wt := day.CurrentWorkTypeID
if err := h.DB.CreateEvent(user.ID, day.ID, "in", &wt, now.Unix(), ""); err != nil {
@@ -254,7 +270,7 @@ func (h *Handler) clockOut(chatID int64) (string, error) {
if last.EventType == "out" {
return "", errors.New("Already clocked out")
}
loc, _ := time.LoadLocation(user.Timezone)
loc := loadLocation(user.Timezone)
now := time.Now().In(loc)
if err := h.DB.CreateEvent(user.ID, day.ID, "out", nil, now.Unix(), ""); err != nil {
return "", err
@@ -726,7 +742,7 @@ func (h *Handler) handleEditMsg(msg *tgbotapi.Message) {
h.sendText(msg.Chat.ID, "Error loading profile")
return
}
loc, _ := time.LoadLocation(user.Timezone)
loc := loadLocation(user.Timezone)
date = userDateToGregorian(date, user.Calendar)
h.sendDayView(msg.Chat.ID, 0, user, date, loc, true)
}
@@ -737,7 +753,7 @@ func (h *Handler) handleHistoryCallback(chatID int64, msgID int, callbackID, dat
if err != nil {
return
}
loc, _ := time.LoadLocation(user.Timezone)
loc := loadLocation(user.Timezone)
if data == "history" {
h.editCalendar(chatID, msgID, 0, 0)
@@ -816,7 +832,7 @@ func (h *Handler) editCalendar(chatID int64, msgID int, year, month int) {
if err != nil {
return
}
loc, _ := time.LoadLocation(user.Timezone)
loc := loadLocation(user.Timezone)
now := time.Now().In(loc)
if year == 0 || month == 0 {
@@ -919,15 +935,7 @@ func (h *Handler) sendDayView(chatID int64, msgID int, user *db.User, date strin
tgbotapi.NewInlineKeyboardButtonData("Back to Calendar", "history"),
),
)
if isNewMsg || msgID == 0 {
reply := tgbotapi.NewMessage(chatID, text)
reply.ReplyMarkup = &kb
h.Bot.Send(reply)
} else {
edit := tgbotapi.NewEditMessageText(chatID, msgID, text)
edit.ReplyMarkup = &kb
h.Bot.Send(edit)
}
h.sendOrEdit(chatID, msgID, text, &kb)
return
}
@@ -963,15 +971,7 @@ func (h *Handler) sendDayView(chatID int64, msgID int, user *db.User, date strin
))
kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...)
if isNewMsg || msgID == 0 {
reply := tgbotapi.NewMessage(chatID, text)
reply.ReplyMarkup = &kb
h.Bot.Send(reply)
} else {
edit := tgbotapi.NewEditMessageText(chatID, msgID, text)
edit.ReplyMarkup = &kb
h.Bot.Send(edit)
}
h.sendOrEdit(chatID, msgID, text, &kb)
}
func (h *Handler) editEventType(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) {
@@ -1184,25 +1184,32 @@ func (h *Handler) handleExport(msg *tgbotapi.Message) {
h.sendText(msg.Chat.ID, "Error loading profile")
return
}
loc, _ := time.LoadLocation(user.Timezone)
loc := loadLocation(user.Timezone)
now := time.Now().In(loc)
// Default to current month in user's calendar
cy, cm := now.Year(), int(now.Month())
switch user.Calendar {
case "jalali":
cy, cm, _ = gregorianToJalali(cy, cm, now.Day())
case "hijri":
cy, cm, _ = gregorianToHijri(cy, cm, now.Day())
}
// Parse optional YYYY-MM argument (in user's calendar)
gy, gm := now.Year(), int(now.Month())
if len(msg.Text) > 7 {
if n, _ := fmt.Sscanf(msg.Text[7:], "%d-%d", &gy, &gm); n == 2 {
if gy < 0 || gm < 1 || gm > 12 {
if n, _ := fmt.Sscanf(msg.Text[7:], "%d-%d", &cy, &cm); n == 2 {
if cy < 0 || cm < 1 || cm > 12 {
h.sendText(msg.Chat.ID, "Invalid date. Use: /export YYYY-MM")
return
}
gy, gm = userYearMonthToGregorian(gy, gm, user.Calendar)
}
}
h.sendExportDirect(msg.Chat.ID, user, gy, gm)
h.sendExportDirect(msg.Chat.ID, user, cy, cm)
}
func (h *Handler) sendExportDirect(chatID int64, user *db.User, gy, gm int) {
func (h *Handler) sendExportDirect(chatID int64, user *db.User, calYear, calMonth int) {
// Check if clocked in
last, err := h.DB.GetLastEvent(user.ID)
if err == nil && last != nil && last.EventType == "in" {
@@ -1210,8 +1217,20 @@ func (h *Handler) sendExportDirect(chatID int64, user *db.User, gy, gm int) {
return
}
slog.Info("export", "user_id", user.ID, "year", gy, "month", gm)
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, gy, time.Month(gm))
loc := loadLocation(user.Timezone)
startStr, endStr := monthGregorianRange(user.Calendar, calYear, calMonth)
start, err := time.Parse("2006-01-02", startStr)
if err != nil {
start = time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
}
end, err := time.Parse("2006-01-02", endStr)
if err != nil {
startOfMonth := time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
end = startOfMonth.AddDate(0, 1, -1)
}
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", calYear, "cal_m", calMonth, "from", startStr, "to", endStr)
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, calYear, calMonth, start, end)
if err != nil {
slog.Error("generate report", "error", err)
h.sendText(chatID, "Error generating report")
@@ -1219,7 +1238,7 @@ func (h *Handler) sendExportDirect(chatID int64, user *db.User, gy, gm int) {
}
slog.Info("report generated", "bytes", len(data))
doc := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{
Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", gy, gm)),
Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", calYear, calMonth)),
Bytes: data,
})
if _, err := h.Bot.Send(doc); err != nil {
@@ -1233,7 +1252,7 @@ func (h *Handler) exportCallback(chatID int64, msgID int, callbackID string) {
if err != nil {
return
}
loc, _ := time.LoadLocation(user.Timezone)
loc := loadLocation(user.Timezone)
now := time.Now().In(loc)
cy, cm := now.Year(), int(now.Month())
switch user.Calendar {
@@ -1313,16 +1332,20 @@ func (h *Handler) handleExportCallback(chatID int64, msgID int, callbackID, data
h.Bot.Send(edit)
return
}
// Convert from user's calendar to Gregorian for export
gy, gm := y, m
switch user.Calendar {
case "jalali":
gy, gm, _ = jalaliToGregorian(y, m, 1)
case "hijri":
gy, gm, _ = hijriToGregorian(y, m, 1)
// Convert from user's calendar to full Gregorian range
startStr, endStr := monthGregorianRange(user.Calendar, y, m)
start, err := time.Parse("2006-01-02", startStr)
if err != nil {
h.sendText(chatID, "Error processing date")
return
}
slog.Info("export", "user_id", user.ID, "year", gy, "month", gm, "cal", user.Calendar, "cal_y", y, "cal_m", m)
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, gy, time.Month(gm))
end, err := time.Parse("2006-01-02", endStr)
if err != nil {
h.sendText(chatID, "Error processing date")
return
}
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", y, "cal_m", m, "from", startStr, "to", endStr)
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, y, m, start, end)
if err != nil {
slog.Error("generate report", "error", err)
edit := tgbotapi.NewEditMessageText(chatID, msgID, "Error generating report")
@@ -1337,7 +1360,7 @@ func (h *Handler) handleExportCallback(chatID int64, msgID int, callbackID, data
edit.ReplyMarkup = &kb
h.Bot.Send(edit)
doc := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{
Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", gy, gm)),
Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", y, m)),
Bytes: data,
})
if _, err := h.Bot.Send(doc); err != nil {
@@ -1367,7 +1390,7 @@ func (h *Handler) buildStatusText(chatID int64) string {
last, err := h.DB.GetLastEvent(user.ID)
if err == nil && last != nil && last.EventType == "in" {
loc, _ := time.LoadLocation(user.Timezone)
loc := loadLocation(user.Timezone)
t := time.Unix(last.OccurredAt, 0).In(loc).Format("15:04")
text += fmt.Sprintf("\nStatus: working since %s", t)
} else {
@@ -1376,7 +1399,7 @@ func (h *Handler) buildStatusText(chatID int64) string {
events, err := h.DB.EventsForDayByDayID(day.ID)
if err == nil && len(events) > 0 {
loc, _ := time.LoadLocation(user.Timezone)
loc := loadLocation(user.Timezone)
now := time.Now().In(loc)
y, m, d := now.Date()
dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix()

View File

@@ -11,7 +11,14 @@ import (
const driverName = "sqlite"
var schemaPath = "/app/db/schema.sql"
var schemaPath string
func init() {
schemaPath = os.Getenv("SCHEMA_PATH")
if schemaPath == "" {
schemaPath = "/app/db/schema.sql"
}
}
type Store struct {
db *sql.DB