Compare commits
3 Commits
c803239193
...
0bb5db46d0
| Author | SHA1 | Date | |
|---|---|---|---|
|
0bb5db46d0
|
|||
|
7176f9b81d
|
|||
|
4146e35f35
|
7
.env.example
Normal file
7
.env.example
Normal 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
27
.gitea/workflows/ci.yml
Normal 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
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.build/
|
||||||
|
*.exe
|
||||||
|
db.sqlite3
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
20
Dockerfile
Normal file
20
Dockerfile
Normal 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
32
Makefile
Normal 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
73
cmd/bot/main.go
Normal 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 long‑polling
|
||||||
|
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
61
cmd/bot/webhook.go
Normal 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
26
db/schema.sql
Normal 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
15
docker-compose.yml
Normal 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
30
go.mod
Normal 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
83
go.sum
Normal 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
76
internal/bot/export.go
Normal 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 day‑offs
|
||||||
|
}
|
||||||
|
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
190
internal/bot/handlers.go
Normal 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 clock‑in 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 clock‑in.
|
||||||
|
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 clock‑out 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 human‑readable 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
80
internal/bot/totals.go
Normal 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.
|
||||||
|
// Multi‑out 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
24
internal/config/loader.go
Normal 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
152
internal/db/store.go
Normal 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 day‑off record.
|
||||||
|
func (s *Store) SetDayOff(date, reason string) error {
|
||||||
|
query := `
|
||||||
|
INSERT INTO days_off (date, reason) VALUES (?, ?)
|
||||||
|
ON CONFLICT(date) DO UPDATE SET reason=excluded.reason
|
||||||
|
`
|
||||||
|
_, err := s.db.Exec(query, date, reason)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveDayOff deletes a day‑off record.
|
||||||
|
func (s *Store) RemoveDayOff(date string) error {
|
||||||
|
_, err := s.db.Exec(`DELETE FROM days_off WHERE date = ?`, date)
|
||||||
|
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
146
opencode.json
Normal 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
16
pkg/i18n/en.json
Normal 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
16
pkg/i18n/fa.json
Normal 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
67
pkg/i18n/translator.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user