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