Compare commits

...

3 Commits

20 changed files with 1146 additions and 0 deletions

7
.env.example Normal file
View File

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

27
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,27 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22"
- name: Check formatting
run: test -z "$(gofmt -d .)" || exit 1
- name: Vet
run: go vet ./...
- name: Build
run: go build ./...
- name: Test
run: go test ./...

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.build/
*.exe
db.sqlite3
.env
.DS_Store

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /usr/local/bin/worktime-bot ./cmd/bot/
FROM alpine:3.18
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
VOLUME ["/data"]
CMD ["worktime-bot"]

32
Makefile Normal file
View File

@@ -0,0 +1,32 @@
.PHONY: build run test clean docker docker-run lint vet
BINARY=worktime-bot
BUILD_DIR=.build
build: $(BUILD_DIR)/$(BINARY)
$(BUILD_DIR)/$(BINARY): cmd/bot/*.go internal/**/*.go pkg/**/*.go go.mod go.sum
mkdir -p $(BUILD_DIR)
go build -o $(BUILD_DIR)/$(BINARY) ./cmd/bot/
run: build
$(BUILD_DIR)/$(BINARY)
test:
go test ./...
lint: vet
vet:
go vet ./...
clean:
rm -rf $(BUILD_DIR)
docker:
docker compose build
docker-run: docker
docker compose up -d
.PHONY: all
all: build test lint

73
cmd/bot/main.go Normal file
View File

@@ -0,0 +1,73 @@
package main
import (
"log"
"net/http"
"net/url"
"os"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/joho/godotenv"
"worktimeBot/internal/bot"
"worktimeBot/internal/db"
"worktimeBot/pkg/i18n"
)
func main() {
// Load .env file if it exists (ignored when running inside Docker with env vars)
_ = godotenv.Load()
token := os.Getenv("BOT_TOKEN")
proxyURL := os.Getenv("HTTP_PROXY")
dbPath := getEnvDefault("DB_PATH", "db.sqlite3")
// Build HTTP client with optional proxy
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)
if err != nil {
log.Fatal(err)
}
dbStore, err := db.NewStore(dbPath)
if err != nil {
log.Fatal(err)
}
translator, _ := i18n.NewTranslator("en")
handler := bot.NewHandler(botAPI, dbStore, translator)
webhookURL := os.Getenv("BOT_WEBHOOK_URL")
if webhookURL != "" {
startWebhook(botAPI, handler, webhookURL)
} else {
// Fallback to longpolling
u := tgbotapi.NewUpdate(0)
u.Timeout = 30
updates := botAPI.GetUpdatesChan(u)
for update := range updates {
if update.Message != nil {
handler.HandleMessage(update)
}
}
}
}
func getEnvDefault(key, defaultValue string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultValue
}

61
cmd/bot/webhook.go Normal file
View File

@@ -0,0 +1,61 @@
package main
import (
"log"
"net/http"
"os"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"worktimeBot/internal/bot"
)
// startWebhook configures the Telegram webhook and starts the HTTP(S) listener.
func startWebhook(botAPI *tgbotapi.BotAPI, handler *bot.Handler, webhookURL string) {
// Delete any previously set webhook
if _, err := botAPI.Request(tgbotapi.DeleteWebhookConfig{}); err != nil {
log.Fatal("DeleteWebhook:", err)
}
// Set the new webhook
wh, err := tgbotapi.NewWebhook(webhookURL)
if err != nil {
log.Fatal("NewWebhook:", err)
}
if _, err := botAPI.Request(wh); err != nil {
log.Fatal("SetWebhook:", err)
}
// Register the webhook endpoint and get the updates channel
updates := botAPI.ListenForWebhook("/webhook")
// Determine where to listen
listenAddr := getEnvDefault("BOT_LISTEN", ":8080")
tlsCert := os.Getenv("BOT_TLS_CERT")
tlsKey := os.Getenv("BOT_TLS_KEY")
if tlsCert != "" && tlsKey != "" {
log.Printf("Starting HTTPS webhook on %s", listenAddr)
go func() {
if err := http.ListenAndServeTLS(listenAddr, tlsCert, tlsKey, nil); err != nil {
log.Fatal("HTTPS server:", err)
}
}()
} else {
log.Printf("Starting HTTP webhook on %s (TLS handled by reverse proxy)", listenAddr)
go func() {
if err := http.ListenAndServe(listenAddr, nil); err != nil {
log.Fatal("HTTP server:", err)
}
}()
}
log.Printf("Webhook registered at %s", webhookURL)
// Process incoming updates
for update := range updates {
if update.Message != nil {
handler.HandleMessage(update)
}
}
}

26
db/schema.sql Normal file
View File

@@ -0,0 +1,26 @@
CREATE TABLE 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())
);
CREATE INDEX idx_events_occurred_at
ON events(occurred_at);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);
CREATE TABLE days_off (
date TEXT PRIMARY KEY
CHECK (date = strftime('%Y-%m-%d', date)),
reason TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
DEFAULT (unixepoch())
);

15
docker-compose.yml Normal file
View File

@@ -0,0 +1,15 @@
services:
worktime-bot:
build: .
env_file:
- .env
environment:
- BOT_TOKEN=${BOT_TOKEN}
- HTTP_PROXY=${HTTP_PROXY-}
- DB_PATH=/data/db.sqlite3
volumes:
- worktime_data:/data
volumes:
worktime_data:
driver: local

30
go.mod Normal file
View File

@@ -0,0 +1,30 @@
module worktimeBot
go 1.26.4
require (
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/joho/godotenv v1.5.1
github.com/xuri/excelize/v2 v2.10.1
modernc.org/sqlite v1.53.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/richardlehane/mscfb v1.0.6 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.34.0 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

83
go.sum Normal file
View File

@@ -0,0 +1,83 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

76
internal/bot/export.go Normal file
View File

@@ -0,0 +1,76 @@
package bot
import (
"fmt"
"time"
"github.com/xuri/excelize/v2"
"worktimeBot/internal/db"
)
// GenerateMonthlyReport creates an Excel spreadsheet for the given month.
func GenerateMonthlyReport(store *db.Store, year int, month time.Month) ([]byte, error) {
f := excelize.NewFile()
defer func() { _ = f.Close() }()
sheet := "Report"
// Create a new sheet
index, err := f.NewSheet(sheet)
if err != nil {
return nil, err
}
f.SetActiveSheet(index)
// Header row
headers := []string{"Date", "Work Duration (h)", "Pairs"}
for i, h := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
if err := f.SetCellValue(sheet, cell, h); err != nil {
return nil, err
}
}
row := 2
loc := time.Now().Location()
firstDay := time.Date(year, month, 1, 0, 0, 0, 0, loc)
lastDay := firstDay.AddDate(0, 1, -1)
for d := firstDay; d.Before(lastDay.AddDate(0, 0, 1)); d = d.AddDate(0, 0, 1) {
dateStr := d.Format("2006-01-02")
isOff, err := store.IsDayOff(dateStr)
if err != nil {
return nil, err
}
if isOff {
continue // skip dayoffs
}
events, err := store.EventsForDay(dateStr)
if err != nil {
return nil, err
}
totals := ComputeDailyTotals(events)
hours := float64(totals.TotalSeconds) / 3600.0
cellDate, _ := excelize.CoordinatesToCellName(1, row)
cellHours, _ := excelize.CoordinatesToCellName(2, row)
cellPairs, _ := excelize.CoordinatesToCellName(3, row)
if err := f.SetCellValue(sheet, cellDate, dateStr); err != nil {
return nil, err
}
if err := f.SetCellValue(sheet, cellHours, fmt.Sprintf("%.2f", hours)); err != nil {
return nil, err
}
if err := f.SetCellValue(sheet, cellPairs, totals.PairCount); err != nil {
return nil, err
}
row++
}
buf, err := f.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}

190
internal/bot/handlers.go Normal file
View File

@@ -0,0 +1,190 @@
package bot
import (
"database/sql"
"fmt"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"worktimeBot/internal/db"
"worktimeBot/pkg/i18n"
)
type Handler struct {
Bot *tgbotapi.BotAPI
DB *db.Store
Translator *i18n.Translator
}
func NewHandler(bot *tgbotapi.BotAPI, store *db.Store, tr *i18n.Translator) *Handler {
return &Handler{Bot: bot, DB: store, Translator: tr}
}
// HandleMessage routes incoming messages.
func (h *Handler) HandleMessage(update tgbotapi.Update) {
msg := update.Message
switch msg.Text {
case "/clockin":
if err := h.handleClockIn(); err != nil {
h.replyWithError(msg.Chat.ID, err.Error())
} else {
h.replyWithText(msg.Chat.ID, "clock_in")
}
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":
h.toggleRemote(msg)
case "/report":
h.handleReport(msg)
case "/export":
h.handleExport(msg)
case "/dayoff":
h.handleDayOff(msg)
default:
h.replyWithText(msg.Chat.ID, "unknown_command")
}
}
// handleClockIn validates and records a clockin event.
func (h *Handler) handleClockIn() error {
last, err := h.DB.GetLastEvent()
if err != nil && err != sql.ErrNoRows {
return err
}
if last != nil && last.EventType == "in" {
return fmt.Errorf(h.Translator.Get("already_clocked_in", "en"))
}
// Set default remote flag after first clockin.
if _, err := h.DB.GetSetting("remote_flag"); err != nil {
_ = h.DB.SetSetting("remote_flag", "onsite")
}
return h.DB.CreateEvent("in", time.Now().Unix(), "")
}
// handleClockOut validates and records a clockout event.
func (h *Handler) handleClockOut() error {
last, err := h.DB.GetLastEvent()
if err != nil {
return err
}
if last == nil {
return fmt.Errorf(h.Translator.Get("error", "en"))
}
switch last.EventType {
case "in":
return h.DB.CreateEvent("out", time.Now().Unix(), "")
case "out":
return fmt.Errorf(h.Translator.Get("already_clocked_out", "en"))
default:
return fmt.Errorf(h.Translator.Get("error", "en"))
}
}
// toggleRemote switches the remote/onsite flag.
func (h *Handler) toggleRemote(msg *tgbotapi.Message) {
current, err := h.DB.GetSetting("remote_flag")
if err != nil {
current = "onsite"
}
newMode := "remote"
if current == "remote" {
newMode = "onsite"
}
if err := h.DB.SetSetting("remote_flag", newMode); err != nil {
h.replyWithError(msg.Chat.ID, "error")
return
}
key := "remote_switched_on"
if newMode == "onsite" {
key = "remote_switched_off"
}
h.replyWithText(msg.Chat.ID, key)
}
// handleReport sends daily work time summary.
func (h *Handler) handleReport(msg *tgbotapi.Message) {
today := time.Now().Format("2006-01-02")
events, err := h.DB.EventsForDay(today)
if err != nil {
h.replyWithError(msg.Chat.ID, "error")
return
}
totals := ComputeDailyTotals(events)
if totals.PairCount == 0 {
h.replyWithText(msg.Chat.ID, "total_work_time")
return
}
workStr := formatDuration(totals.TotalSeconds)
breakStr := formatDuration(totals.BreakSeconds)
text := fmt.Sprintf("Work: %s | Break: %s", workStr, breakStr)
reply := tgbotapi.NewMessage(msg.Chat.ID, text)
_, _ = h.Bot.Send(reply)
}
// formatDuration converts seconds to a humanreadable string.
func formatDuration(seconds int64) string {
hours := seconds / 3600
mins := (seconds % 3600) / 60
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, mins)
}
return fmt.Sprintf("%dm", mins)
}
// handleExport generates and sends a monthly Excel report.
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)
_, _ = h.Bot.Send(reply)
}
func (h *Handler) replyWithError(chatID int64, key string) {
text := h.Translator.Get(key, "en")
reply := tgbotapi.NewMessage(chatID, text)
_, _ = h.Bot.Send(reply)
}

80
internal/bot/totals.go Normal file
View File

@@ -0,0 +1,80 @@
package bot
import (
"sort"
"worktimeBot/internal/db"
)
type State int
const (
StateIdle State = iota
StateWorking
StateOnBreak
)
// DailyTotals holds aggregated times for a single day.
type DailyTotals struct {
TotalSeconds int64
BreakSeconds int64
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 {
if len(events) == 0 {
return DailyTotals{}
}
sorted := make([]db.Event, len(events))
copy(sorted, events)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].OccurredAt < sorted[j].OccurredAt
})
var workTotal, breakTotal int64
var workStart, breakStart int64
pairs := 0
state := StateIdle
for _, e := range sorted {
switch {
case e.EventType == "in":
switch state {
case StateIdle, StateOnBreak:
if state == StateOnBreak {
breakTotal += e.OccurredAt - breakStart
breakStart = 0
}
workStart = e.OccurredAt
state = StateWorking
// Working → ignore duplicate in (shouldn't happen)
case StateWorking:
// already working, ignore
}
case e.EventType == "out":
switch state {
case StateWorking:
// End of work period
workTotal += e.OccurredAt - workStart
workStart = 0
pairs++
state = StateIdle
case StateIdle:
// Second consecutive out → break start
breakStart = e.OccurredAt
state = StateOnBreak
case StateOnBreak:
// Duplicate break start ignore
}
}
}
return DailyTotals{
TotalSeconds: workTotal,
BreakSeconds: breakTotal,
PairCount: pairs,
}
}

24
internal/config/loader.go Normal file
View File

@@ -0,0 +1,24 @@
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
}

152
internal/db/store.go Normal file
View File

@@ -0,0 +1,152 @@
package db
import (
"database/sql"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
const driverName = "sqlite"
var schemaPath = filepath.Join("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 {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
if err := execSchema(db, schemaPath); err != nil {
return nil, err
}
return &Store{db: db}, nil
}
func execSchema(db *sql.DB, path string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
_, err = db.Exec(string(data))
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)
return err
}
// GetLastEvent returns the most recent event.
func (s *Store) GetLastEvent() (*Event, error) {
row, err := s.db.Query(`
SELECT id, event_type, occurred_at, note, created_at
FROM events
ORDER BY occurred_at DESC
LIMIT 1
`)
if err != nil {
return nil, err
}
defer row.Close()
if !row.Next() {
return nil, sql.ErrNoRows
}
var e Event
if err := row.Scan(&e.ID, &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) {
var value string
err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", 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)
return err
}
// SetDayOff adds or updates a dayoff 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)
return err
}
// RemoveDayOff deletes a dayoff record.
func (s *Store) RemoveDayOff(date string) error {
_, err := s.db.Exec(`DELETE FROM days_off WHERE date = ?`, 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) {
var exists int
err := s.db.QueryRow(`SELECT COUNT(1) FROM days_off WHERE date = ?`, 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) {
rows, err := s.db.Query(`
SELECT id, event_type, occurred_at, note, created_at
FROM events
WHERE strftime('%Y-%m-%d', occurred_at, 'unixepoch') = ?
ORDER BY occurred_at ASC
`, day)
if err != nil {
return nil, err
}
defer rows.Close()
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 {
return nil, err
}
events = append(events, e)
}
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"`
}

146
opencode.json Normal file
View File

@@ -0,0 +1,146 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"llama.cpp": {
"npm": "@ai-sdk/openai-compatible",
"name": "llama-server (local)",
"options": {
"baseURL": "http://127.0.0.1:8082/v1"
},
"models": {
"coder": {
"name": "coder",
"limit": {
"context": 32000,
"output": 65536
}
}
}
}
},
"lsp": true,
"formatter": {
"gofmt": {
"command": ["gofmt", "-w", "$FILE"],
"extensions": [".go"]
},
"prettier": {
"command": ["prettier", "--write", "$FILE"],
"extensions": [
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".vue",
".html",
".htm",
".css",
".scss",
".less",
".sass",
".json",
".jsonc",
".yaml",
".yml",
".md",
".mdx"
]
},
"php-cs-fixer": {
"command": ["php-cs-fixer", "fix", "$FILE"],
"extensions": [".php"]
}
},
"mcp": {
"playwright": {
"type": "local",
"command": ["npx", "-y", "@playwright/mcp@latest"],
"enabled": false
},
"postgres": {
"type": "local",
"command": ["uvx", "postgres-mcp"],
"enabled": false,
"environment": {
"DATABASE_URI": "postgresql://user:pass@localhost:5432/mydb"
}
},
"mariadb": {
"type": "local",
"command": ["npx", "-y", "@oleander/mcp-server-mariadb"],
"enabled": false,
"environment": {
"MARIADB_HOST": "127.0.0.1",
"MARIADB_PORT": "3306",
"MARIADB_USER": "root",
"MARIADB_PASS": "",
"MARIADB_DB": "mydb"
}
},
"redis": {
"type": "local",
"command": ["uvx", "redis-mcp-server"],
"enabled": false,
"environment": {
"REDIS_URL": "redis://localhost:6379"
}
},
"sqlite": {
"type": "local",
"command": ["npx", "-y", "mcp-sqlite", "./data/dev.db"],
"enabled": false
},
"docker": {
"type": "local",
"command": ["uvx", "mcp-server-docker"],
"enabled": false
},
"gitea": {
"type": "local",
"command": ["uvx", "gitea-mcp"],
"enabled": false,
"environment": {
"GITEA_URL": "https://git.db1232.ir",
"GITEA_TOKEN": "your-personal-access-token"
}
},
"matrix": {
"type": "local",
"command": ["npx", "-y", "@iflow-mcp/mjknowles-matrix-mcp-server"],
"enabled": false,
"environment": {
"MATRIX_HOMESERVER_URL": "https://tempchat.db123.ir",
"MATRIX_ACCESS_TOKEN": "your-access-token"
}
},
"fallow": {
"type": "local",
"command": ["npx", "-y", "--package", "fallow", "fallow-mcp"],
"enabled": false
}
},
"permission": {
"read": "allow",
"glob": "allow",
"grep": "allow",
"list": "allow",
"lsp": "allow",
"webfetch": "allow",
"websearch": "allow",
"question": "allow",
"todowrite": "allow",
"edit": "ask",
"write": "ask",
"task": "ask",
"external_directory": "ask",
"skill": "ask",
"doom_loop": "deny",
"bash": {
"*": "ask",
"git push*": "deny",
"rm -rf*": "deny"
}
}
}

16
pkg/i18n/en.json Normal file
View File

@@ -0,0 +1,16 @@
{
"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"
}

16
pkg/i18n/fa.json Normal file
View File

@@ -0,0 +1,16 @@
{
"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": "تعطیلی برداشته شد"
}

67
pkg/i18n/translator.go Normal file
View File

@@ -0,0 +1,67 @@
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
}