Compare commits

...

13 Commits

Author SHA1 Message Date
7aada8d118 Add README.md
Some checks failed
CI / build (push) Has been cancelled
2026-06-23 22:36:41 +03:30
b4fab29d45 Clean up: remove unused packages, switch to slog, tidy Makefile 2026-06-23 22:34:01 +03:30
32446a99d1 Fix break tracking (out→in gap), add startup log, clean up /start message 2026-06-23 22:00:25 +03:30
7da6f4cb49 Add BOT_ALLOWED_USERS, fix SetColWidth params, drop fmt import, save chat_id on start 2026-06-23 21:56:18 +03:30
a92c72f46a feat: add persistent reply keyboard with Clock In / Clock Out buttons
Also handle button text (English + Persian) in message router.
2026-06-23 21:44:41 +03:30
5009bb130c chore: remove AGENTS.md (all tasks implemented) 2026-06-23 21:42:55 +03:30
4a5778afe5 feat: add inline keyboard, /start command, and callback handlers
- Show main menu with Clock In/Out, Report, Export, Remote, Day Off buttons
- Each action result includes a 'Back to Menu' button
- Handle callback queries alongside text commands
- Delete stale webhook before switching to polling
- Log i18n initialization error instead of silently ignoring it
2026-06-23 21:42:52 +03:30
0be03f7078 chore: connect bot container to proxy_net for nginx reverse-proxy
This allows nginx-edge to reach the bot via service name
on the shared proxy_net external network.
2026-06-23 21:42:45 +03:30
c4e8c8dc3a fix: make schema idempotent with IF NOT EXISTS
Add i18n locale files to Docker image and fix all CREATE
statements to use IF NOT EXISTS to survive container restarts.
2026-06-23 21:42:39 +03:30
b0fcb7a6c3 main: pass correct APIEndpoint to NewBotAPIWithClient 2026-06-23 21:00:27 +03:30
3aaf5b326d main: use default HTTP client (proxy via env) 2026-06-23 20:58:52 +03:30
f6d6e994aa Docker: proxy args for go mod download (temporary) 2026-06-23 20:53:58 +03:30
7d03a4d8fc Docker: use explicit Go 1.26.4 image to match go.mod 2026-06-23 20:52:52 +03:30
16 changed files with 692 additions and 366 deletions

View File

@@ -1,7 +1,9 @@
BOT_TOKEN=YOUR_TELEGRAM_BOT_TOKEN BOT_TOKEN=YOUR_TELEGRAM_BOT_TOKEN
HTTP_PROXY= HTTP_PROXY=
HTTPS_PROXY=
DB_PATH=db.sqlite3 DB_PATH=db.sqlite3
BOT_WEBHOOK_URL= BOT_WEBHOOK_URL=
BOT_LISTEN=:8080 BOT_LISTEN=:8080
BOT_TLS_CERT= BOT_TLS_CERT=
BOT_TLS_KEY= BOT_TLS_KEY=
BOT_ALLOWED_USERS=

View File

@@ -1,39 +0,0 @@
# Agents.md
## Current Task
Build a simple Telegram timetracking bot in Go.
## Context Summary
- **User requirements**: only clockin / clockout, autoinfer breaks, onsite/remote flag (ask after first clockin), i18n (fa/en), SQLite storage, monthly .xlsx export, HTTP proxy support, Docker, CI via Gitea.
- **Design decisions**: single user, event pairing logic, minimal SQLite schema, ondemand Excel download, Docker multistage, CI/CD workflow.
- **Implementation plan**: 11 phases (scaffold → Docker → CI), with edgecase handling (multiple clockouts, missed clockout, midnight sessions, timezone).
## Edge Cases Covered
- Multiple clockouts → treat middle clockout as break start.
- No clockout → blocked unless clockin first.
- Midnight or overnight sessions → attributed to originating day.
- Unpaired final event → indicates currently clockedin.
- Remote work flag set after first clockin (default onsite).
## Edge Cases Checklist
- [ ] Clockin/out validation (no duplicate in/out).
- [ ] Onsite/remote flag storage.
- [ ] i18n message loading (fa/en).
- [ ] Excel file saved monthly and on demand.
- [ ] Proxy configuration respected for all outbound traffic.
## Next Steps (TODO)
1. Scaffold repo & config loader.
2. Initialize SQLite DB and event models.
3. Implement Telegram bot core with proxy transport.
4. Add clockin / clockout handlers with validation.
5. Build event pairing & daily/weekly/monthly totals logic.
6. Create monthly .xlsx template and generation.
7. Implement dayoff handling and editevent UI.
8. Add Dockerfile, dockercompose, and Gitea CI workflow.
9. Polish i18n, error handling, and edgecase tests.

View File

@@ -1,6 +1,12 @@
FROM golang:1.26-alpine AS builder FROM golang:1.26.4-alpine AS builder
WORKDIR /src WORKDIR /src
ARG HTTP_PROXY
ARG HTTPS_PROXY
ENV HTTP_PROXY=${HTTP_PROXY}
ENV HTTPS_PROXY=${HTTPS_PROXY}
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
@@ -14,7 +20,6 @@ WORKDIR /data
COPY --from=builder /usr/local/bin/worktime-bot /usr/local/bin/worktime-bot COPY --from=builder /usr/local/bin/worktime-bot /usr/local/bin/worktime-bot
COPY --from=builder /src/db/schema.sql ./db/schema.sql COPY --from=builder /src/db/schema.sql ./db/schema.sql
VOLUME ["/data"] VOLUME ["/data"]
CMD ["worktime-bot"] CMD ["worktime-bot"]

View File

@@ -5,7 +5,7 @@ BUILD_DIR=.build
build: $(BUILD_DIR)/$(BINARY) build: $(BUILD_DIR)/$(BINARY)
$(BUILD_DIR)/$(BINARY): cmd/bot/*.go internal/**/*.go pkg/**/*.go go.mod go.sum $(BUILD_DIR)/$(BINARY): cmd/bot/*.go internal/**/*.go go.mod go.sum
mkdir -p $(BUILD_DIR) mkdir -p $(BUILD_DIR)
go build -o $(BUILD_DIR)/$(BINARY) ./cmd/bot/ go build -o $(BUILD_DIR)/$(BINARY) ./cmd/bot/

75
README.md Normal file
View File

@@ -0,0 +1,75 @@
# 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.
## Quick Start
```bash
cp .env.example .env
# edit .env — set BOT_TOKEN and BOT_ALLOWED_USERS
docker compose up -d
```
### 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) |
## Usage
**Persistent reply keyboard** at the bottom: `Clock In` / `Clock Out`
**Inline menu** on `/start`:
- Clock In / Clock Out
- Report / Export
- Toggle Remote/Onsite / Day Off
### 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 |
Daily report is sent automatically at 23:00.
## How Break Time Works
The gap between a clock-out and the next clock-in is counted as break:
```
09:00 Clock In → work starts
12:00 Clock Out → work: 3h, break starts
13:00 Clock In → break: 1h, work resumes
17:00 Clock Out → work: 4h
total: 7h work, 1h break
```
## Project Structure
```
├── cmd/bot/ main.go, webhook.go
├── internal/
│ ├── bot/ handlers, export, totals
│ └── db/ SQLite store
├── db/schema.sql Database schema
├── Dockerfile
└── docker-compose.yml
```
## Build
```bash
docker compose up -d --build
```

View File

@@ -1,59 +1,64 @@
package main package main
import ( import (
"log" "log/slog"
"net/http" "net/http"
"net/url"
"os" "os"
"strconv"
"strings"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"worktimeBot/internal/bot" "worktimeBot/internal/bot"
"worktimeBot/internal/db" "worktimeBot/internal/db"
"worktimeBot/pkg/i18n"
) )
func main() { func main() {
// Load .env file if it exists (ignored when running inside Docker with env vars) slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
_ = godotenv.Load() AddSource: true,
Level: slog.LevelInfo,
})))
godotenv.Load()
token := os.Getenv("BOT_TOKEN") token := os.Getenv("BOT_TOKEN")
proxyURL := os.Getenv("HTTP_PROXY")
dbPath := getEnvDefault("DB_PATH", "db.sqlite3") dbPath := getEnvDefault("DB_PATH", "db.sqlite3")
// Build HTTP client with optional proxy httpClient := http.DefaultClient
var httpClient *http.Client
if proxyURL != "" {
parsed, err := url.Parse(proxyURL)
if err != nil {
log.Fatal(err)
}
tr := &http.Transport{Proxy: http.ProxyURL(parsed)}
httpClient = &http.Client{Transport: tr}
} else {
httpClient = http.DefaultClient
}
botAPI, err := tgbotapi.NewBotAPIWithClient(token, "", httpClient) botAPI, err := tgbotapi.NewBotAPIWithClient(token, tgbotapi.APIEndpoint, httpClient)
if err != nil { if err != nil {
log.Fatal(err) 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 {
log.Fatal(err) slog.Error("failed to open database", "path", dbPath, "error", err)
os.Exit(1)
} }
translator, _ := i18n.NewTranslator("en") allowedUsers := parseAllowedUsers(os.Getenv("BOT_ALLOWED_USERS"))
handler := bot.NewHandler(botAPI, dbStore, translator) handler := bot.NewHandler(botAPI, dbStore, allowedUsers)
slog.Info("bot started",
"bot_user", botAPI.Self.UserName,
"allowed_users", len(allowedUsers),
"polling", true,
)
webhookURL := os.Getenv("BOT_WEBHOOK_URL") webhookURL := os.Getenv("BOT_WEBHOOK_URL")
if webhookURL != "" { if webhookURL != "" {
startWebhook(botAPI, handler, webhookURL) startWebhook(botAPI, handler, webhookURL)
} else { } else {
// Fallback to longpolling if _, err := botAPI.Request(tgbotapi.DeleteWebhookConfig{}); err != nil {
slog.Error("delete webhook", "error", err)
}
go dailyReportScheduler(handler, dbStore)
u := tgbotapi.NewUpdate(0) u := tgbotapi.NewUpdate(0)
u.Timeout = 30 u.Timeout = 30
updates := botAPI.GetUpdatesChan(u) updates := botAPI.GetUpdatesChan(u)
@@ -61,8 +66,53 @@ func main() {
if update.Message != nil { if update.Message != nil {
handler.HandleMessage(update) handler.HandleMessage(update)
} }
if update.CallbackQuery != nil {
handler.HandleCallback(update)
} }
} }
}
}
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
}
func dailyReportScheduler(h *bot.Handler, store *db.Store) {
for {
now := time.Now()
next := time.Date(now.Year(), now.Month(), now.Day(), 23, 0, 0, 0, now.Location())
if now.After(next) {
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")
if err != nil {
slog.Error("daily report: chat_id not found")
continue
}
chatID, err := strconv.ParseInt(chatIDStr, 10, 64)
if err != nil {
slog.Error("daily report: invalid chat_id", "value", chatIDStr)
continue
}
slog.Info("sending daily report", "chat_id", chatID)
h.SendDailyReport(chatID)
}
} }
func getEnvDefault(key, defaultValue string) string { func getEnvDefault(key, defaultValue string) string {

View File

@@ -1,7 +1,7 @@
package main package main
import ( import (
"log" "log/slog"
"net/http" "net/http"
"os" "os"
@@ -10,52 +10,54 @@ import (
"worktimeBot/internal/bot" "worktimeBot/internal/bot"
) )
// startWebhook configures the Telegram webhook and starts the HTTP(S) listener.
func startWebhook(botAPI *tgbotapi.BotAPI, handler *bot.Handler, webhookURL string) { func startWebhook(botAPI *tgbotapi.BotAPI, handler *bot.Handler, webhookURL string) {
// Delete any previously set webhook
if _, err := botAPI.Request(tgbotapi.DeleteWebhookConfig{}); err != nil { if _, err := botAPI.Request(tgbotapi.DeleteWebhookConfig{}); err != nil {
log.Fatal("DeleteWebhook:", err) slog.Error("delete webhook", "error", err)
os.Exit(1)
} }
// Set the new webhook
wh, err := tgbotapi.NewWebhook(webhookURL) wh, err := tgbotapi.NewWebhook(webhookURL)
if err != nil { if err != nil {
log.Fatal("NewWebhook:", err) slog.Error("new webhook", "error", err)
os.Exit(1)
} }
if _, err := botAPI.Request(wh); err != nil { if _, err := botAPI.Request(wh); err != nil {
log.Fatal("SetWebhook:", err) slog.Error("set webhook", "error", err)
os.Exit(1)
} }
// Register the webhook endpoint and get the updates channel
updates := botAPI.ListenForWebhook("/webhook") updates := botAPI.ListenForWebhook("/webhook")
// Determine where to listen
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")
if tlsCert != "" && tlsKey != "" { if tlsCert != "" && tlsKey != "" {
log.Printf("Starting HTTPS webhook on %s", 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, nil); err != nil {
log.Fatal("HTTPS server:", err) slog.Error("HTTPS server", "error", err)
os.Exit(1)
} }
}() }()
} else { } else {
log.Printf("Starting HTTP webhook on %s (TLS handled by reverse proxy)", 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, nil); err != nil {
log.Fatal("HTTP server:", err) slog.Error("HTTP server", "error", err)
os.Exit(1)
} }
}() }()
} }
log.Printf("Webhook registered at %s", webhookURL) slog.Info("webhook registered", "url", webhookURL)
// Process incoming updates
for update := range updates { for update := range updates {
if update.Message != nil { if update.Message != nil {
handler.HandleMessage(update) handler.HandleMessage(update)
} }
if update.CallbackQuery != nil {
handler.HandleCallback(update)
}
} }
} }

View File

@@ -1,4 +1,4 @@
CREATE TABLE events ( CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
event_type TEXT NOT NULL event_type TEXT NOT NULL
CHECK (event_type IN ('in', 'out')), CHECK (event_type IN ('in', 'out')),
@@ -9,7 +9,7 @@ CREATE TABLE events (
DEFAULT (unixepoch()) DEFAULT (unixepoch())
); );
CREATE INDEX idx_events_occurred_at CREATE INDEX IF NOT EXISTS idx_events_occurred_at
ON events(occurred_at); ON events(occurred_at);
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
@@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS settings (
value TEXT NOT NULL DEFAULT '' value TEXT NOT NULL DEFAULT ''
); );
CREATE TABLE days_off ( CREATE TABLE IF NOT EXISTS days_off (
date TEXT PRIMARY KEY date TEXT PRIMARY KEY
CHECK (date = strftime('%Y-%m-%d', date)), CHECK (date = strftime('%Y-%m-%d', date)),
reason TEXT NOT NULL DEFAULT '', reason TEXT NOT NULL DEFAULT '',

View File

@@ -1,15 +1,25 @@
services: services:
worktime-bot: worktime-bot:
build: . build:
context: .
args:
HTTP_PROXY: http://192.168.100.2:10808
HTTPS_PROXY: http://192.168.100.2:10808
env_file: env_file:
- .env - .env
environment: environment:
- BOT_TOKEN=${BOT_TOKEN} - BOT_TOKEN=${BOT_TOKEN}
- HTTP_PROXY=${HTTP_PROXY-} - HTTP_PROXY=${HTTP_PROXY-}
- HTTPS_PROXY=${HTTPS_PROXY-}
- DB_PATH=/data/db.sqlite3 - DB_PATH=/data/db.sqlite3
volumes: volumes:
- worktime_data:/data - worktime_data:/data
networks:
default:
name: proxy_net
external: true
volumes: volumes:
worktime_data: worktime_data:
driver: local driver: local

View File

@@ -9,68 +9,155 @@ import (
"worktimeBot/internal/db" "worktimeBot/internal/db"
) )
// GenerateMonthlyReport creates an Excel spreadsheet for the given month.
func GenerateMonthlyReport(store *db.Store, year int, month time.Month) ([]byte, error) { func GenerateMonthlyReport(store *db.Store, year int, month time.Month) ([]byte, error) {
f := excelize.NewFile() f := excelize.NewFile()
defer func() { _ = f.Close() }() defer f.Close()
sheet := "Report" sheet := "Report"
// Create a new sheet
index, err := f.NewSheet(sheet) index, err := f.NewSheet(sheet)
if err != nil { if err != nil {
return nil, err return nil, err
} }
f.SetActiveSheet(index) f.SetActiveSheet(index)
f.DeleteSheet("Sheet1")
// Header row titleStyle, _ := f.NewStyle(&excelize.Style{
headers := []string{"Date", "Work Duration (h)", "Pairs"} Font: &excelize.Font{Bold: true, Size: 14, Color: "FFFFFF"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
})
headerStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "FFFFFF"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}},
Border: []excelize.Border{
{Type: "left", Color: "FFFFFF", Style: 1},
{Type: "right", Color: "FFFFFF", Style: 1},
{Type: "top", Color: "FFFFFF", Style: 1},
{Type: "bottom", Color: "FFFFFF", Style: 1},
},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
})
dataStyle, _ := f.NewStyle(&excelize.Style{
Border: []excelize.Border{
{Type: "left", Color: "D9D9D9", Style: 1},
{Type: "right", Color: "D9D9D9", Style: 1},
{Type: "top", Color: "D9D9D9", Style: 1},
{Type: "bottom", Color: "D9D9D9", Style: 1},
},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
})
totalStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 11},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"D9E2F3"}},
Border: []excelize.Border{
{Type: "left", Color: "D9D9D9", Style: 1},
{Type: "right", Color: "D9D9D9", Style: 1},
{Type: "top", Color: "D9D9D9", Style: 1},
{Type: "bottom", Color: "D9D9D9", Style: 2},
},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
})
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)
f.SetRowHeight(sheet, 1, 30)
headers := []string{"Date", "Clock In", "Clock Out", "Work", "Break"}
for i, h := range headers { for i, h := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 1) cell, _ := excelize.CoordinatesToCellName(i+1, 2)
if err := f.SetCellValue(sheet, cell, h); err != nil { f.SetCellValue(sheet, cell, h)
return nil, err f.SetCellStyle(sheet, cell, cell, headerStyle)
}
} }
f.SetRowHeight(sheet, 2, 22)
row := 2 f.SetColWidth(sheet, "A", "A", 14)
f.SetColWidth(sheet, "B", "B", 10)
f.SetColWidth(sheet, "C", "C", 10)
f.SetColWidth(sheet, "D", "D", 10)
f.SetColWidth(sheet, "E", "E", 10)
row := 3
loc := time.Now().Location() loc := time.Now().Location()
firstDay := time.Date(year, month, 1, 0, 0, 0, 0, loc) firstDay := time.Date(year, month, 1, 0, 0, 0, 0, loc)
lastDay := firstDay.AddDate(0, 1, -1) lastDay := firstDay.AddDate(0, 1, -1)
for d := firstDay; d.Before(lastDay.AddDate(0, 0, 1)); d = d.AddDate(0, 0, 1) { var totalWork, totalBreak int64
for d := firstDay; !d.After(lastDay); d = d.AddDate(0, 0, 1) {
dateStr := d.Format("2006-01-02") dateStr := d.Format("2006-01-02")
isOff, err := store.IsDayOff(dateStr) isOff, err := store.IsDayOff(dateStr)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if isOff { if isOff {
continue // skip dayoffs continue
} }
events, err := store.EventsForDay(dateStr) events, err := store.EventsForDay(dateStr)
if err != nil { if err != nil {
return nil, err return nil, err
} }
totals := ComputeDailyTotals(events) totals := ComputeDailyTotals(events)
hours := float64(totals.TotalSeconds) / 3600.0
totalWork += totals.TotalSeconds
totalBreak += totals.BreakSeconds
cellDate, _ := excelize.CoordinatesToCellName(1, row) cellDate, _ := excelize.CoordinatesToCellName(1, row)
cellHours, _ := excelize.CoordinatesToCellName(2, row) f.SetCellValue(sheet, cellDate, dateStr)
cellPairs, _ := excelize.CoordinatesToCellName(3, row) f.SetCellStyle(sheet, cellDate, cellDate, dataStyle)
if err := f.SetCellValue(sheet, cellDate, dateStr); err != nil { if len(events) > 0 {
return nil, err cellIn, _ := excelize.CoordinatesToCellName(2, row)
cellOut, _ := excelize.CoordinatesToCellName(3, row)
f.SetCellValue(sheet, cellIn, time.Unix(events[0].OccurredAt, 0).Format("15:04"))
f.SetCellStyle(sheet, cellIn, cellIn, dataStyle)
if events[len(events)-1].EventType == "out" {
f.SetCellValue(sheet, cellOut, time.Unix(events[len(events)-1].OccurredAt, 0).Format("15:04"))
} }
if err := f.SetCellValue(sheet, cellHours, fmt.Sprintf("%.2f", hours)); err != nil { f.SetCellStyle(sheet, cellOut, cellOut, dataStyle)
return nil, err
}
if err := f.SetCellValue(sheet, cellPairs, totals.PairCount); err != nil {
return nil, err
} }
cellWork, _ := excelize.CoordinatesToCellName(4, row)
f.SetCellValue(sheet, cellWork, fmtHHMM(totals.TotalSeconds))
f.SetCellStyle(sheet, cellWork, cellWork, dataStyle)
cellBreak, _ := excelize.CoordinatesToCellName(5, row)
f.SetCellValue(sheet, cellBreak, fmtHHMM(totals.BreakSeconds))
f.SetCellStyle(sheet, cellBreak, cellBreak, dataStyle)
row++ row++
} }
row++
f.SetCellValue(sheet, fmt.Sprintf("A%d", row), "Total")
f.SetCellStyle(sheet, fmt.Sprintf("A%d", row), fmt.Sprintf("A%d", row), totalStyle)
f.SetCellValue(sheet, fmt.Sprintf("D%d", row), fmtDDHHMM(totalWork))
f.SetCellStyle(sheet, fmt.Sprintf("D%d", row), fmt.Sprintf("D%d", row), totalStyle)
f.SetCellValue(sheet, fmt.Sprintf("E%d", row), fmtDDHHMM(totalBreak))
f.SetCellStyle(sheet, fmt.Sprintf("E%d", row), fmt.Sprintf("E%d", row), totalStyle)
buf, err := f.WriteToBuffer() buf, err := f.WriteToBuffer()
if err != nil { if err != nil {
return nil, err return nil, err
} }
return buf.Bytes(), nil return buf.Bytes(), nil
} }
func fmtHHMM(seconds int64) string {
hours := seconds / 3600
mins := (seconds % 3600) / 60
return fmt.Sprintf("%d:%02d", hours, mins)
}
func fmtDDHHMM(seconds int64) string {
days := seconds / 86400
rem := seconds % 86400
hours := rem / 3600
mins := (rem % 3600) / 60
return fmt.Sprintf("%d:%02d:%02d", days, hours, mins)
}

View File

@@ -4,40 +4,38 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"time" "time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"worktimeBot/internal/db" "worktimeBot/internal/db"
"worktimeBot/pkg/i18n"
) )
type Handler struct { type Handler struct {
Bot *tgbotapi.BotAPI Bot *tgbotapi.BotAPI
DB *db.Store DB *db.Store
Translator *i18n.Translator AllowedUsers map[int64]bool
} }
func NewHandler(bot *tgbotapi.BotAPI, store *db.Store, tr *i18n.Translator) *Handler { func NewHandler(bot *tgbotapi.BotAPI, store *db.Store, allowed map[int64]bool) *Handler {
return &Handler{Bot: bot, DB: store, Translator: tr} return &Handler{Bot: bot, DB: store, AllowedUsers: allowed}
} }
// HandleMessage routes incoming messages.
func (h *Handler) HandleMessage(update tgbotapi.Update) { func (h *Handler) HandleMessage(update tgbotapi.Update) {
msg := update.Message msg := update.Message
if len(h.AllowedUsers) > 0 && !h.AllowedUsers[msg.Chat.ID] {
slog.Warn("unauthorized message", "chat_id", msg.Chat.ID, "user", msg.From.UserName, "text", msg.Text)
return
}
slog.Info("message", "chat_id", msg.Chat.ID, "text", msg.Text)
switch msg.Text { switch msg.Text {
case "/clockin": case "/start":
if err := h.handleClockIn(); err != nil { h.handleStart(msg)
h.replyWithError(msg.Chat.ID, err.Error()) case "/clockin", "Clock In":
} else { h.handleClockInMsg(msg)
h.replyWithText(msg.Chat.ID, "clock_in") case "/clockout", "Clock Out":
} h.handleClockOutMsg(msg)
case "/clockout":
if err := h.handleClockOut(); err != nil {
h.replyWithError(msg.Chat.ID, err.Error())
} else {
h.replyWithText(msg.Chat.ID, "clock_out")
}
case "/remote": case "/remote":
h.toggleRemote(msg) h.toggleRemote(msg)
case "/report": case "/report":
@@ -47,87 +45,394 @@ func (h *Handler) HandleMessage(update tgbotapi.Update) {
case "/dayoff": case "/dayoff":
h.handleDayOff(msg) h.handleDayOff(msg)
default: default:
h.replyWithText(msg.Chat.ID, "unknown_command") h.sendText(msg.Chat.ID, "Unknown command")
} }
} }
// handleClockIn validates and records a clockin event. func (h *Handler) HandleCallback(update tgbotapi.Update) {
func (h *Handler) handleClockIn() error { cb := update.CallbackQuery
last, err := h.DB.GetLastEvent() if len(h.AllowedUsers) > 0 && !h.AllowedUsers[cb.Message.Chat.ID] {
if err != nil && err != sql.ErrNoRows { slog.Warn("unauthorized callback", "chat_id", cb.Message.Chat.ID, "data", cb.Data)
return err h.Bot.Request(tgbotapi.NewCallback(cb.ID, "Unauthorized"))
return
} }
if last != nil && last.EventType == "in" { slog.Info("callback", "chat_id", cb.Message.Chat.ID, "data", cb.Data)
return errors.New(h.Translator.Get("already_clocked_in", "en")) chatID := cb.Message.Chat.ID
} msgID := cb.Message.MessageID
// Set default remote flag after first clockin. switch cb.Data {
if _, err := h.DB.GetSetting("remote_flag"); err != nil { case "clockin":
_ = h.DB.SetSetting("remote_flag", "onsite") h.clockInCallback(chatID, msgID, cb.ID)
} case "clockout":
return h.DB.CreateEvent("in", time.Now().Unix(), "") h.clockOutCallback(chatID, msgID, cb.ID)
} case "remote":
h.toggleRemoteCallback(chatID, msgID, cb.ID)
// handleClockOut validates and records a clockout event. case "report":
func (h *Handler) handleClockOut() error { h.reportCallback(chatID, msgID, cb.ID)
last, err := h.DB.GetLastEvent() case "export":
if err != nil { h.exportCallback(chatID, msgID, cb.ID)
return err case "dayoff":
} h.dayOffCallback(chatID, msgID, cb.ID)
if last == nil { case "back_menu":
return errors.New(h.Translator.Get("error", "en")) h.backToMenu(chatID, msgID, cb.ID)
} }
switch last.EventType { }
case "in":
return h.DB.CreateEvent("out", time.Now().Unix(), "") // --- /start ---
case "out":
return errors.New(h.Translator.Get("already_clocked_out", "en")) func (h *Handler) handleStart(msg *tgbotapi.Message) {
default: h.DB.SetSetting("chat_id", fmt.Sprintf("%d", msg.Chat.ID))
return errors.New(h.Translator.Get("error", "en")) slog.Info("start", "chat_id", msg.Chat.ID)
text := "Welcome! Choose an action:"
reply := tgbotapi.NewMessage(msg.Chat.ID, text)
reply.ReplyMarkup = mainKeyboard()
h.Bot.Send(reply)
}
func quickKeyboard() tgbotapi.ReplyKeyboardMarkup {
return tgbotapi.NewReplyKeyboard(
tgbotapi.NewKeyboardButtonRow(
tgbotapi.NewKeyboardButton("Clock In"),
tgbotapi.NewKeyboardButton("Clock Out"),
),
)
}
func mainKeyboard() tgbotapi.InlineKeyboardMarkup {
return tgbotapi.NewInlineKeyboardMarkup(
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("Clock In", "clockin"),
tgbotapi.NewInlineKeyboardButtonData("Clock Out", "clockout"),
),
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("Report", "report"),
tgbotapi.NewInlineKeyboardButtonData("Export", "export"),
),
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("Toggle Remote/Onsite", "remote"),
tgbotapi.NewInlineKeyboardButtonData("Day Off", "dayoff"),
),
)
}
func backKeyboard() tgbotapi.InlineKeyboardMarkup {
return tgbotapi.NewInlineKeyboardMarkup(
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"),
),
)
}
// --- 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"))
} }
} }
// toggleRemote switches the remote/onsite flag.
func (h *Handler) toggleRemote(msg *tgbotapi.Message) { func (h *Handler) toggleRemote(msg *tgbotapi.Message) {
current, err := h.DB.GetSetting("remote_flag") current, err := h.DB.GetSetting("remote_flag")
if err != nil { if err != nil {
current = "onsite" current = "onsite"
} }
newMode := "remote" newMode := "remote"
text := "🌍 Remote mode enabled"
if current == "remote" { if current == "remote" {
newMode = "onsite" newMode = "onsite"
text = "🏢 Onsite mode enabled"
} }
if err := h.DB.SetSetting("remote_flag", newMode); err != nil { if err := h.DB.SetSetting("remote_flag", newMode); err != nil {
h.replyWithError(msg.Chat.ID, "error") slog.Error("toggle remote", "error", err)
h.sendText(msg.Chat.ID, "Error toggling mode")
return return
} }
key := "remote_switched_on" slog.Info("remote toggled", "mode", newMode)
if newMode == "onsite" { h.sendText(msg.Chat.ID, text)
key = "remote_switched_off"
}
h.replyWithText(msg.Chat.ID, key)
} }
// handleReport sends daily work time summary.
func (h *Handler) handleReport(msg *tgbotapi.Message) { func (h *Handler) handleReport(msg *tgbotapi.Message) {
today := time.Now().Format("2006-01-02") text := h.buildDailyReport(time.Now())
events, err := h.DB.EventsForDay(today) 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 { if err != nil {
h.replyWithError(msg.Chat.ID, "error") return "Error fetching events."
return
} }
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) totals := ComputeDailyTotals(events)
if totals.PairCount == 0 { if totals.PairCount == 0 {
h.replyWithText(msg.Chat.ID, "total_work_time") t := time.Unix(events[0].OccurredAt, 0).Format("15:04")
return 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) workStr := formatDuration(totals.TotalSeconds)
breakStr := formatDuration(totals.BreakSeconds) breakStr := formatDuration(totals.BreakSeconds)
text := fmt.Sprintf("Work: %s | Break: %s", workStr, breakStr) lines += fmt.Sprintf("\n📊 Summary:\n Work: %s\n Break: %s\n", workStr, breakStr)
reply := tgbotapi.NewMessage(msg.Chat.ID, text)
_, _ = h.Bot.Send(reply) return lines
} }
// formatDuration converts seconds to a humanreadable string. 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 { func formatDuration(seconds int64) string {
hours := seconds / 3600 hours := seconds / 3600
mins := (seconds % 3600) / 60 mins := (seconds % 3600) / 60
@@ -137,55 +442,14 @@ func formatDuration(seconds int64) string {
return fmt.Sprintf("%dm", mins) return fmt.Sprintf("%dm", mins)
} }
// handleExport generates and sends a monthly Excel report. func (h *Handler) sendText(chatID int64, text string) {
func (h *Handler) handleExport(msg *tgbotapi.Message) {
now := time.Now()
year, month := now.Year(), now.Month()
data, err := GenerateMonthlyReport(h.DB, year, month)
if err != nil {
h.replyWithError(msg.Chat.ID, "error")
return
}
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 {
h.replyWithError(msg.Chat.ID, "error")
}
}
// handleDayOff toggles today as a day off.
func (h *Handler) handleDayOff(msg *tgbotapi.Message) {
today := time.Now().Format("2006-01-02")
isOff, err := h.DB.IsDayOff(today)
if err != nil {
h.replyWithError(msg.Chat.ID, "error")
return
}
if isOff {
if err := h.DB.RemoveDayOff(today); err != nil {
h.replyWithError(msg.Chat.ID, "error")
return
}
h.replyWithText(msg.Chat.ID, "dayoff_removed")
} else {
if err := h.DB.SetDayOff(today, ""); err != nil {
h.replyWithError(msg.Chat.ID, "error")
return
}
h.replyWithText(msg.Chat.ID, "dayoff_set")
}
}
func (h *Handler) replyWithText(chatID int64, key string) {
text := h.Translator.Get(key, "en")
reply := tgbotapi.NewMessage(chatID, text) reply := tgbotapi.NewMessage(chatID, text)
_, _ = h.Bot.Send(reply) reply.ReplyMarkup = quickKeyboard()
h.Bot.Send(reply)
} }
func (h *Handler) replyWithError(chatID int64, key string) { func (h *Handler) sendSuccessWithMenu(chatID int64, text string) {
text := h.Translator.Get(key, "en")
reply := tgbotapi.NewMessage(chatID, text) reply := tgbotapi.NewMessage(chatID, text)
_, _ = h.Bot.Send(reply) reply.ReplyMarkup = mainKeyboard()
h.Bot.Send(reply)
} }

View File

@@ -14,16 +14,12 @@ const (
StateOnBreak StateOnBreak
) )
// DailyTotals holds aggregated times for a single day.
type DailyTotals struct { type DailyTotals struct {
TotalSeconds int64 TotalSeconds int64
BreakSeconds int64 BreakSeconds int64
PairCount int PairCount int
} }
// ComputeDailyTotals pairs events with break inference.
// Multiout sequences: first out ends work, second out starts break,
// next in ends break, next out ends work, etc.
func ComputeDailyTotals(events []db.Event) DailyTotals { func ComputeDailyTotals(events []db.Event) DailyTotals {
if len(events) == 0 { if len(events) == 0 {
return DailyTotals{} return DailyTotals{}
@@ -40,34 +36,31 @@ func ComputeDailyTotals(events []db.Event) DailyTotals {
state := StateIdle state := StateIdle
for _, e := range sorted { for _, e := range sorted {
switch { switch e.EventType {
case e.EventType == "in": case "in":
switch state { switch state {
case StateIdle, StateOnBreak: case StateIdle:
if state == StateOnBreak { workStart = e.OccurredAt
breakTotal += e.OccurredAt - breakStart state = StateWorking
breakStart = 0 case StateOnBreak:
} breakTotal += e.OccurredAt - breakStart
breakStart = 0
workStart = e.OccurredAt workStart = e.OccurredAt
state = StateWorking state = StateWorking
// Working → ignore duplicate in (shouldn't happen)
case StateWorking:
// already working, ignore
} }
case e.EventType == "out": case "out":
switch state { switch state {
case StateWorking: case StateWorking:
// End of work period
workTotal += e.OccurredAt - workStart workTotal += e.OccurredAt - workStart
workStart = 0 workStart = 0
pairs++ pairs++
state = StateIdle breakStart = e.OccurredAt
state = StateOnBreak
case StateIdle: case StateIdle:
// Second consecutive out → break start
breakStart = e.OccurredAt breakStart = e.OccurredAt
state = StateOnBreak state = StateOnBreak
case StateOnBreak: case StateOnBreak:
// Duplicate break start ignore breakStart = e.OccurredAt
} }
} }
} }

View File

@@ -1,24 +0,0 @@
package config
import (
"encoding/json"
"os"
)
type Config struct {
BotToken string `json:"bot_token"`
ProxyAddr string `json:"proxy_address"`
LogLevel string `json:"log_level"`
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}

View File

@@ -1,16 +0,0 @@
{
"unknown_command": "Unknown command",
"clock_in": "Clock In",
"clock_out": "Clock Out",
"error": "Error",
"no_permission": "You do not have permission",
"remote_switched_on": "Remote mode enabled",
"remote_switched_off": "Remote mode disabled",
"already_clocked_in": "You are already clocked in",
"already_clocked_out": "You are already clocked out",
"break_started": "Break started",
"remote_enabled": "Remote mode enabled",
"remote_disabled": "Remote mode disabled",
"dayoff_set": "Today marked as day off",
"dayoff_removed": "Day off removed"
}

View File

@@ -1,16 +0,0 @@
{
"unknown_command": "دستور نامشخص",
"clock_in": "ورودی کاری",
"clock_out": "خروج کاری",
"error": "خطا",
"no_permission": "دسترسی ندارید",
"remote_switched_on": "وضعیت راه دور فعال شد",
"remote_switched_off": "وضعیت راه دور غیرفعال شد",
"already_clocked_in": "شما قبلاً ورود زده‌اید",
"already_clocked_out": "شما قبلاً خروج زده‌اید",
"break_started": "استراحت شروع شد",
"remote_enabled": "وضعیت راه دور فعال شد",
"remote_disabled": "وضعیت راه دور غیرفعال شد",
"dayoff_set": "امروز به عنوان تعطیل ثبت شد",
"dayoff_removed": "تعطیلی برداشته شد"
}

View File

@@ -1,67 +0,0 @@
package i18n
import (
"encoding/json"
"fmt"
"io/ioutil"
"path/filepath"
"sync"
)
// Translator handles translation loading and retrieval.
type Translator struct {
mu sync.RWMutex
messages map[string]map[string]string // lang -> key -> message
}
// NewTranslator loads JSON locale files en.json and fa.json from the i18n directory.
// defaultLang sets the default fallback language (e.g., "en").
func NewTranslator(defaultLang string) (*Translator, error) {
if defaultLang == "" {
defaultLang = "en"
}
t := &Translator{
messages: make(map[string]map[string]string),
}
// Load known locale files: en.json and fa.json located in the same package directory.
for _, lang := range []string{"en", "fa"} {
filePath := filepath.Join("i18n", lang+".json")
data, err := ioutil.ReadFile(filePath)
if err != nil {
return nil, err
}
var m map[string]string
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
t.messages[lang] = m
}
// Ensure default language exists.
if _, ok := t.messages[defaultLang]; !ok {
return nil, fmt.Errorf("default language %s not found", defaultLang)
}
return t, nil
}
// Get returns the translated string for a given key and language.
// Falls back to English if the key/language is missing.
func (t *Translator) Get(key, lang string) string {
if lang == "" {
lang = "en"
}
t.mu.RLock()
defer t.mu.RUnlock()
if msgs, ok := t.messages[lang]; ok {
if v, ok := msgs[key]; ok {
return v
}
}
// fallback to English messages
if msgs, ok := t.messages["en"]; ok {
if v, ok := msgs[key]; ok {
return v
}
}
// If not found, return the key itself.
return key
}