Compare commits
7 Commits
0926323d18
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
bddb920c82
|
|||
|
2c1c149b13
|
|||
|
6f7f5878bf
|
|||
|
7dbd9fda12
|
|||
|
d9e13cb51d
|
|||
|
59c35305c7
|
|||
|
5670b0455d
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@
|
||||
db.sqlite3
|
||||
.env
|
||||
.DS_Store
|
||||
opencode.json
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.26.4-alpine AS builder
|
||||
FROM golang:1.26.4-alpine3.23 AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
@@ -12,7 +12,7 @@ RUN go mod download
|
||||
COPY . .
|
||||
RUN go build -o /usr/local/bin/worktime-bot ./cmd/bot/
|
||||
|
||||
FROM alpine:3.18
|
||||
FROM alpine:3.23
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# WorkTime Bot
|
||||
|
||||
[](https://git.db123.ir/db123/worktimeBot/actions/workflows/ci.yml)
|
||||
|
||||
A Telegram time-tracking bot with **RPG progression system**, **WorkTime League**, **salary estimation**, **burnout assessment**, and **multi-calendar support** (Gregorian, Jalali/Persian, Hijri). Clock in/out, track work types, mark day off, get daily reports, browse and edit history by month, and export monthly `.xlsx` reports.
|
||||
|
||||
## Quick Start
|
||||
@@ -106,6 +108,7 @@ Categorize your work sessions (e.g., Remote, Onsite, Meeting). The current work
|
||||
| `/edit YYYY-MM-DD` | View/edit events for a specific date |
|
||||
| `/note [DATE] HH:MM <text>` | Set note for an event at a specific time |
|
||||
| `/settimezone <IANA>` | Set timezone (e.g. `Asia/Tehran`) |
|
||||
| `/setcalendar <type>` | Set calendar type (`gregorian`, `jalali`, `hijri`) |
|
||||
| `/setbreak <minutes>` | Set minimum break threshold (0-480, default 5) |
|
||||
| `/setaccent` | Select export accent color |
|
||||
| `/setreporttime HH:MM` | Set daily auto-report time |
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
tgbot "github.com/go-telegram/bot"
|
||||
|
||||
@@ -26,10 +27,25 @@ func startWebhook(ctx context.Context, b *tgbot.Bot, h *handler.Handler, webhook
|
||||
tlsCert := os.Getenv("BOT_TLS_CERT")
|
||||
tlsKey := os.Getenv("BOT_TLS_KEY")
|
||||
|
||||
server := &http.Server{
|
||||
Addr: listenAddr,
|
||||
Handler: b.WebhookHandler(),
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
slog.Info("shutting down webhook server")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
slog.Error("webhook server shutdown", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if tlsCert != "" && tlsKey != "" {
|
||||
slog.Info("starting HTTPS webhook", "addr", listenAddr)
|
||||
go func() {
|
||||
if err := http.ListenAndServeTLS(listenAddr, tlsCert, tlsKey, b.WebhookHandler()); err != nil {
|
||||
if err := server.ListenAndServeTLS(tlsCert, tlsKey); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("HTTPS server", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -37,7 +53,7 @@ func startWebhook(ctx context.Context, b *tgbot.Bot, h *handler.Handler, webhook
|
||||
} else {
|
||||
slog.Info("starting HTTP webhook (TLS by reverse proxy)", "addr", listenAddr)
|
||||
go func() {
|
||||
if err := http.ListenAndServe(listenAddr, b.WebhookHandler()); err != nil {
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("HTTP server", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -3,16 +3,16 @@ services:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
HTTP_PROXY: http://192.168.100.2:10808
|
||||
HTTPS_PROXY: http://192.168.100.2:10808
|
||||
HTTP_PROXY: ${HTTP_PROXY}
|
||||
HTTPS_PROXY: ${HTTPS_PROXY}
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- BOT_TOKEN=${BOT_TOKEN}
|
||||
- HTTP_PROXY=${HTTP_PROXY-}
|
||||
- HTTPS_PROXY=${HTTPS_PROXY-}
|
||||
- DB_PATH=/data/db.sqlite3
|
||||
- TZ=Asia/Tehran
|
||||
- HTTP_PROXY=${HTTP_PROXY}
|
||||
- HTTPS_PROXY=${HTTPS_PROXY}
|
||||
- DB_PATH=${DB_PATH}
|
||||
- TZ=${TZ}
|
||||
volumes:
|
||||
- worktime_data:/data
|
||||
|
||||
|
||||
@@ -157,6 +157,70 @@ func TestSanitizeNote(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBurnoutLevelString(t *testing.T) {
|
||||
cases := []struct {
|
||||
level BurnoutLevel
|
||||
want string
|
||||
}{
|
||||
{BurnoutHealthy, "Healthy"},
|
||||
{BurnoutMild, "Mild concern"},
|
||||
{BurnoutModerate, "Moderate concern"},
|
||||
{BurnoutHigh, "High concern"},
|
||||
{BurnoutLevel(99), "Unknown"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := c.level.String()
|
||||
if got != c.want {
|
||||
t.Errorf("BurnoutLevel(%d).String() = %q, want %q", c.level, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavMonth(t *testing.T) {
|
||||
tests := []struct {
|
||||
y, m int
|
||||
prevY, prevM int
|
||||
nextY, nextM int
|
||||
}{
|
||||
{2026, 6, 2026, 5, 2026, 7},
|
||||
{2026, 1, 2025, 12, 2026, 2},
|
||||
{2026, 12, 2026, 11, 2027, 1},
|
||||
{2024, 2, 2024, 1, 2024, 3},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
prevY, prevM, nextY, nextM := NavMonth(tc.y, tc.m)
|
||||
if prevY != tc.prevY || prevM != tc.prevM {
|
||||
t.Errorf("NavMonth(%d, %d) prev = (%d, %d), want (%d, %d)", tc.y, tc.m, prevY, prevM, tc.prevY, tc.prevM)
|
||||
}
|
||||
if nextY != tc.nextY || nextM != tc.nextM {
|
||||
t.Errorf("NavMonth(%d, %d) next = (%d, %d), want (%d, %d)", tc.y, tc.m, nextY, nextM, tc.nextY, tc.nextM)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSalary(t *testing.T) {
|
||||
tests := []struct {
|
||||
est SalaryEstimate
|
||||
currency string
|
||||
want string
|
||||
wantShort string
|
||||
}{
|
||||
{SalaryEstimate{GrossEarning: 1000.50}, "$ USD", "$1000.50 USD", "$1000.50"},
|
||||
{SalaryEstimate{GrossEarning: 0}, "$ USD", "$0.00 USD", "$0.00"},
|
||||
{SalaryEstimate{GrossEarning: 2500}, "T Toman", "T2500.00 Toman", "T2500.00"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := tc.est.FormatSalary(tc.currency)
|
||||
if got != tc.want {
|
||||
t.Errorf("FormatSalary(%q) = %q, want %q", tc.currency, got, tc.want)
|
||||
}
|
||||
gotShort := tc.est.FormatSalaryShort(tc.currency)
|
||||
if gotShort != tc.wantShort {
|
||||
t.Errorf("FormatSalaryShort(%q) = %q, want %q", tc.currency, gotShort, tc.wantShort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeDisplayName(t *testing.T) {
|
||||
if got := SanitizeDisplayName(" Alice "); got != "Alice" {
|
||||
t.Errorf("SanitizeDisplayName trim: got %q", got)
|
||||
|
||||
@@ -18,7 +18,7 @@ func ComputeDailyTotals(events []Event, minBreakThreshold int64, dayStart, dayEn
|
||||
state := StateIdle
|
||||
var workStart, breakStart int64
|
||||
|
||||
if dayStart > 0 {
|
||||
if dayStart > 0 && sorted[0].EventType == "out" {
|
||||
state = StateWorking
|
||||
workStart = dayStart
|
||||
} else if sorted[0].EventType == "out" {
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestComputeDailyTotals_SingleInOut(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDailyTotals_MissingFirstIn(t *testing.T) {
|
||||
func TestComputeDailyTotals_DayStartWithOutFirst(t *testing.T) {
|
||||
events := []Event{
|
||||
mkEvent("out", 200),
|
||||
mkEvent("in", 300),
|
||||
@@ -44,7 +44,39 @@ func TestComputeDailyTotals_MissingFirstIn(t *testing.T) {
|
||||
}
|
||||
got := ComputeDailyTotals(events, 0, 100, 500)
|
||||
if got.TotalSeconds != 200 {
|
||||
t.Errorf("missing first in: TotalSeconds=%d, want 200", got.TotalSeconds)
|
||||
t.Errorf("dayStart with out first: TotalSeconds=%d, want 200", got.TotalSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDailyTotals_DayStartWithInFirst(t *testing.T) {
|
||||
events := []Event{
|
||||
mkEvent("in", 100),
|
||||
mkEvent("out", 200),
|
||||
mkEvent("in", 300),
|
||||
mkEvent("out", 400),
|
||||
}
|
||||
got := ComputeDailyTotals(events, 0, 50, 500)
|
||||
if got.TotalSeconds != 200 {
|
||||
t.Errorf("dayStart with in first: TotalSeconds=%d, want 200", got.TotalSeconds)
|
||||
}
|
||||
if got.BreakSeconds != 200 {
|
||||
t.Errorf("dayStart with in first: BreakSeconds=%d, want 200", got.BreakSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDailyTotals_DayStartInFirstRealistic(t *testing.T) {
|
||||
events := []Event{
|
||||
mkEvent("in", 33060),
|
||||
mkEvent("out", 47100),
|
||||
mkEvent("in", 48780),
|
||||
mkEvent("out", 74460),
|
||||
}
|
||||
got := ComputeDailyTotals(events, 0, 0, 86399)
|
||||
if got.TotalSeconds != 39720 {
|
||||
t.Errorf("realistic day: TotalSeconds=%d, want 39720 (11h 02m)", got.TotalSeconds)
|
||||
}
|
||||
if got.BreakSeconds != 13619 {
|
||||
t.Errorf("realistic day: BreakSeconds=%d, want 13619 (3h 19m)", got.BreakSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
PendingRate PendingKind = "rate"
|
||||
PendingCurrency PendingKind = "currency"
|
||||
PendingTimezone PendingKind = "timezone"
|
||||
PendingCalendar PendingKind = "calendar"
|
||||
PendingBreak PendingKind = "break_threshold"
|
||||
PendingUsername PendingKind = "username"
|
||||
PendingReportTime PendingKind = "report_time"
|
||||
@@ -176,6 +177,8 @@ func (h *Handler) routeCommand(ctx context.Context, msg *models.Message, user *d
|
||||
h.handleSetAccent(ctx, msg, user)
|
||||
case "/settimezone":
|
||||
h.handleSetTimezone(ctx, msg, user)
|
||||
case "/setcalendar":
|
||||
h.handleSetCalendar(ctx, msg, user)
|
||||
case "/note":
|
||||
h.handleNote(ctx, msg, user)
|
||||
case "/summary":
|
||||
@@ -323,6 +326,10 @@ func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID
|
||||
h.reportTimeCallback(ctx, chatID, msgID, cbID, user)
|
||||
case data == "breakthreshold":
|
||||
h.breakThresholdCallback(ctx, chatID, msgID, cbID, user)
|
||||
case data == "deleteaccount":
|
||||
h.deleteAccountPrompt(ctx, chatID, msgID, cbID, user)
|
||||
case strings.HasPrefix(data, "delaccount_"):
|
||||
h.deleteAccountConfirm(ctx, chatID, msgID, cbID, user)
|
||||
case strings.HasPrefix(data, "league_"):
|
||||
sortBy := data[7:]
|
||||
h.leagueCallback(ctx, chatID, msgID, cbID, user, sortBy)
|
||||
@@ -354,6 +361,8 @@ func (h *Handler) processPendingInput(ctx context.Context, msg *models.Message,
|
||||
h.processCurrencyInput(ctx, msg, user, pi, text)
|
||||
case PendingTimezone:
|
||||
h.processTimezoneInput(ctx, msg, user, pi, text)
|
||||
case PendingCalendar:
|
||||
h.processCalendarInput(ctx, msg, user, pi, text)
|
||||
case PendingBreak:
|
||||
h.processBreakInput(ctx, msg, user, pi, text)
|
||||
case PendingUsername:
|
||||
|
||||
70
internal/handler/handler_test.go
Normal file
70
internal/handler/handler_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
seconds int64
|
||||
want string
|
||||
}{
|
||||
{0, "0m"},
|
||||
{30, "0m"},
|
||||
{59, "0m"},
|
||||
{60, "1m"},
|
||||
{3599, "59m"},
|
||||
{3600, "1h 0m"},
|
||||
{3661, "1h 1m"},
|
||||
{7200, "2h 0m"},
|
||||
{7260, "2h 1m"},
|
||||
{86399, "23h 59m"},
|
||||
{86400, "24h 0m"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := formatDuration(tc.seconds)
|
||||
if got != tc.want {
|
||||
t.Errorf("formatDuration(%d) = %q, want %q", tc.seconds, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFmtHHMM(t *testing.T) {
|
||||
tests := []struct {
|
||||
seconds int64
|
||||
want string
|
||||
}{
|
||||
{0, "0:00"},
|
||||
{60, "0:01"},
|
||||
{3600, "1:00"},
|
||||
{3661, "1:01"},
|
||||
{86399, "23:59"},
|
||||
{86400, "24:00"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := fmtHHMM(tc.seconds)
|
||||
if got != tc.want {
|
||||
t.Errorf("fmtHHMM(%d) = %q, want %q", tc.seconds, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFmtDDHHMM(t *testing.T) {
|
||||
tests := []struct {
|
||||
seconds int64
|
||||
want string
|
||||
}{
|
||||
{0, "0:00:00"},
|
||||
{60, "0:00:01"},
|
||||
{3600, "0:01:00"},
|
||||
{86400, "1:00:00"},
|
||||
{90061, "1:01:01"},
|
||||
{172800, "2:00:00"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := fmtDDHHMM(tc.seconds)
|
||||
if got != tc.want {
|
||||
t.Errorf("fmtDDHHMM(%d) = %q, want %q", tc.seconds, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,12 +134,15 @@ func (h *Handler) buildAchievementsList(user *domain.User) string {
|
||||
}
|
||||
|
||||
func (h *Handler) awardXPAndCheckAchievements(user *domain.User, stats *domain.RPGStats, sessionWork int64, today string, isWeekend bool) string {
|
||||
text := ""
|
||||
h.updateStreak(stats, user.ID, today)
|
||||
xp, leveledUp, newLevel, _ := h.computeAndAwardXP(stats, user.ID, sessionWork, stats.CurrentStreak, today)
|
||||
xp, leveledUp, newLevel, err := h.computeAndAwardXP(stats, user.ID, sessionWork, stats.CurrentStreak, today)
|
||||
if err != nil {
|
||||
slog.Error("compute and award xp failed", "user_id", user.ID, "error", err)
|
||||
return ""
|
||||
}
|
||||
totalHours := float64(stats.TotalWorkSeconds) / domain.SecondsPerHour
|
||||
unlocked := h.checkAchievements(user.ID, stats, sessionWork, today, isWeekend, totalHours)
|
||||
text += fmt.Sprintf("\n\n+%d XP (Lv.%d)", xp, newLevel)
|
||||
text := fmt.Sprintf("\n\n+%d XP (Lv.%d)", xp, newLevel)
|
||||
if leveledUp {
|
||||
text += "\nLevel up!"
|
||||
}
|
||||
@@ -241,6 +244,9 @@ func (h *Handler) checkAchievements(userID int64, stats *domain.RPGStats, workSe
|
||||
h.checkHourAchievements(u, stats, totalHours)
|
||||
h.checkSessionAchievements(u, workSeconds, isWeekend)
|
||||
h.checkLevelAchievements(u, stats)
|
||||
h.checkConsistencyAchievements(u, userID, date)
|
||||
h.checkPerfectWeekAchievements(u, userID, date)
|
||||
h.checkWorkTypeAchievements(u, userID)
|
||||
return u.unlocked
|
||||
}
|
||||
|
||||
@@ -334,3 +340,63 @@ func (h *Handler) checkLevelAchievements(u *achievementUnlocker, stats *domain.R
|
||||
u.unlock("level_50")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) checkConsistencyAchievements(u *achievementUnlocker, userID int64, today string) {
|
||||
t, err := time.Parse(domain.DateLayout, today)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
sevenDaysAgo := t.AddDate(0, 0, -7).Format(domain.DateLayout)
|
||||
yesterday := t.AddDate(0, 0, -1).Format(domain.DateLayout)
|
||||
records, err := h.Repo.GetDailyXPInRange(userID, sevenDaysAgo, yesterday)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(records) < 7 {
|
||||
return
|
||||
}
|
||||
allHaveSixHours := true
|
||||
for _, r := range records {
|
||||
if r.WorkSeconds < 6*domain.SecondsPerHour {
|
||||
allHaveSixHours = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allHaveSixHours {
|
||||
u.unlock("consistency_master")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) checkPerfectWeekAchievements(u *achievementUnlocker, userID int64, today string) {
|
||||
t, err := time.Parse(domain.DateLayout, today)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
start := t.AddDate(0, 0, -6).Format(domain.DateLayout)
|
||||
days, err := h.Repo.GetUserDaysInRange(userID, start, today)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(days) < 7 {
|
||||
return
|
||||
}
|
||||
for _, d := range days {
|
||||
if d.IsDayOff {
|
||||
return
|
||||
}
|
||||
}
|
||||
u.unlock("perfect_week")
|
||||
}
|
||||
|
||||
func (h *Handler) checkWorkTypeAchievements(u *achievementUnlocker, userID int64) {
|
||||
rem, ons, err := h.Repo.SumWorkSecondsByWorkType(userID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if rem >= 100*domain.SecondsPerHour {
|
||||
u.unlock("remote_veteran")
|
||||
}
|
||||
if ons >= 100*domain.SecondsPerHour {
|
||||
u.unlock("onsite_master")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ func (h *Handler) buildSettingsKeyboard(user *domain.User, st *domain.UserSettin
|
||||
{Text: fmt.Sprintf("Rate: %.2f", st.HourlyRate), CallbackData: "setrate"},
|
||||
{Text: fmt.Sprintf("Currency: %s", st.Currency), CallbackData: "currency"},
|
||||
},
|
||||
{{Text: "Delete all data", CallbackData: "deleteaccount", Style: "danger"}},
|
||||
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
||||
}
|
||||
return "Settings:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
||||
@@ -143,7 +144,8 @@ func (h *Handler) leagueToggleCallback(ctx context.Context, chatID int64, msgID
|
||||
return
|
||||
}
|
||||
if !st.RPGEnabled {
|
||||
h.editText(ctx, chatID, msgID, "League requires the RPG system. Enable RPG first in Settings.", nil)
|
||||
kb := settingsBackKeyboard()
|
||||
h.editText(ctx, chatID, msgID, "League requires the RPG system. Enable RPG first in Settings.", &kb)
|
||||
return
|
||||
}
|
||||
text, kb := h.buildToggleKeyboard(user.ID, "league", "WorkTime League")
|
||||
@@ -443,6 +445,12 @@ func (h *Handler) handleSetRate(ctx context.Context, msg *models.Message, user *
|
||||
return
|
||||
}
|
||||
h.saveRate(ctx, msg.Chat.ID, user, rate)
|
||||
st, _ := h.Repo.GetOrCreateSettings(user.ID)
|
||||
sym := "$"
|
||||
if st != nil && st.Currency != "" {
|
||||
sym = domain.CurrencySymbol(st.Currency)
|
||||
}
|
||||
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Hourly rate set to %s%.2f", sym, rate))
|
||||
}
|
||||
|
||||
func (h *Handler) handleSetCurrency(ctx context.Context, msg *models.Message, user *domain.User) {
|
||||
@@ -458,6 +466,7 @@ func (h *Handler) handleSetCurrency(ctx context.Context, msg *models.Message, us
|
||||
return
|
||||
}
|
||||
h.saveCurrency(ctx, msg.Chat.ID, user, currency)
|
||||
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Currency set to: %s", currency))
|
||||
}
|
||||
|
||||
func (h *Handler) handleSetTimezone(ctx context.Context, msg *models.Message, user *domain.User) {
|
||||
@@ -472,6 +481,9 @@ func (h *Handler) handleSetTimezone(ctx context.Context, msg *models.Message, us
|
||||
return
|
||||
}
|
||||
h.saveTimezone(ctx, msg.Chat.ID, user, args)
|
||||
loc := h.loadLocation(args)
|
||||
now := time.Now().In(loc).Format(domain.TimeLayout)
|
||||
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Timezone set to %s (current time: %s)", args, now))
|
||||
}
|
||||
|
||||
func (h *Handler) handleSetBreak(ctx context.Context, msg *models.Message, user *domain.User) {
|
||||
@@ -487,6 +499,7 @@ func (h *Handler) handleSetBreak(ctx context.Context, msg *models.Message, user
|
||||
return
|
||||
}
|
||||
h.saveBreakThreshold(ctx, msg.Chat.ID, user, mins)
|
||||
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Break threshold set to %d minutes.", mins))
|
||||
}
|
||||
|
||||
func (h *Handler) handleSetUsername(ctx context.Context, msg *models.Message, user *domain.User) {
|
||||
@@ -497,6 +510,7 @@ func (h *Handler) handleSetUsername(ctx context.Context, msg *models.Message, us
|
||||
return
|
||||
}
|
||||
h.saveUsername(ctx, msg.Chat.ID, user, name)
|
||||
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Username set to: %s", name))
|
||||
}
|
||||
|
||||
func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message, user *domain.User) {
|
||||
@@ -513,7 +527,9 @@ func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message,
|
||||
}
|
||||
if err := h.validateAndSaveReportTime(ctx, msg.Chat.ID, user, args); err != nil {
|
||||
h.sendText(ctx, msg.Chat.ID, err.Error())
|
||||
return
|
||||
}
|
||||
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Report time set to %s", args))
|
||||
}
|
||||
|
||||
func (h *Handler) handleSetAccent(ctx context.Context, msg *models.Message, user *domain.User) {
|
||||
@@ -560,8 +576,13 @@ func (h *Handler) processRateInput(ctx context.Context, msg *models.Message, use
|
||||
}
|
||||
h.saveRate(ctx, msg.Chat.ID, user, rate)
|
||||
backKB := settingsBackKeyboard()
|
||||
st, _ := h.Repo.GetOrCreateSettings(user.ID)
|
||||
sym := "$"
|
||||
if st != nil && st.Currency != "" {
|
||||
sym = domain.CurrencySymbol(st.Currency)
|
||||
}
|
||||
h.editText(ctx, msg.Chat.ID, pi.MsgID,
|
||||
fmt.Sprintf("Hourly rate set to %s%.2f", domain.CurrencySymbol(""), rate), &backKB)
|
||||
fmt.Sprintf("Hourly rate set to %s%.2f", sym, rate), &backKB)
|
||||
}
|
||||
|
||||
func (h *Handler) processCurrencyInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
|
||||
@@ -599,6 +620,48 @@ func (h *Handler) processTimezoneInput(ctx context.Context, msg *models.Message,
|
||||
fmt.Sprintf("Timezone set to %s (current time: %s)", text, now.Format(domain.TimeLayout)), &backKB)
|
||||
}
|
||||
|
||||
func (h *Handler) handleSetCalendar(ctx context.Context, msg *models.Message, user *domain.User) {
|
||||
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setcalendar"))
|
||||
if args == "" {
|
||||
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingCalendar,
|
||||
fmt.Sprintf("Current calendar: %s\n\nValid options: gregorian, jalali, hijri", user.Calendar), nil)
|
||||
return
|
||||
}
|
||||
args = strings.ToLower(args)
|
||||
valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true}
|
||||
if !valid[args] {
|
||||
h.sendText(ctx, msg.Chat.ID, "Invalid calendar. Valid options: gregorian, jalali, hijri")
|
||||
return
|
||||
}
|
||||
h.saveCalendar(ctx, msg.Chat.ID, user, args)
|
||||
}
|
||||
|
||||
func (h *Handler) processCalendarInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
|
||||
text = strings.ToLower(strings.TrimSpace(text))
|
||||
valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true}
|
||||
if !valid[text] {
|
||||
h.editText(ctx, msg.Chat.ID, pi.MsgID,
|
||||
"Invalid calendar. Valid options: gregorian, jalali, hijri", &models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||
{{Text: "Try again", CallbackData: "caltype"}},
|
||||
{{Text: "Cancel", CallbackData: "back_settings"}},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
h.saveCalendar(ctx, msg.Chat.ID, user, text)
|
||||
backKB := settingsBackKeyboard()
|
||||
h.editText(ctx, msg.Chat.ID, pi.MsgID,
|
||||
fmt.Sprintf("Calendar set to %s", text), &backKB)
|
||||
}
|
||||
|
||||
func (h *Handler) saveCalendar(_ context.Context, _ int64, user *domain.User, cal string) {
|
||||
user.Calendar = cal
|
||||
if err := h.Repo.UpdateUser(user); err != nil {
|
||||
slog.Error("save calendar failed", "user_id", user.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) processBreakInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
|
||||
mins, err := strconv.Atoi(text)
|
||||
if err != nil || mins < 0 || mins > domain.MaxBreakThresholdMins {
|
||||
@@ -683,15 +746,20 @@ func (h *Handler) saveRate(ctx context.Context, chatID int64, user *domain.User,
|
||||
func (h *Handler) saveCurrency(_ context.Context, _ int64, user *domain.User, currency string) {
|
||||
st, err := h.Repo.GetOrCreateSettings(user.ID)
|
||||
if err != nil {
|
||||
slog.Error("save currency: get settings failed", "user_id", user.ID, "error", err)
|
||||
return
|
||||
}
|
||||
st.Currency = currency
|
||||
h.Repo.UpdateSettings(st)
|
||||
if err := h.Repo.UpdateSettings(st); err != nil {
|
||||
slog.Error("save currency: update settings failed", "user_id", user.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) saveTimezone(_ context.Context, _ int64, user *domain.User, tz string) {
|
||||
user.Timezone = tz
|
||||
h.Repo.UpdateUser(user)
|
||||
if err := h.Repo.UpdateUser(user); err != nil {
|
||||
slog.Error("save timezone failed", "user_id", user.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) saveBreakThreshold(_ context.Context, _ int64, user *domain.User, mins int) {
|
||||
@@ -699,10 +767,13 @@ func (h *Handler) saveBreakThreshold(_ context.Context, _ int64, user *domain.Us
|
||||
today := time.Now().In(loc).Format(domain.DateLayout)
|
||||
day, err := h.Repo.GetOrCreateDay(user.ID, today)
|
||||
if err != nil {
|
||||
slog.Error("save break threshold: get day failed", "user_id", user.ID, "error", err)
|
||||
return
|
||||
}
|
||||
day.MinBreakThreshold = int64(mins) * 60
|
||||
h.Repo.UpdateDay(day)
|
||||
if err := h.Repo.UpdateDay(day); err != nil {
|
||||
slog.Error("save break threshold: update day failed", "user_id", user.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) saveUsername(_ context.Context, _ int64, user *domain.User, name string) {
|
||||
@@ -711,7 +782,9 @@ func (h *Handler) saveUsername(_ context.Context, _ int64, user *domain.User, na
|
||||
return
|
||||
}
|
||||
user.Username = name
|
||||
h.Repo.UpdateUser(user)
|
||||
if err := h.Repo.UpdateUser(user); err != nil {
|
||||
slog.Error("save username failed", "user_id", user.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) validateAndSaveReportTime(_ context.Context, _ int64, user *domain.User, timeStr string) error {
|
||||
@@ -730,3 +803,26 @@ func (h *Handler) validateAndSaveReportTime(_ context.Context, _ int64, user *do
|
||||
st.ReportTime = timeStr
|
||||
return h.Repo.UpdateSettings(st)
|
||||
}
|
||||
|
||||
func (h *Handler) deleteAccountPrompt(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
|
||||
defer h.answerCb(ctx, cbID)
|
||||
kb := models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{
|
||||
{
|
||||
{Text: "Yes, delete everything", CallbackData: fmt.Sprintf("delaccount_%d", user.ID), Style: "danger"},
|
||||
{Text: "Cancel", CallbackData: "back_settings"},
|
||||
},
|
||||
},
|
||||
}
|
||||
h.editText(ctx, chatID, msgID, "This will permanently delete ALL your data (events, days, RPG progress, settings, achievements).\nAre you sure?", &kb)
|
||||
}
|
||||
|
||||
func (h *Handler) deleteAccountConfirm(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
|
||||
defer h.answerCb(ctx, cbID)
|
||||
if err := h.Repo.DeleteUserData(user.ID); err != nil {
|
||||
slog.Error("delete user data", "user_id", user.ID, "error", err)
|
||||
h.editText(ctx, chatID, msgID, "Error deleting data. Please try again.", nil)
|
||||
return
|
||||
}
|
||||
h.editText(ctx, chatID, msgID, "All your data has been deleted. You can start fresh by sending /start.", nil)
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ type Repository interface {
|
||||
UpsertDailyXP(userID int64, date string, xpEarned, workSeconds int64) error
|
||||
SetDailyWorkSeconds(userID int64, date string, workSeconds int64) error
|
||||
SumDailyWorkSeconds(userID int64) (int64, error)
|
||||
GetDailyXPInRange(userID int64, start, end string) ([]domain.DailyXP, error)
|
||||
SumWorkSecondsByWorkType(userID int64) (remoteSeconds, onsiteSeconds int64, _ error)
|
||||
|
||||
// Achievements
|
||||
GetAchievementByCode(code string) (*domain.Achievement, error)
|
||||
@@ -55,5 +57,6 @@ type Repository interface {
|
||||
GetLeagueRankings(orderBy string) ([]domain.LeagueEntry, error)
|
||||
|
||||
// Lifecycle
|
||||
DeleteUserData(userID int64) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
@@ -21,20 +21,55 @@ type Store struct {
|
||||
func NewStore(path string) (*Store, error) {
|
||||
db, err := sql.Open(driverName, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("open: %w", err)
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("ping: %w", err)
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
slog.Warn("failed to set WAL mode", "error", err)
|
||||
}
|
||||
if err := runMigrations(db); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUserData(userID int64) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete user data begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM events WHERE day_id IN (SELECT id FROM days WHERE user_id=?)`, userID); err != nil {
|
||||
return fmt.Errorf("delete user data events: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM days WHERE user_id=?`, userID); err != nil {
|
||||
return fmt.Errorf("delete user data days: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM user_achievements WHERE user_id=?`, userID); err != nil {
|
||||
return fmt.Errorf("delete user data achievements: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM daily_xp WHERE user_id=?`, userID); err != nil {
|
||||
return fmt.Errorf("delete user data daily_xp: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM user_rpg WHERE user_id=?`, userID); err != nil {
|
||||
return fmt.Errorf("delete user data rpg: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM user_settings WHERE user_id=?`, userID); err != nil {
|
||||
return fmt.Errorf("delete user data settings: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM users WHERE id=?`, userID); err != nil {
|
||||
return fmt.Errorf("delete user data users: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("delete user data commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
@@ -66,7 +101,7 @@ func (s *Store) GetOrCreateUser(chatID int64) (*domain.User, error) {
|
||||
"INSERT OR IGNORE INTO users (chat_id, username) VALUES (?, ?)",
|
||||
chatID, randomUsername(),
|
||||
); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("get or create user: %w", err)
|
||||
}
|
||||
return s.GetUserByChatID(chatID)
|
||||
}
|
||||
@@ -81,7 +116,7 @@ func (s *Store) GetUserByChatID(chatID int64) (*domain.User, error) {
|
||||
&u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive,
|
||||
&u.Calendar, &u.CreatedAt, &u.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("get user by chat id: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
@@ -91,7 +126,10 @@ func (s *Store) UpdateUser(user *domain.User) error {
|
||||
"UPDATE users SET username=?, timezone=?, is_active=?, calendar=?, updated_at=unixepoch() WHERE id=?",
|
||||
user.Username, user.Timezone, user.IsActive, user.Calendar, user.ID,
|
||||
)
|
||||
return err
|
||||
if err != nil {
|
||||
return fmt.Errorf("update user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetAllUsers() ([]domain.User, error) {
|
||||
@@ -233,6 +271,57 @@ func (s *Store) SumDailyWorkSeconds(userID int64) (int64, error) {
|
||||
return total, err
|
||||
}
|
||||
|
||||
func (s *Store) GetDailyXPInRange(userID int64, start, end string) ([]domain.DailyXP, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, user_id, date, xp_earned, work_seconds, created_at
|
||||
FROM daily_xp WHERE user_id=? AND date >= ? AND date <= ?
|
||||
ORDER BY date`, userID, start, end,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []domain.DailyXP
|
||||
for rows.Next() {
|
||||
var x domain.DailyXP
|
||||
if err := rows.Scan(&x.ID, &x.UserID, &x.Date, &x.XPEarned, &x.WorkSeconds, &x.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, x)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) SumWorkSecondsByWorkType(userID int64) (remoteSeconds, onsiteSeconds int64, _ error) {
|
||||
// Each day has a current_work_type_id. Daily XP work_seconds are per day.
|
||||
// Join with days to get the day's primary work type, then sum by type.
|
||||
rows, err := s.db.Query(`
|
||||
SELECT COALESCE(d.current_work_type_id, 1), COALESCE(SUM(dx.work_seconds), 0)
|
||||
FROM daily_xp dx
|
||||
JOIN days d ON d.user_id = dx.user_id AND d.date = dx.date
|
||||
WHERE dx.user_id = ? AND dx.work_seconds > 0
|
||||
GROUP BY d.current_work_type_id
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var wtID int64
|
||||
var secs int64
|
||||
if err := rows.Scan(&wtID, &secs); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
switch wtID {
|
||||
case 2:
|
||||
remoteSeconds += secs
|
||||
default:
|
||||
onsiteSeconds += secs
|
||||
}
|
||||
}
|
||||
return remoteSeconds, onsiteSeconds, rows.Err()
|
||||
}
|
||||
|
||||
// Achievements
|
||||
|
||||
func (s *Store) GetAchievementByCode(code string) (*domain.Achievement, error) {
|
||||
|
||||
593
internal/repo/store_test.go
Normal file
593
internal/repo/store_test.go
Normal file
@@ -0,0 +1,593 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goose.SetLogger(goose.NopLogger())
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := NewStore("file::memory:?cache=shared")
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore() failed: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestGetOrCreateUser(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
defer s.Close()
|
||||
|
||||
u, err := s.GetOrCreateUser(12345)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrCreateUser() failed: %v", err)
|
||||
}
|
||||
if u.ID == 0 {
|
||||
t.Fatal("GetOrCreateUser() returned zero ID")
|
||||
}
|
||||
if u.ChatID != 12345 {
|
||||
t.Errorf("ChatID = %d, want 12345", u.ChatID)
|
||||
}
|
||||
if u.Timezone == "" {
|
||||
t.Errorf("Timezone is empty")
|
||||
}
|
||||
if u.Calendar != "gregorian" {
|
||||
t.Errorf("Calendar = %q, want gregorian", u.Calendar)
|
||||
}
|
||||
|
||||
u2, err := s.GetOrCreateUser(12345)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrCreateUser() second call failed: %v", err)
|
||||
}
|
||||
if u2.ID != u.ID {
|
||||
t.Errorf("second call returned different ID: %d vs %d", u2.ID, u.ID)
|
||||
}
|
||||
|
||||
u3, err := s.GetOrCreateUser(67890)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrCreateUser() third call failed: %v", err)
|
||||
}
|
||||
if u3.ID == u.ID {
|
||||
t.Error("different chat_id should produce different user ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrCreateSettings(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
defer s.Close()
|
||||
|
||||
user, _ := s.GetOrCreateUser(12345)
|
||||
|
||||
st, err := s.GetOrCreateSettings(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrCreateSettings() failed: %v", err)
|
||||
}
|
||||
if !st.ReportEnabled {
|
||||
t.Error("ReportEnabled should default to true")
|
||||
}
|
||||
if st.ExportAccent != "ocean" {
|
||||
t.Errorf("ExportAccent = %q, want ocean", st.ExportAccent)
|
||||
}
|
||||
|
||||
st.ExportAccent = "beach"
|
||||
st.ReportEnabled = false
|
||||
if err := s.UpdateSettings(st); err != nil {
|
||||
t.Fatalf("UpdateSettings() failed: %v", err)
|
||||
}
|
||||
|
||||
st2, err := s.GetOrCreateSettings(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrCreateSettings() after update failed: %v", err)
|
||||
}
|
||||
if st2.ExportAccent != "beach" {
|
||||
t.Errorf("ExportAccent = %q, want beach", st2.ExportAccent)
|
||||
}
|
||||
if st2.ReportEnabled {
|
||||
t.Error("ReportEnabled should be false after update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrCreateDay(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
defer s.Close()
|
||||
|
||||
user, _ := s.GetOrCreateUser(12345)
|
||||
|
||||
day, err := s.GetOrCreateDay(user.ID, "2026-06-25")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrCreateDay() failed: %v", err)
|
||||
}
|
||||
if day.UserID != user.ID {
|
||||
t.Errorf("UserID = %d, want %d", day.UserID, user.ID)
|
||||
}
|
||||
if day.Date != "2026-06-25" {
|
||||
t.Errorf("Date = %q, want 2026-06-25", day.Date)
|
||||
}
|
||||
if day.IsDayOff {
|
||||
t.Error("IsDayOff should default to false")
|
||||
}
|
||||
if day.MinBreakThreshold != 300 {
|
||||
t.Errorf("MinBreakThreshold = %d, want 300", day.MinBreakThreshold)
|
||||
}
|
||||
|
||||
day2, err := s.GetOrCreateDay(user.ID, "2026-06-25")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrCreateDay() second call failed: %v", err)
|
||||
}
|
||||
if day2.ID != day.ID {
|
||||
t.Errorf("second call returned different day ID")
|
||||
}
|
||||
|
||||
got, err := s.GetDay(user.ID, "2026-06-25")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDay() failed: %v", err)
|
||||
}
|
||||
if got.ID != day.ID {
|
||||
t.Errorf("GetDay() returned different day ID")
|
||||
}
|
||||
|
||||
_, err = s.GetDay(user.ID, "2026-06-26")
|
||||
if err == nil {
|
||||
t.Error("GetDay() should fail for non-existent day")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDayOff(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
defer s.Close()
|
||||
|
||||
user, _ := s.GetOrCreateUser(12345)
|
||||
|
||||
if err := s.SetDayOff(user.ID, "2026-06-25", "sick"); err != nil {
|
||||
t.Fatalf("SetDayOff() failed: %v", err)
|
||||
}
|
||||
|
||||
day, err := s.GetDay(user.ID, "2026-06-25")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDay() after SetDayOff failed: %v", err)
|
||||
}
|
||||
if !day.IsDayOff {
|
||||
t.Error("IsDayOff should be true after SetDayOff")
|
||||
}
|
||||
if day.DayOffReason != "sick" {
|
||||
t.Errorf("DayOffReason = %q, want sick", day.DayOffReason)
|
||||
}
|
||||
|
||||
if err := s.RemoveDayOff(user.ID, "2026-06-25"); err != nil {
|
||||
t.Fatalf("RemoveDayOff() failed: %v", err)
|
||||
}
|
||||
|
||||
day, _ = s.GetDay(user.ID, "2026-06-25")
|
||||
if day.IsDayOff {
|
||||
t.Error("IsDayOff should be false after RemoveDayOff")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAndQueryEvents(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
defer s.Close()
|
||||
|
||||
user, _ := s.GetOrCreateUser(12345)
|
||||
day, _ := s.GetOrCreateDay(user.ID, "2026-06-25")
|
||||
|
||||
wt := int64(2)
|
||||
if err := s.CreateEvent(user.ID, day.ID, "in", &wt, 1000, "note1"); err != nil {
|
||||
t.Fatalf("CreateEvent() in failed: %v", err)
|
||||
}
|
||||
if err := s.CreateEvent(user.ID, day.ID, "out", nil, 2000, ""); err != nil {
|
||||
t.Fatalf("CreateEvent() out failed: %v", err)
|
||||
}
|
||||
|
||||
events, err := s.EventsForDayByDate(user.ID, "2026-06-25")
|
||||
if err != nil {
|
||||
t.Fatalf("EventsForDayByDate() failed: %v", err)
|
||||
}
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("got %d events, want 2", len(events))
|
||||
}
|
||||
|
||||
if events[0].EventType != "in" || events[0].OccurredAt != 1000 {
|
||||
t.Errorf("first event: type=%q, time=%d", events[0].EventType, events[0].OccurredAt)
|
||||
}
|
||||
if events[0].WorkTypeID == nil || *events[0].WorkTypeID != 2 {
|
||||
t.Error("first event WorkTypeID should be 2")
|
||||
}
|
||||
if events[0].Note != "note1" {
|
||||
t.Errorf("first event Note = %q, want note1", events[0].Note)
|
||||
}
|
||||
|
||||
if events[1].EventType != "out" || events[1].OccurredAt != 2000 {
|
||||
t.Errorf("second event: type=%q, time=%d", events[1].EventType, events[1].OccurredAt)
|
||||
}
|
||||
if events[1].WorkTypeID != nil {
|
||||
t.Error("out event WorkTypeID should be nil")
|
||||
}
|
||||
|
||||
last, err := s.GetLastEvent(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLastEvent() failed: %v", err)
|
||||
}
|
||||
if last.EventType != "out" || last.OccurredAt != 2000 {
|
||||
t.Errorf("last event: type=%q, time=%d", last.EventType, last.OccurredAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCRUDEvent(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
defer s.Close()
|
||||
|
||||
user, _ := s.GetOrCreateUser(12345)
|
||||
day, _ := s.GetOrCreateDay(user.ID, "2026-06-25")
|
||||
|
||||
wt := int64(1)
|
||||
s.CreateEvent(user.ID, day.ID, "in", &wt, 100, "")
|
||||
events, _ := s.EventsForDayByDate(user.ID, "2026-06-25")
|
||||
eid := events[0].ID
|
||||
|
||||
if err := s.UpdateEventTime(eid, 150); err != nil {
|
||||
t.Fatalf("UpdateEventTime() failed: %v", err)
|
||||
}
|
||||
if err := s.UpdateEventWorkType(eid, 2); err != nil {
|
||||
t.Fatalf("UpdateEventWorkType() failed: %v", err)
|
||||
}
|
||||
if err := s.SetEventNote(eid, "updated"); err != nil {
|
||||
t.Fatalf("SetEventNote() failed: %v", err)
|
||||
}
|
||||
|
||||
e, err := s.GetEventByID(eid, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetEventByID() failed: %v", err)
|
||||
}
|
||||
if e.OccurredAt != 150 {
|
||||
t.Errorf("OccurredAt = %d, want 150", e.OccurredAt)
|
||||
}
|
||||
if e.WorkTypeID == nil || *e.WorkTypeID != 2 {
|
||||
t.Error("WorkTypeID should be 2")
|
||||
}
|
||||
if e.Note != "updated" {
|
||||
t.Errorf("Note = %q, want updated", e.Note)
|
||||
}
|
||||
|
||||
if err := s.DeleteEvent(eid); err != nil {
|
||||
t.Fatalf("DeleteEvent() failed: %v", err)
|
||||
}
|
||||
events, _ = s.EventsForDayByDate(user.ID, "2026-06-25")
|
||||
if len(events) != 0 {
|
||||
t.Errorf("after delete: got %d events, want 0", len(events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPGStats(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
defer s.Close()
|
||||
|
||||
user, _ := s.GetOrCreateUser(12345)
|
||||
|
||||
stats, err := s.GetOrCreateRPGStats(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrCreateRPGStats() failed: %v", err)
|
||||
}
|
||||
if stats.Level != 1 {
|
||||
t.Errorf("Level = %d, want 1", stats.Level)
|
||||
}
|
||||
if stats.XP != 0 {
|
||||
t.Errorf("XP = %d, want 0", stats.XP)
|
||||
}
|
||||
|
||||
stats.XP = 5000
|
||||
stats.Level = 10
|
||||
stats.CurrentStreak = 5
|
||||
stats.LongestStreak = 10
|
||||
stats.TotalWorkSeconds = 36000
|
||||
if err := s.UpdateRPGStats(stats); err != nil {
|
||||
t.Fatalf("UpdateRPGStats() failed: %v", err)
|
||||
}
|
||||
|
||||
stats2, _ := s.GetOrCreateRPGStats(user.ID)
|
||||
if stats2.XP != 5000 || stats2.Level != 10 {
|
||||
t.Errorf("after update: XP=%d Level=%d, want 5000 10", stats2.XP, stats2.Level)
|
||||
}
|
||||
if stats2.CurrentStreak != 5 || stats2.LongestStreak != 10 {
|
||||
t.Errorf("after update: CurrentStreak=%d LongestStreak=%d, want 5 10", stats2.CurrentStreak, stats2.LongestStreak)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyXP(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
defer s.Close()
|
||||
|
||||
user, _ := s.GetOrCreateUser(12345)
|
||||
|
||||
if err := s.UpsertDailyXP(user.ID, "2026-06-25", 100, 3600); err != nil {
|
||||
t.Fatalf("UpsertDailyXP() failed: %v", err)
|
||||
}
|
||||
if err := s.UpsertDailyXP(user.ID, "2026-06-26", 200, 7200); err != nil {
|
||||
t.Fatalf("UpsertDailyXP() second call failed: %v", err)
|
||||
}
|
||||
if err := s.UpsertDailyXP(user.ID, "2026-06-25", 50, 1800); err != nil {
|
||||
t.Fatalf("UpsertDailyXP() third call failed: %v", err)
|
||||
}
|
||||
|
||||
total, err := s.SumDailyWorkSeconds(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("SumDailyWorkSeconds() failed: %v", err)
|
||||
}
|
||||
if total != 12600 {
|
||||
t.Errorf("SumDailyWorkSeconds = %d, want 12600 (3600+7200+1800)", total)
|
||||
}
|
||||
|
||||
records, err := s.GetDailyXPInRange(user.ID, "2026-06-25", "2026-06-26")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDailyXPInRange() failed: %v", err)
|
||||
}
|
||||
if len(records) != 2 {
|
||||
t.Fatalf("got %d records, want 2", len(records))
|
||||
}
|
||||
if records[0].Date != "2026-06-25" || records[0].WorkSeconds != 5400 {
|
||||
t.Errorf("record[0]: date=%q work=%d, want 2026-06-25 5400", records[0].Date, records[0].WorkSeconds)
|
||||
}
|
||||
if records[1].Date != "2026-06-26" || records[1].WorkSeconds != 7200 {
|
||||
t.Errorf("record[1]: date=%q work=%d, want 2026-06-26 7200", records[1].Date, records[1].WorkSeconds)
|
||||
}
|
||||
|
||||
s.SetDailyWorkSeconds(user.ID, "2026-06-25", 3000)
|
||||
records, _ = s.GetDailyXPInRange(user.ID, "2026-06-25", "2026-06-25")
|
||||
if len(records) != 1 || records[0].WorkSeconds != 3000 {
|
||||
t.Errorf("after SetDailyWorkSeconds: work=%d, want 3000", records[0].WorkSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAchievements(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
defer s.Close()
|
||||
|
||||
user, _ := s.GetOrCreateUser(12345)
|
||||
|
||||
a, err := s.GetAchievementByCode("first_clockin")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAchievementByCode() failed: %v", err)
|
||||
}
|
||||
if a.Code != "first_clockin" || a.Name == "" {
|
||||
t.Errorf("achievement: code=%q name=%q", a.Code, a.Name)
|
||||
}
|
||||
|
||||
has, err := s.HasAchievement(user.ID, "first_clockin")
|
||||
if err != nil {
|
||||
t.Fatalf("HasAchievement() failed: %v", err)
|
||||
}
|
||||
if has {
|
||||
t.Error("HasAchievement should be false before unlocking")
|
||||
}
|
||||
|
||||
ok, err := s.UnlockAchievement(user.ID, a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("UnlockAchievement() failed: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("UnlockAchievement should return true on first unlock")
|
||||
}
|
||||
|
||||
ok, err = s.UnlockAchievement(user.ID, a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("UnlockAchievement() duplicate failed: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("UnlockAchievement should return false on duplicate")
|
||||
}
|
||||
|
||||
has, _ = s.HasAchievement(user.ID, "first_clockin")
|
||||
if !has {
|
||||
t.Error("HasAchievement should be true after unlocking")
|
||||
}
|
||||
|
||||
achs, err := s.GetUserAchievements(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserAchievements() failed: %v", err)
|
||||
}
|
||||
if len(achs) != 1 {
|
||||
t.Fatalf("got %d achievements, want 1", len(achs))
|
||||
}
|
||||
if achs[0].Code != "first_clockin" {
|
||||
t.Errorf("achievement code = %q, want first_clockin", achs[0].Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWorkTypes(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
defer s.Close()
|
||||
|
||||
wts, err := s.GetWorkTypes()
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorkTypes() failed: %v", err)
|
||||
}
|
||||
if len(wts) != 2 {
|
||||
t.Fatalf("got %d work types, want 2", len(wts))
|
||||
}
|
||||
|
||||
wt, err := s.GetWorkType(1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorkType(1) failed: %v", err)
|
||||
}
|
||||
if wt.Name != "onsite" {
|
||||
t.Errorf("WorkType 1 = %q, want onsite", wt.Name)
|
||||
}
|
||||
|
||||
_, err = s.GetWorkType(999)
|
||||
if err == nil {
|
||||
t.Error("GetWorkType(999) should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserDaysInRange(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
defer s.Close()
|
||||
|
||||
user, _ := s.GetOrCreateUser(12345)
|
||||
s.GetOrCreateDay(user.ID, "2026-06-01")
|
||||
s.GetOrCreateDay(user.ID, "2026-06-02")
|
||||
s.GetOrCreateDay(user.ID, "2026-06-05")
|
||||
|
||||
days, err := s.GetUserDaysInRange(user.ID, "2026-06-01", "2026-06-10")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserDaysInRange() failed: %v", err)
|
||||
}
|
||||
if len(days) != 3 {
|
||||
t.Fatalf("got %d days, want 3", len(days))
|
||||
}
|
||||
|
||||
days, _ = s.GetUserDaysInRange(user.ID, "2026-06-03", "2026-06-10")
|
||||
if len(days) != 1 {
|
||||
t.Fatalf("got %d days in narrow range, want 1", len(days))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDatesWithEvents(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
defer s.Close()
|
||||
|
||||
user, _ := s.GetOrCreateUser(12345)
|
||||
d1, _ := s.GetOrCreateDay(user.ID, "2026-06-01")
|
||||
d2, _ := s.GetOrCreateDay(user.ID, "2026-06-02")
|
||||
s.GetOrCreateDay(user.ID, "2026-06-03")
|
||||
|
||||
s.CreateEvent(user.ID, d1.ID, "in", nil, 100, "")
|
||||
s.CreateEvent(user.ID, d2.ID, "in", nil, 200, "")
|
||||
|
||||
dates, err := s.GetDatesWithEvents(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDatesWithEvents() failed: %v", err)
|
||||
}
|
||||
if len(dates) != 2 {
|
||||
t.Fatalf("got %d dates, want 2", len(dates))
|
||||
}
|
||||
|
||||
distinct, err := s.GetDistinctEventDates(user.ID, "2026-06-01", "2026-06-30")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDistinctEventDates() failed: %v", err)
|
||||
}
|
||||
if len(distinct) != 2 {
|
||||
t.Fatalf("got %d distinct dates, want 2", len(distinct))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumWorkSecondsByWorkType(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
defer s.Close()
|
||||
|
||||
user, _ := s.GetOrCreateUser(12345)
|
||||
day, _ := s.GetOrCreateDay(user.ID, "2026-06-25")
|
||||
|
||||
wt2 := int64(2)
|
||||
s.CreateEvent(user.ID, day.ID, "in", &wt2, 100, "")
|
||||
s.CreateEvent(user.ID, day.ID, "out", nil, 3600, "")
|
||||
s.SetDailyWorkSeconds(user.ID, "2026-06-25", 3500)
|
||||
|
||||
rem, ons, err := s.SumWorkSecondsByWorkType(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("SumWorkSecondsByWorkType() failed: %v", err)
|
||||
}
|
||||
if rem+ons == 0 {
|
||||
t.Error("expected non-zero work seconds, got 0 for both types")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUserData(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
defer s.Close()
|
||||
|
||||
user, _ := s.GetOrCreateUser(12345)
|
||||
s.GetOrCreateSettings(user.ID)
|
||||
s.GetOrCreateRPGStats(user.ID)
|
||||
s.GetOrCreateDay(user.ID, "2026-06-25")
|
||||
s.UpsertDailyXP(user.ID, "2026-06-25", 100, 3600)
|
||||
|
||||
a, _ := s.GetAchievementByCode("first_clockin")
|
||||
s.UnlockAchievement(user.ID, a.ID)
|
||||
|
||||
if err := s.DeleteUserData(user.ID); err != nil {
|
||||
t.Fatalf("DeleteUserData() failed: %v", err)
|
||||
}
|
||||
|
||||
user2, err := s.GetOrCreateUser(12345)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrCreateUser() after delete failed: %v", err)
|
||||
}
|
||||
if user2.ID <= user.ID {
|
||||
t.Error("new user should get a fresh ID")
|
||||
}
|
||||
|
||||
total, _ := s.SumDailyWorkSeconds(user.ID)
|
||||
if total != 0 {
|
||||
t.Errorf("work seconds after delete = %d, want 0", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkReportSent(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
defer s.Close()
|
||||
|
||||
user, _ := s.GetOrCreateUser(12345)
|
||||
|
||||
if err := s.MarkReportSent(user.ID, "2026-06-25"); err != nil {
|
||||
t.Fatalf("MarkReportSent() failed: %v", err)
|
||||
}
|
||||
|
||||
settings, _ := s.GetOrCreateSettings(user.ID)
|
||||
if settings.LastReportDate != "2026-06-25" {
|
||||
t.Errorf("LastReportDate = %q, want 2026-06-25", settings.LastReportDate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllUsers(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
defer s.Close()
|
||||
|
||||
s.GetOrCreateUser(12345)
|
||||
s.GetOrCreateUser(67890)
|
||||
|
||||
users, err := s.GetAllUsers()
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllUsers() failed: %v", err)
|
||||
}
|
||||
if len(users) != 2 {
|
||||
t.Fatalf("got %d users, want 2", len(users))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserAndDay(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
defer s.Close()
|
||||
|
||||
user, _ := s.GetOrCreateUser(12345)
|
||||
user.Timezone = "America/New_York"
|
||||
user.Username = "testuser"
|
||||
if err := s.UpdateUser(user); err != nil {
|
||||
t.Fatalf("UpdateUser() failed: %v", err)
|
||||
}
|
||||
|
||||
user2, _ := s.GetOrCreateUser(12345)
|
||||
if user2.Timezone != "America/New_York" {
|
||||
t.Errorf("Timezone = %q, want America/New_York", user2.Timezone)
|
||||
}
|
||||
if user2.Username != "testuser" {
|
||||
t.Errorf("Username = %q, want testuser", user2.Username)
|
||||
}
|
||||
|
||||
day, _ := s.GetOrCreateDay(user.ID, "2026-06-25")
|
||||
day.MinBreakThreshold = 600
|
||||
if err := s.UpdateDay(day); err != nil {
|
||||
t.Fatalf("UpdateDay() failed: %v", err)
|
||||
}
|
||||
|
||||
day2, _ := s.GetDay(user.ID, "2026-06-25")
|
||||
if day2.MinBreakThreshold != 600 {
|
||||
t.Errorf("MinBreakThreshold = %d, want 600", day2.MinBreakThreshold)
|
||||
}
|
||||
}
|
||||
146
opencode.json
146
opencode.json
@@ -1,146 +0,0 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user