From ba1770b4939c6bb66f7039124be2a7117a3e034f Mon Sep 17 00:00:00 2001 From: Blake Ridgway Date: Sat, 11 Apr 2026 14:06:59 -0500 Subject: [PATCH] first commit --- .env.example | 9 + .gitignore | 19 ++ Makefile | 66 +++++ README.md | 61 ++++ assets/avatar.svg | 87 ++++++ bot/bot.go | 370 ++++++++++++++++++++++++ bot/format.go | 81 ++++++ bot/handlers_admin.go | 76 +++++ bot/handlers_challenge.go | 48 ++++ bot/handlers_leaderboard.go | 91 ++++++ bot/handlers_personal.go | 118 ++++++++ bot/handlers_reports.go | 43 +++ bot/handlers_setup.go | 21 ++ bot/handlers_social.go | 73 +++++ db/db.go | 540 ++++++++++++++++++++++++++++++++++++ go.mod | 27 ++ go.sum | 62 +++++ main.go | 66 +++++ parser/parser.go | 77 +++++ parser/parser_test.go | 41 +++ rc.d/cyclingbot | 51 ++++ 21 files changed, 2027 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 assets/avatar.svg create mode 100644 bot/bot.go create mode 100644 bot/format.go create mode 100644 bot/handlers_admin.go create mode 100644 bot/handlers_challenge.go create mode 100644 bot/handlers_leaderboard.go create mode 100644 bot/handlers_personal.go create mode 100644 bot/handlers_reports.go create mode 100644 bot/handlers_setup.go create mode 100644 bot/handlers_social.go create mode 100644 db/db.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 parser/parser.go create mode 100644 parser/parser_test.go create mode 100644 rc.d/cyclingbot diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f29c5d4 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Required: your bot token from https://discord.com/developers/applications +DISCORD_TOKEN=your-bot-token-here + +# Optional: restrict slash command registration to a specific server (instant propagation) +# Leave blank for global commands (~1 hour to propagate) +GUILD_ID= + +# Optional: path to the SQLite database file (default: cycling_bot.db) +DB_PATH=cycling_bot.db diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4765b13 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Compiled binary +cycling-bot + +# Environment / secrets +.env + +# SQLite database +*.db +*.db-shm +*.db-wal + +# Go build cache / test artifacts +*.test +*.out + +# Editor / OS +.DS_Store +*.swp +*.swo diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cdb6813 --- /dev/null +++ b/Makefile @@ -0,0 +1,66 @@ +HOST = 172.16.0.214 +SSH_USER = root +SSH = ssh $(SSH_USER)@$(HOST) +SCP = scp + +JAIL_NAME = cyclingbot +JAIL_ROOT = /jails/$(JAIL_NAME) +BINARY = cycling-bot + +.PHONY: all build deploy deploy-env restart stop logs status clean + +all: build deploy start + +# Cross-compile for FreeBSD amd64 +build: + GOOS=freebsd GOARCH=amd64 go build -o $(BINARY) . + +# Create required directories inside the jail (safe to run multiple times) +setup: + $(SSH) "mkdir -p $(JAIL_ROOT)/usr/local/bin \ + $(JAIL_ROOT)/usr/local/etc/rc.d \ + $(JAIL_ROOT)/var/db/$(JAIL_NAME) \ + $(JAIL_ROOT)/var/log \ + $(JAIL_ROOT)/var/run" + +# Copy binary and rc.d script into the jail (via /tmp to avoid jail dir permission issues) +deploy: build setup + $(SCP) $(BINARY) $(SSH_USER)@$(HOST):/tmp/$(BINARY) + $(SCP) rc.d/$(JAIL_NAME) $(SSH_USER)@$(HOST):/tmp/$(JAIL_NAME)-rcd + $(SSH) "jexec $(JAIL_NAME) service $(JAIL_NAME) stop 2>/dev/null; \ + rm -f $(JAIL_ROOT)/usr/local/bin/$(BINARY) && \ + cp /tmp/$(BINARY) $(JAIL_ROOT)/usr/local/bin/$(BINARY) && \ + chmod +x $(JAIL_ROOT)/usr/local/bin/$(BINARY) && \ + rm /tmp/$(BINARY) && \ + rm -f $(JAIL_ROOT)/usr/local/etc/rc.d/$(JAIL_NAME) && \ + cp /tmp/$(JAIL_NAME)-rcd $(JAIL_ROOT)/usr/local/etc/rc.d/$(JAIL_NAME) && \ + chmod +x $(JAIL_ROOT)/usr/local/etc/rc.d/$(JAIL_NAME) && \ + rm /tmp/$(JAIL_NAME)-rcd" + +# Copy .env separately (run once — avoid overwriting production config) +deploy-env: + $(SCP) .env \ + $(SSH_USER)@$(HOST):$(JAIL_ROOT)/var/db/$(JAIL_NAME)/.env + $(SSH) "chmod 600 $(JAIL_ROOT)/var/db/$(JAIL_NAME)/.env && \ + chown 1001:1001 $(JAIL_ROOT)/var/db/$(JAIL_NAME)/.env" + +start: + $(SSH) "rm -f $(JAIL_ROOT)/var/run/$(JAIL_NAME).pid && \ + jexec $(JAIL_NAME) service $(JAIL_NAME) start" + +restart: + $(SSH) "jexec $(JAIL_NAME) service $(JAIL_NAME) stop 2>/dev/null; \ + rm -f $(JAIL_ROOT)/var/run/$(JAIL_NAME).pid && \ + jexec $(JAIL_NAME) service $(JAIL_NAME) start" + +stop: + $(SSH) "jexec $(JAIL_NAME) service $(JAIL_NAME) stop" + +logs: + $(SSH) "jexec $(JAIL_NAME) tail -f /var/log/$(JAIL_NAME).log" + +status: + $(SSH) "jls && jexec $(JAIL_NAME) service $(JAIL_NAME) status" + +clean: + rm -f $(BINARY) diff --git a/README.md b/README.md new file mode 100644 index 0000000..db5bc71 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Cycling Discord Bot + +A Discord bot for tracking cycling (and other fitness) distances across a server. Members post their distance in a designated channel and the bot logs it automatically. + +## Features + +- Auto-parses distances from messages in a configured channel +- Leaderboard, yearly totals, weekly/monthly reports +- Personal stats: history, PB, streaks, unit preference (km/miles) +- Social: kudos, head-to-head comparison +- Admin tools: manual adjustments, ride removal, audit log, challenge management +- Updates channel topic with the current running total + +## Setup + +Copy `.env.example` to `.env` and fill in your values: + +``` +DISCORD_TOKEN=your-bot-token-here +GUILD_ID= # optional: restrict to one server for instant command propagation +DB_PATH=cycling_bot.db # optional: defaults to cycling_bot.db +``` + +## Build & Run + +```sh +go build -o cycling-bot . +./cycling-bot +``` + +Cross-compile for FreeBSD: + +```sh +GOOS=freebsd GOARCH=amd64 go build -o cycling-bot . +``` + +See `Makefile` for deploy targets (`make deploy`, `make deploy-env`, `make restart`, etc.). + +## Commands + +| Command | Description | +|---|---| +| `/setchannel` | [Admin] Set the channel to track | +| `/resetchallenge` | [Admin] Archive current totals and start fresh | +| `/setchallengename` | [Admin] Name the current challenge | +| `/setgoal` | [Admin] Set a collective KM goal | +| `/addkm` | [Admin] Manually credit/debit KM for a user | +| `/removelog` | [Admin] Remove a ride by message ID | +| `/audit` | [Admin] View all logged rides for a user | +| `/leaderboard` | Top cyclists in the current challenge | +| `/yearlyleaderboard` | Top cyclists for the calendar year | +| `/totalkm` | Total distance in the current challenge | +| `/mystats` | Your challenge + yearly stats | +| `/history` | Your recent rides | +| `/pb` | Your personal best single ride | +| `/streak` | Consecutive days with a logged ride | +| `/setunit` | Set preferred unit (km or miles) | +| `/kudos` | Give a shoutout to another rider | +| `/compare` | Head-to-head stats vs another rider | +| `/weeklyreport` | Distances logged this week | +| `/monthlyreport` | Distances logged this month | diff --git a/assets/avatar.svg b/assets/avatar.svg new file mode 100644 index 0000000..4023828 --- /dev/null +++ b/assets/avatar.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bot/bot.go b/bot/bot.go new file mode 100644 index 0000000..cb487f3 --- /dev/null +++ b/bot/bot.go @@ -0,0 +1,370 @@ +package bot + +import ( + "context" + "fmt" + "log" + "strings" + "sync" + "time" + + "github.com/bwmarrin/discordgo" + + "cycling-discord-bot/db" + "cycling-discord-bot/parser" +) + +const ( + reactionOK = "✅" + reactionDupe = "🔁" + settingChannel = "fitness_channel_id" +) + +type Bot struct { + session *discordgo.Session + db *db.DB + guildID string + + topicMu sync.Mutex + topicTimer *time.Timer +} + +func New(token string, database *db.DB, guildID string) (*Bot, error) { + s, err := discordgo.New("Bot " + token) + if err != nil { + return nil, fmt.Errorf("create session: %w", err) + } + s.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent + + b := &Bot{session: s, db: database, guildID: guildID} + s.AddHandler(b.onMessageCreate) + s.AddHandler(b.onMessageDelete) + s.AddHandler(b.onInteraction) + s.AddHandler(b.onReady) + return b, nil +} + +func (b *Bot) Open() error { return b.session.Open() } +func (b *Bot) Close() { _ = b.session.Close() } + +func (b *Bot) RegisterCommands() error { + str := discordgo.ApplicationCommandOptionString + num := discordgo.ApplicationCommandOptionNumber + usr := discordgo.ApplicationCommandOptionUser + ch := discordgo.ApplicationCommandOptionChannel + int := discordgo.ApplicationCommandOptionInteger + + commands := []*discordgo.ApplicationCommand{ + // ── Setup ─────────────────────────────────────────────────────────── + {Name: "setchannel", Description: "[Admin] Set the channel to track for the fitness challenge", + Options: []*discordgo.ApplicationCommandOption{ + {Type: ch, Name: "channel", Description: "Channel to monitor", Required: true}, + }}, + + // ── Challenge management ───────────────────────────────────────────── + {Name: "resetchallenge", Description: "[Admin] Archive current totals and start a new challenge", + Options: []*discordgo.ApplicationCommandOption{ + {Type: str, Name: "name", Description: "Name for the archived challenge (e.g. 'March TdF')", Required: false}, + }}, + {Name: "setchallengename", Description: "[Admin] Set the display name for the current challenge", + Options: []*discordgo.ApplicationCommandOption{ + {Type: str, Name: "name", Description: "Challenge name", Required: true}, + }}, + {Name: "setgoal", Description: "[Admin] Set a collective KM goal for the challenge", + Options: []*discordgo.ApplicationCommandOption{ + {Type: num, Name: "km", Description: "Target distance in KM", Required: true}, + }}, + + // ── Leaderboard & totals ───────────────────────────────────────────── + {Name: "leaderboard", Description: "Show the top cyclists in the current challenge"}, + {Name: "yearlyleaderboard", Description: "Show the top cyclists for the current calendar year"}, + {Name: "totalkm", Description: "Show total distance logged in the current challenge"}, + + // ── Personal ───────────────────────────────────────────────────────── + {Name: "mystats", Description: "Show your personal stats (challenge + yearly)"}, + {Name: "history", Description: "Show your recent rides", + Options: []*discordgo.ApplicationCommandOption{ + {Type: int, Name: "count", Description: "Number of rides to show (default 5)", Required: false}, + }}, + {Name: "pb", Description: "Show your personal best single-ride distance"}, + {Name: "streak", Description: "Show consecutive days with a logged ride", + Options: []*discordgo.ApplicationCommandOption{ + {Type: usr, Name: "user", Description: "User to check (defaults to you)", Required: false}, + }}, + {Name: "setunit", Description: "Set your preferred distance unit for personal stats", + Options: []*discordgo.ApplicationCommandOption{ + {Type: str, Name: "unit", Description: "km or miles", Required: true, + Choices: []*discordgo.ApplicationCommandOptionChoice{ + {Name: "Kilometres (km)", Value: "km"}, + {Name: "Miles (mi)", Value: "miles"}, + }}, + }}, + + // ── Social ─────────────────────────────────────────────────────────── + {Name: "kudos", Description: "Give a shoutout to a fellow rider", + Options: []*discordgo.ApplicationCommandOption{ + {Type: usr, Name: "user", Description: "Rider to kudos", Required: true}, + }}, + {Name: "compare", Description: "Compare your stats head-to-head with another rider", + Options: []*discordgo.ApplicationCommandOption{ + {Type: usr, Name: "user", Description: "Rider to compare against", Required: true}, + }}, + + // ── Reports ────────────────────────────────────────────────────────── + {Name: "weeklyreport", Description: "Show distances logged this week"}, + {Name: "monthlyreport", Description: "Show distances logged this month"}, + + // ── Admin ──────────────────────────────────────────────────────────── + {Name: "addkm", Description: "[Admin] Manually add or subtract KM for a user", + Options: []*discordgo.ApplicationCommandOption{ + {Type: usr, Name: "user", Description: "User to credit", Required: true}, + {Type: num, Name: "km", Description: "Distance in KM (negative to subtract)", Required: true}, + }}, + {Name: "removelog", Description: "[Admin] Remove a specific logged ride by message ID", + Options: []*discordgo.ApplicationCommandOption{ + {Type: str, Name: "message_id", Description: "Message ID of the ride to remove", Required: true}, + }}, + {Name: "audit", Description: "[Admin] View all logged rides for a user", + Options: []*discordgo.ApplicationCommandOption{ + {Type: usr, Name: "user", Description: "User to audit", Required: true}, + }}, + } + + for _, cmd := range commands { + if _, err := b.session.ApplicationCommandCreate(b.session.State.User.ID, b.guildID, cmd); err != nil { + return fmt.Errorf("register %q: %w", cmd.Name, err) + } + log.Printf("registered command /%s", cmd.Name) + } + return nil +} + +// ── Event handlers ──────────────────────────────────────────────────────────── + +func (b *Bot) onReady(s *discordgo.Session, r *discordgo.Ready) { + log.Printf("logged in as %s#%s", r.User.Username, r.User.Discriminator) + _ = s.UpdateGameStatus(0, "tracking your KMs 🚴") +} + +func (b *Bot) onMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { + if m.Author == nil || m.Author.Bot { + return + } + ctx := context.Background() + + channelID, ok, err := b.db.GetSetting(ctx, m.GuildID, settingChannel) + if err != nil { + log.Printf("onMessageCreate: db error: %v", err) + return + } + log.Printf("onMessageCreate: guild=%s incoming=%s configured=%s ok=%v", + m.GuildID, m.ChannelID, channelID, ok) + if !ok || m.ChannelID != channelID { + return + } + + log.Printf("onMessageCreate: content=%q", m.Content) + + km, ok := parser.ParseKM(m.Content) + if !ok { + log.Printf("onMessageCreate: no distance found in %q", m.Content) + return + } + log.Printf("onMessageCreate: parsed %.2f km from %q", km, m.Content) + + added, err := b.db.AddLog(ctx, m.GuildID, m.Author.ID, displayName(m.Member, m.Author), m.ID, m.ChannelID, km) + if err != nil { + log.Printf("db.AddLog error: %v", err) + return + } + + if added { + log.Printf("logged %.2f km for %s", km, m.Author.Username) + _ = s.MessageReactionAdd(m.ChannelID, m.ID, reactionOK) + b.scheduleTopicUpdate(m.GuildID, m.ChannelID) + } else { + _ = s.MessageReactionAdd(m.ChannelID, m.ID, reactionDupe) + } +} + +func (b *Bot) onMessageDelete(s *discordgo.Session, m *discordgo.MessageDelete) { + ctx := context.Background() + km, err := b.db.RemoveLog(ctx, m.ID) + if err != nil { + log.Printf("db.RemoveLog error: %v", err) + return + } + if km > 0 { + log.Printf("removed %.2f km for deleted message %s", km, m.ID) + b.scheduleTopicUpdate(m.GuildID, m.ChannelID) + } +} + +func (b *Bot) onInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.Type != discordgo.InteractionApplicationCommand { + return + } + ctx := context.Background() + switch i.ApplicationCommandData().Name { + case "setchannel": b.handleSetChannel(ctx, s, i) + case "resetchallenge": b.handleResetChallenge(ctx, s, i) + case "setchallengename": b.handleSetChallengeName(ctx, s, i) + case "setgoal": b.handleSetGoal(ctx, s, i) + case "leaderboard": b.handleLeaderboard(ctx, s, i) + case "yearlyleaderboard":b.handleYearlyLeaderboard(ctx, s, i) + case "totalkm": b.handleTotalKM(ctx, s, i) + case "mystats": b.handleMyStats(ctx, s, i) + case "history": b.handleHistory(ctx, s, i) + case "pb": b.handlePB(ctx, s, i) + case "streak": b.handleStreak(ctx, s, i) + case "setunit": b.handleSetUnit(ctx, s, i) + case "kudos": b.handleKudos(ctx, s, i) + case "compare": b.handleCompare(ctx, s, i) + case "weeklyreport": b.handleWeeklyReport(ctx, s, i) + case "monthlyreport": b.handleMonthlyReport(ctx, s, i) + case "addkm": b.handleAddKM(ctx, s, i) + case "removelog": b.handleRemoveLog(ctx, s, i) + case "audit": b.handleAudit(ctx, s, i) + } +} + +// ── Topic update ────────────────────────────────────────────────────────────── + +func (b *Bot) scheduleTopicUpdate(guildID, channelID string) { + b.topicMu.Lock() + defer b.topicMu.Unlock() + if b.topicTimer != nil { + b.topicTimer.Stop() + } + b.topicTimer = time.AfterFunc(30*time.Second, func() { + ctx := context.Background() + + // Current year total + yearTotals, err := b.db.GetYearTotals(ctx, guildID) + if err != nil { + log.Printf("scheduleTopicUpdate: db error: %v", err) + return + } + + currentYear := time.Now().Year() + var currentTotal float64 + var historical []string + for _, yt := range yearTotals { + if yt.Year == currentYear { + currentTotal = yt.TotalKM + } else { + historical = append(historical, + fmt.Sprintf("-%d Results: %s km", yt.Year, fmtKM(yt.TotalKM, 0))) + } + } + + topic := fmt.Sprintf( + "•Current Total: %s km\n•Miles-to-Kilometers = Miles x 1.609\n•Log your fitness activity distances (any sport)\n•You may not add distances you accumulated prior to joining this server.", + fmtKM(currentTotal, 1), + ) + if len(historical) > 0 { + topic += "\n\n" + strings.Join(historical, "\n") + } + + // Discord topic limit is 1024 chars + if len(topic) > 1024 { + topic = topic[:1021] + "..." + } + + if _, err := b.session.ChannelEdit(channelID, &discordgo.ChannelEdit{Topic: topic}); err != nil { + log.Printf("scheduleTopicUpdate: failed: %v", err) + return + } + log.Printf("scheduleTopicUpdate: updated topic for guild %s (%.1f km)", guildID, currentTotal) + }) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +func (b *Bot) isAdmin(i *discordgo.InteractionCreate) bool { + return i.Member != nil && i.Member.Permissions&discordgo.PermissionManageServer != 0 +} + +func (b *Bot) challengeStart(ctx context.Context, guildID string) time.Time { + t, _, _ := b.db.GetChallengeStart(ctx, guildID) + return t +} + +func respond(s *discordgo.Session, i *discordgo.InteractionCreate, content string) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{Content: content}, + }) +} + +func respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, content string) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: content, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} + +func displayName(member *discordgo.Member, user *discordgo.User) string { + if member != nil && member.Nick != "" { + return member.Nick + } + if user != nil { + if user.GlobalName != "" { + return user.GlobalName + } + return user.Username + } + return "Unknown" +} + +func weekStart() time.Time { + now := time.Now().UTC() + weekday := int(now.Weekday()) + if weekday == 0 { + weekday = 7 + } + return now.Truncate(24 * time.Hour).Add(-time.Duration(weekday-1) * 24 * time.Hour) +} + +func monthStart() time.Time { + now := time.Now().UTC() + return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) +} + +func optString(opts []*discordgo.ApplicationCommandInteractionDataOption, name string) (string, bool) { + for _, o := range opts { + if o.Name == name { + return o.StringValue(), true + } + } + return "", false +} + +func optInt(opts []*discordgo.ApplicationCommandInteractionDataOption, name string, def int64) int64 { + for _, o := range opts { + if o.Name == name { + return o.IntValue() + } + } + return def +} + +func optUser(opts []*discordgo.ApplicationCommandInteractionDataOption, name string, s *discordgo.Session) *discordgo.User { + for _, o := range opts { + if o.Name == name { + return o.UserValue(s) + } + } + return nil +} + +// strings.Title is deprecated — simple replacement for single words +func titleCase(s string) string { + if len(s) == 0 { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} diff --git a/bot/format.go b/bot/format.go new file mode 100644 index 0000000..f84c222 --- /dev/null +++ b/bot/format.go @@ -0,0 +1,81 @@ +package bot + +import ( + "fmt" + "strings" +) + +const miToKM = 1.60934 + +// formatDist formats a KM value in the user's preferred unit. +func formatDist(km float64, unit string) string { + if unit == "miles" { + return fmt.Sprintf("%.1f mi", km/miToKM) + } + return fmt.Sprintf("%.1f km", km) +} + +// distanceComparison returns a fun contextual comparison string. +func distanceComparison(km float64) string { + switch { + case km >= 384400: + return "You've cycled **to the Moon!** 🌕" + case km >= 40075: + return fmt.Sprintf("That's **%.1f laps around the Earth!** 🌏", km/40075) + case km >= 1892: + return fmt.Sprintf("That's like riding **London → Rome** (%.0f%%)! 🗺️", km/1892*100) + case km >= 500: + return fmt.Sprintf("That's like riding **Amsterdam → Paris** and back (%.0f%%)! 🇫🇷", km/830*100) + default: + return "Keep pedalling — the KMs are adding up! 💪" + } +} + +// progressBar renders a simple ASCII progress bar. +func progressBar(current, max float64, width int) string { + if max <= 0 { + return "" + } + pct := current / max + if pct > 1 { + pct = 1 + } + filled := int(pct * float64(width)) + bar := "" + for i := 0; i < width; i++ { + if i < filled { + bar += "█" + } else { + bar += "░" + } + } + return fmt.Sprintf("[%s] %.0f%%", bar, pct*100) +} + +// fmtKM formats a KM value with comma separators (e.g. 5243.3 → "5,243.3"). +// decimals controls how many decimal places to show. +func fmtKM(km float64, decimals int) string { + s := fmt.Sprintf(fmt.Sprintf("%%.%df", decimals), km) + parts := strings.SplitN(s, ".", 2) + intPart := parts[0] + + n := len(intPart) + var out []byte + for i := 0; i < n; i++ { + if i > 0 && (n-i)%3 == 0 { + out = append(out, ',') + } + out = append(out, intPart[i]) + } + if len(parts) > 1 { + return string(out) + "." + parts[1] + } + return string(out) +} + +func plural(n int) string { + if n == 1 { + return "" + } + return "s" +} diff --git a/bot/handlers_admin.go b/bot/handlers_admin.go new file mode 100644 index 0000000..fbb8daf --- /dev/null +++ b/bot/handlers_admin.go @@ -0,0 +1,76 @@ +package bot + +import ( + "context" + "fmt" + "math" + "strings" + + "github.com/bwmarrin/discordgo" +) + +func (b *Bot) handleAddKM(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + if !b.isAdmin(i) { + respondEphemeral(s, i, "You need the **Manage Server** permission to use this command.") + return + } + opts := i.ApplicationCommandData().Options + targetUser := opts[0].UserValue(s) + km := opts[1].FloatValue() + + targetMember, _ := s.GuildMember(i.GuildID, targetUser.ID) + name := displayName(targetMember, targetUser) + + if err := b.db.AdjustKM(ctx, i.GuildID, targetUser.ID, name, km); err != nil { + respondEphemeral(s, i, "Error updating KM.") + return + } + verb := "added" + if km < 0 { + verb = "removed" + km = math.Abs(km) + } + respondEphemeral(s, i, fmt.Sprintf("✅ %s %.1f km for **%s**.", titleCase(verb), km, name)) +} + +func (b *Bot) handleRemoveLog(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + if !b.isAdmin(i) { + respondEphemeral(s, i, "You need the **Manage Server** permission to use this command.") + return + } + messageID := i.ApplicationCommandData().Options[0].StringValue() + km, err := b.db.RemoveLog(ctx, messageID) + if err != nil { + respondEphemeral(s, i, "Error removing log.") + return + } + if km == 0 { + respondEphemeral(s, i, "No log found for that message ID.") + return + } + respondEphemeral(s, i, fmt.Sprintf("✅ Removed a **%.1f km** entry.", km)) +} + +func (b *Bot) handleAudit(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + if !b.isAdmin(i) { + respondEphemeral(s, i, "You need the **Manage Server** permission to use this command.") + return + } + target := i.ApplicationCommandData().Options[0].UserValue(s) + targetMember, _ := s.GuildMember(i.GuildID, target.ID) + name := displayName(targetMember, target) + + logs, err := b.db.GetUserLogs(ctx, i.GuildID, target.ID, 20) + if err != nil || len(logs) == 0 { + respondEphemeral(s, i, fmt.Sprintf("No logs found for **%s**.", name)) + return + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("## 🔍 Audit: %s (last %d)\n\n", name, len(logs))) + for _, l := range logs { + sb.WriteString(fmt.Sprintf("`%s` — **%.1f km** (msg: `%s`)\n", + l.LoggedAt.Format("02 Jan 15:04"), l.KM, l.MessageID)) + } + respondEphemeral(s, i, sb.String()) +} diff --git a/bot/handlers_challenge.go b/bot/handlers_challenge.go new file mode 100644 index 0000000..8a35a00 --- /dev/null +++ b/bot/handlers_challenge.go @@ -0,0 +1,48 @@ +package bot + +import ( + "context" + "fmt" + + "github.com/bwmarrin/discordgo" +) + +func (b *Bot) handleResetChallenge(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + if !b.isAdmin(i) { + respondEphemeral(s, i, "You need the **Manage Server** permission to use this command.") + return + } + name, _ := optString(i.ApplicationCommandData().Options, "name") + if err := b.db.ResetChallenge(ctx, i.GuildID, name); err != nil { + respondEphemeral(s, i, "Error resetting challenge.") + return + } + _ = b.db.DeleteSetting(ctx, i.GuildID, "challenge_goal_km") + respond(s, i, "🔄 Challenge reset! Previous totals have been archived. Start logging your rides!") +} + +func (b *Bot) handleSetChallengeName(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + if !b.isAdmin(i) { + respondEphemeral(s, i, "You need the **Manage Server** permission to use this command.") + return + } + name := i.ApplicationCommandData().Options[0].StringValue() + if err := b.db.SetSetting(ctx, i.GuildID, "challenge_name", name); err != nil { + respondEphemeral(s, i, "Error saving challenge name.") + return + } + respondEphemeral(s, i, fmt.Sprintf("✅ Challenge name set to **%s**.", name)) +} + +func (b *Bot) handleSetGoal(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + if !b.isAdmin(i) { + respondEphemeral(s, i, "You need the **Manage Server** permission to use this command.") + return + } + km := i.ApplicationCommandData().Options[0].FloatValue() + if err := b.db.SetSetting(ctx, i.GuildID, "challenge_goal_km", fmt.Sprintf("%.2f", km)); err != nil { + respondEphemeral(s, i, "Error saving goal.") + return + } + respond(s, i, fmt.Sprintf("🎯 Goal set to **%.1f km**! Let's ride!", km)) +} diff --git a/bot/handlers_leaderboard.go b/bot/handlers_leaderboard.go new file mode 100644 index 0000000..041439c --- /dev/null +++ b/bot/handlers_leaderboard.go @@ -0,0 +1,91 @@ +package bot + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/bwmarrin/discordgo" + + "cycling-discord-bot/db" +) + +func (b *Bot) handleLeaderboard(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + since := b.challengeStart(ctx, i.GuildID) + entries, err := b.db.GetLeaderboard(ctx, i.GuildID, since, 10) + if err != nil { + respondEphemeral(s, i, "Error fetching leaderboard.") + return + } + if len(entries) == 0 { + respondEphemeral(s, i, "No distances logged yet. Get riding! 🚴") + return + } + name, _, _ := b.db.GetSetting(ctx, i.GuildID, "challenge_name") + if name == "" { + name = "Fitness Challenge" + } + respond(s, i, leaderboardText(fmt.Sprintf("🚴 %s — Leaderboard", name), entries)) +} + +func (b *Bot) handleYearlyLeaderboard(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + year := time.Now().Year() + entries, err := b.db.GetYearlyLeaderboard(ctx, i.GuildID, year, 10) + if err != nil { + respondEphemeral(s, i, "Error fetching yearly leaderboard.") + return + } + if len(entries) == 0 { + respondEphemeral(s, i, fmt.Sprintf("No distances logged in %d yet.", year)) + return + } + respond(s, i, leaderboardText(fmt.Sprintf("📅 %d Yearly Leaderboard", year), entries)) +} + +func (b *Bot) handleTotalKM(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + since := b.challengeStart(ctx, i.GuildID) + total, err := b.db.GetTotalKM(ctx, i.GuildID, since) + if err != nil { + respondEphemeral(s, i, "Error fetching total.") + return + } + + yearStart := time.Date(time.Now().Year(), 1, 1, 0, 0, 0, 0, time.UTC) + yearTotal, _ := b.db.GetTotalKM(ctx, i.GuildID, yearStart) + + name, _, _ := b.db.GetSetting(ctx, i.GuildID, "challenge_name") + if name == "" { + name = "Challenge" + } + + var sb strings.Builder + sb.WriteString("## 🌍 Distance Totals\n\n") + sb.WriteString(fmt.Sprintf("**%s:** %.1f km\n", name, total)) + sb.WriteString(fmt.Sprintf("**%d total:** %.1f km\n\n", time.Now().Year(), yearTotal)) + + if goalStr, ok, _ := b.db.GetSetting(ctx, i.GuildID, "challenge_goal_km"); ok { + var goal float64 + fmt.Sscanf(goalStr, "%f", &goal) + if goal > 0 { + sb.WriteString(fmt.Sprintf("**Goal:** %.1f km %s\n\n", goal, progressBar(total, goal, 12))) + } + } + sb.WriteString(distanceComparison(total)) + respond(s, i, sb.String()) +} + +func leaderboardText(title string, entries []*db.UserStats) string { + medals := []string{"🥇", "🥈", "🥉"} + var sb strings.Builder + sb.WriteString(fmt.Sprintf("## %s\n\n", title)) + for idx, e := range entries { + medal := " " + if idx < len(medals) { + medal = medals[idx] + } + sb.WriteString(fmt.Sprintf("%s **%d.** %s — **%.1f km** (%d ride%s)\n", + medal, idx+1, e.Username, e.TotalKM, e.LogCount, plural(e.LogCount))) + } + return sb.String() +} diff --git a/bot/handlers_personal.go b/bot/handlers_personal.go new file mode 100644 index 0000000..7728c2c --- /dev/null +++ b/bot/handlers_personal.go @@ -0,0 +1,118 @@ +package bot + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/bwmarrin/discordgo" +) + +func (b *Bot) handleMyStats(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + userID := i.Member.User.ID + username := displayName(i.Member, i.Member.User) + unit, _ := b.db.GetUserPreference(ctx, userID, i.GuildID) + + since := b.challengeStart(ctx, i.GuildID) + challenge, _ := b.db.GetUserStats(ctx, i.GuildID, userID, since) + yearly, _ := b.db.GetUserYearlyStats(ctx, i.GuildID, userID, time.Now().Year()) + allTime, _ := b.db.GetUserStats(ctx, i.GuildID, userID, time.Time{}) + pb, _ := b.db.GetUserPB(ctx, i.GuildID, userID) + streak, _ := b.db.GetUserStreak(ctx, userID) + kudos, _ := b.db.GetKudosReceived(ctx, i.GuildID, userID) + + if allTime.LogCount == 0 { + respondEphemeral(s, i, "You haven't logged any distances yet. Post your rides in the fitness challenge channel!") + return + } + + name, _, _ := b.db.GetSetting(ctx, i.GuildID, "challenge_name") + if name == "" { + name = "Challenge" + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("## 🚴 %s's Stats\n\n", username)) + sb.WriteString(fmt.Sprintf("**%s:** %s (%d ride%s)\n", name, + formatDist(challenge.TotalKM, unit), challenge.LogCount, plural(challenge.LogCount))) + sb.WriteString(fmt.Sprintf("**%d:** %s (%d ride%s)\n", time.Now().Year(), + formatDist(yearly.TotalKM, unit), yearly.LogCount, plural(yearly.LogCount))) + sb.WriteString(fmt.Sprintf("**All time:** %s (%d ride%s)\n\n", + formatDist(allTime.TotalKM, unit), allTime.LogCount, plural(allTime.LogCount))) + sb.WriteString(fmt.Sprintf("**Personal best:** %s\n", formatDist(pb, unit))) + sb.WriteString(fmt.Sprintf("**Average ride:** %s\n", formatDist(allTime.TotalKM/float64(allTime.LogCount), unit))) + if streak > 0 { + sb.WriteString(fmt.Sprintf("**Current streak:** %d day%s 🔥\n", streak, plural(streak))) + } + if kudos > 0 { + sb.WriteString(fmt.Sprintf("**Kudos received:** %d 👏\n", kudos)) + } + + respondEphemeral(s, i, sb.String()) +} + +func (b *Bot) handleHistory(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + userID := i.Member.User.ID + unit, _ := b.db.GetUserPreference(ctx, userID, i.GuildID) + count := int(optInt(i.ApplicationCommandData().Options, "count", 5)) + if count > 20 { + count = 20 + } + + logs, err := b.db.GetUserHistory(ctx, i.GuildID, userID, count) + if err != nil || len(logs) == 0 { + respondEphemeral(s, i, "No rides logged yet.") + return + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("## 🚴 Your Last %d Ride%s\n\n", len(logs), plural(len(logs)))) + for _, l := range logs { + sb.WriteString(fmt.Sprintf("**%s** — %s\n", l.LoggedAt.Format("02 Jan 2006"), formatDist(l.KM, unit))) + } + respondEphemeral(s, i, sb.String()) +} + +func (b *Bot) handlePB(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + userID := i.Member.User.ID + unit, _ := b.db.GetUserPreference(ctx, userID, i.GuildID) + pb, err := b.db.GetUserPB(ctx, i.GuildID, userID) + if err != nil || pb == 0 { + respondEphemeral(s, i, "No rides logged yet.") + return + } + respondEphemeral(s, i, fmt.Sprintf("🏆 Your personal best single ride is **%s**!", formatDist(pb, unit))) +} + +func (b *Bot) handleStreak(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + target := optUser(i.ApplicationCommandData().Options, "user", s) + var userID, username string + if target != nil { + userID = target.ID + username = target.Username + } else { + userID = i.Member.User.ID + username = displayName(i.Member, i.Member.User) + } + + streak, err := b.db.GetUserStreak(ctx, userID) + if err != nil { + respondEphemeral(s, i, "Error fetching streak.") + return + } + if streak == 0 { + respondEphemeral(s, i, fmt.Sprintf("**%s** has no active streak. Get riding! 🚴", username)) + return + } + respondEphemeral(s, i, fmt.Sprintf("🔥 **%s** is on a **%d day** streak!", username, streak)) +} + +func (b *Bot) handleSetUnit(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + unit := i.ApplicationCommandData().Options[0].StringValue() + if err := b.db.SetUserPreference(ctx, i.Member.User.ID, i.GuildID, unit); err != nil { + respondEphemeral(s, i, "Error saving preference.") + return + } + respondEphemeral(s, i, fmt.Sprintf("✅ Your stats will now show in **%s**.", unit)) +} diff --git a/bot/handlers_reports.go b/bot/handlers_reports.go new file mode 100644 index 0000000..e27f642 --- /dev/null +++ b/bot/handlers_reports.go @@ -0,0 +1,43 @@ +package bot + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/bwmarrin/discordgo" +) + +func (b *Bot) handleWeeklyReport(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + from := weekStart() + b.sendReport(ctx, s, i, fmt.Sprintf("📅 Week of %s", from.Format("2 Jan")), from, time.Now().UTC()) +} + +func (b *Bot) handleMonthlyReport(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + from := monthStart() + b.sendReport(ctx, s, i, fmt.Sprintf("📅 %s", from.Format("January 2006")), from, time.Now().UTC()) +} + +func (b *Bot) sendReport(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, title string, from, to time.Time) { + entries, err := b.db.GetStatsInRange(ctx, i.GuildID, from, to, 20) + if err != nil { + respondEphemeral(s, i, "Error fetching report.") + return + } + if len(entries) == 0 { + respondEphemeral(s, i, "No rides logged in this period yet.") + return + } + + var total float64 + var sb strings.Builder + sb.WriteString(fmt.Sprintf("## %s Report\n\n", title)) + for idx, e := range entries { + sb.WriteString(fmt.Sprintf("**%d.** %s — **%.1f km** (%d ride%s)\n", + idx+1, e.Username, e.TotalKM, e.LogCount, plural(e.LogCount))) + total += e.TotalKM + } + sb.WriteString(fmt.Sprintf("\n**Period total:** %.1f km", total)) + respond(s, i, sb.String()) +} diff --git a/bot/handlers_setup.go b/bot/handlers_setup.go new file mode 100644 index 0000000..76eebf4 --- /dev/null +++ b/bot/handlers_setup.go @@ -0,0 +1,21 @@ +package bot + +import ( + "context" + "fmt" + + "github.com/bwmarrin/discordgo" +) + +func (b *Bot) handleSetChannel(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + if !b.isAdmin(i) { + respondEphemeral(s, i, "You need the **Manage Server** permission to use this command.") + return + } + ch := i.ApplicationCommandData().Options[0].ChannelValue(s) + if err := b.db.SetSetting(ctx, i.GuildID, settingChannel, ch.ID); err != nil { + respondEphemeral(s, i, "Error saving channel setting.") + return + } + respondEphemeral(s, i, fmt.Sprintf("✅ Now tracking distances in <#%s>.", ch.ID)) +} diff --git a/bot/handlers_social.go b/bot/handlers_social.go new file mode 100644 index 0000000..42129c1 --- /dev/null +++ b/bot/handlers_social.go @@ -0,0 +1,73 @@ +package bot + +import ( + "context" + "fmt" + "strings" + + "github.com/bwmarrin/discordgo" +) + +func (b *Bot) handleKudos(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + target := i.ApplicationCommandData().Options[0].UserValue(s) + if target.ID == i.Member.User.ID { + respondEphemeral(s, i, "You can't kudos yourself! 😄") + return + } + + fromName := displayName(i.Member, i.Member.User) + targetMember, _ := s.GuildMember(i.GuildID, target.ID) + toName := displayName(targetMember, target) + + if err := b.db.GiveKudos(ctx, i.GuildID, i.Member.User.ID, fromName, target.ID, toName); err != nil { + respondEphemeral(s, i, "Error giving kudos.") + return + } + + total, _ := b.db.GetKudosReceived(ctx, i.GuildID, target.ID) + respond(s, i, fmt.Sprintf("👏 **%s** gave kudos to **%s**! They've received %d kudos total. Keep riding! 🚴", + fromName, toName, total)) +} + +func (b *Bot) handleCompare(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + target := i.ApplicationCommandData().Options[0].UserValue(s) + meID := i.Member.User.ID + meName := displayName(i.Member, i.Member.User) + targetMember, _ := s.GuildMember(i.GuildID, target.ID) + themName := displayName(targetMember, target) + + since := b.challengeStart(ctx, i.GuildID) + meStats, _ := b.db.GetUserStats(ctx, i.GuildID, meID, since) + themStats, _ := b.db.GetUserStats(ctx, i.GuildID, target.ID, since) + mePB, _ := b.db.GetUserPB(ctx, i.GuildID, meID) + themPB, _ := b.db.GetUserPB(ctx, i.GuildID, target.ID) + meStreak, _ := b.db.GetUserStreak(ctx, meID) + themStreak, _ := b.db.GetUserStreak(ctx, target.ID) + + cmp := func(a, b float64) string { + switch { + case a > b: + return "⬆️" + case b > a: + return "⬇️" + default: + return "=" + } + } + cmpI := func(a, b int) string { return cmp(float64(a), float64(b)) } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("## ⚔️ %s vs %s\n\n", meName, themName)) + sb.WriteString(fmt.Sprintf("| | **%s** | **%s** |\n", meName, themName)) + sb.WriteString("|---|---|---|\n") + sb.WriteString(fmt.Sprintf("| Total KM %s | %.1f | %.1f |\n", + cmp(meStats.TotalKM, themStats.TotalKM), meStats.TotalKM, themStats.TotalKM)) + sb.WriteString(fmt.Sprintf("| Rides %s | %d | %d |\n", + cmpI(meStats.LogCount, themStats.LogCount), meStats.LogCount, themStats.LogCount)) + sb.WriteString(fmt.Sprintf("| Best ride %s | %.1f km | %.1f km |\n", + cmp(mePB, themPB), mePB, themPB)) + sb.WriteString(fmt.Sprintf("| Streak %s | %d days | %d days |\n", + cmpI(meStreak, themStreak), meStreak, themStreak)) + + respondEphemeral(s, i, sb.String()) +} diff --git a/db/db.go b/db/db.go new file mode 100644 index 0000000..b930421 --- /dev/null +++ b/db/db.go @@ -0,0 +1,540 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "time" + + _ "modernc.org/sqlite" +) + +type DB struct { + conn *sql.DB +} + +type UserStats struct { + UserID string + Username string + TotalKM float64 + LogCount int + LastUpdated string +} + +type RideLog struct { + ID int64 + KM float64 + MessageID string + LoggedAt time.Time +} + +type ChallengeArchive struct { + Name string + TotalKM float64 + Riders int + StartDate time.Time + EndDate time.Time +} + +func Open(path string) (*DB, error) { + conn, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("open db: %w", err) + } + d := &DB{conn: conn} + if err := d.migrate(); err != nil { + return nil, fmt.Errorf("migrate: %w", err) + } + return d, nil +} + +func (d *DB) Close() error { + return d.conn.Close() +} + +func (d *DB) migrate() error { + _, err := d.conn.Exec(` + CREATE TABLE IF NOT EXISTS distance_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL DEFAULT '', + user_id TEXT NOT NULL, + username TEXT NOT NULL, + km REAL NOT NULL, + message_id TEXT NOT NULL UNIQUE, + channel_id TEXT NOT NULL, + logged_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS settings ( + guild_id TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (guild_id, key) + ); + + CREATE TABLE IF NOT EXISTS challenge_archive ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + total_km REAL NOT NULL, + riders INTEGER NOT NULL, + start_date DATETIME NOT NULL, + end_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS user_preferences ( + user_id TEXT NOT NULL, + guild_id TEXT NOT NULL, + unit TEXT NOT NULL DEFAULT 'km', + PRIMARY KEY (user_id, guild_id) + ); + + CREATE TABLE IF NOT EXISTS kudos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL, + from_user_id TEXT NOT NULL, + from_username TEXT NOT NULL, + to_user_id TEXT NOT NULL, + to_username TEXT NOT NULL, + given_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + `) + if err != nil { + return err + } + // Non-fatal migration for existing databases + _, _ = d.conn.Exec(`ALTER TABLE distance_logs ADD COLUMN guild_id TEXT NOT NULL DEFAULT ''`) + return nil +} + +// ── Settings ───────────────────────────────────────────────────────────────── + +func (d *DB) GetSetting(ctx context.Context, guildID, key string) (string, bool, error) { + var value string + err := d.conn.QueryRowContext(ctx, + `SELECT value FROM settings WHERE guild_id = ? AND key = ?`, guildID, key, + ).Scan(&value) + if err == sql.ErrNoRows { + return "", false, nil + } + return value, err == nil, err +} + +func (d *DB) SetSetting(ctx context.Context, guildID, key, value string) error { + _, err := d.conn.ExecContext(ctx, ` + INSERT INTO settings (guild_id, key, value) VALUES (?, ?, ?) + ON CONFLICT(guild_id, key) DO UPDATE SET value = excluded.value + `, guildID, key, value) + return err +} + +func (d *DB) DeleteSetting(ctx context.Context, guildID, key string) error { + _, err := d.conn.ExecContext(ctx, + `DELETE FROM settings WHERE guild_id = ? AND key = ?`, guildID, key) + return err +} + +// ── Core Logging ────────────────────────────────────────────────────────────── + +// AddLog records a distance entry. Returns false if the message was already processed. +func (d *DB) AddLog(ctx context.Context, guildID, userID, username, messageID, channelID string, km float64) (bool, error) { + res, err := d.conn.ExecContext(ctx, ` + INSERT INTO distance_logs (guild_id, user_id, username, km, message_id, channel_id) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(message_id) DO NOTHING + `, guildID, userID, username, km, messageID, channelID) + if err != nil { + return false, fmt.Errorf("insert log: %w", err) + } + rows, _ := res.RowsAffected() + return rows > 0, nil +} + +// RemoveLog deletes a log by message ID. Returns the removed KM (0 if not found). +func (d *DB) RemoveLog(ctx context.Context, messageID string) (float64, error) { + var km float64 + err := d.conn.QueryRowContext(ctx, + `SELECT km FROM distance_logs WHERE message_id = ?`, messageID).Scan(&km) + if err == sql.ErrNoRows { + return 0, nil + } + if err != nil { + return 0, err + } + _, err = d.conn.ExecContext(ctx, `DELETE FROM distance_logs WHERE message_id = ?`, messageID) + return km, err +} + +// AdjustKM manually adds or subtracts KM for a user. +func (d *DB) AdjustKM(ctx context.Context, guildID, userID, username string, km float64) error { + _, err := d.conn.ExecContext(ctx, ` + INSERT INTO distance_logs (guild_id, user_id, username, km, message_id, channel_id) + VALUES (?, ?, ?, ?, 'manual-' || hex(randomblob(8)), 'admin') + `, guildID, userID, username, km) + return err +} + +// ── Stats Queries ───────────────────────────────────────────────────────────── + +// GetLeaderboard returns top N users by total KM within the optional since time. +func (d *DB) GetLeaderboard(ctx context.Context, guildID string, since time.Time, limit int) ([]*UserStats, error) { + q := `SELECT user_id, username, SUM(km), COUNT(*), MAX(logged_at) + FROM distance_logs WHERE guild_id = ?` + args := []interface{}{guildID} + if !since.IsZero() { + q += ` AND logged_at >= ?` + args = append(args, since) + } + q += ` GROUP BY user_id ORDER BY SUM(km) DESC LIMIT ?` + args = append(args, limit) + + rows, err := d.conn.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []*UserStats + for rows.Next() { + s := &UserStats{} + if err := rows.Scan(&s.UserID, &s.Username, &s.TotalKM, &s.LogCount, &s.LastUpdated); err != nil { + return nil, err + } + results = append(results, s) + } + return results, rows.Err() +} + +// GetTotalKM returns combined KM for a guild within the optional since time. +func (d *DB) GetTotalKM(ctx context.Context, guildID string, since time.Time) (float64, error) { + q := `SELECT COALESCE(SUM(km), 0) FROM distance_logs WHERE guild_id = ?` + args := []interface{}{guildID} + if !since.IsZero() { + q += ` AND logged_at >= ?` + args = append(args, since) + } + var total float64 + err := d.conn.QueryRowContext(ctx, q, args...).Scan(&total) + return total, err +} + +// GetUserStats returns cumulative stats for a single user within the optional since time. +func (d *DB) GetUserStats(ctx context.Context, guildID, userID string, since time.Time) (*UserStats, error) { + q := `SELECT user_id, username, COALESCE(SUM(km), 0), COUNT(*), COALESCE(MAX(logged_at), '') + FROM distance_logs WHERE guild_id = ? AND user_id = ?` + args := []interface{}{guildID, userID} + if !since.IsZero() { + q += ` AND logged_at >= ?` + args = append(args, since) + } + q += ` GROUP BY user_id, username` + + s := &UserStats{} + err := d.conn.QueryRowContext(ctx, q, args...).Scan( + &s.UserID, &s.Username, &s.TotalKM, &s.LogCount, &s.LastUpdated) + if err == sql.ErrNoRows { + return &UserStats{UserID: userID}, nil + } + return s, err +} + +// GetStatsInRange returns user stats for a specific date range (for weekly/monthly reports). +func (d *DB) GetStatsInRange(ctx context.Context, guildID string, from, to time.Time, limit int) ([]*UserStats, error) { + rows, err := d.conn.QueryContext(ctx, ` + SELECT user_id, username, SUM(km), COUNT(*), MAX(logged_at) + FROM distance_logs + WHERE guild_id = ? AND logged_at >= ? AND logged_at <= ? + GROUP BY user_id + ORDER BY SUM(km) DESC + LIMIT ? + `, guildID, from, to, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []*UserStats + for rows.Next() { + s := &UserStats{} + if err := rows.Scan(&s.UserID, &s.Username, &s.TotalKM, &s.LogCount, &s.LastUpdated); err != nil { + return nil, err + } + results = append(results, s) + } + return results, rows.Err() +} + +// GetYearTotals returns total KM per calendar year for a guild, newest first. +func (d *DB) GetYearTotals(ctx context.Context, guildID string) ([]YearTotal, error) { + rows, err := d.conn.QueryContext(ctx, ` + SELECT CAST(strftime('%Y', logged_at) AS INTEGER) as yr, SUM(km) + FROM distance_logs + WHERE guild_id = ? + GROUP BY yr + ORDER BY yr DESC + `, guildID) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []YearTotal + for rows.Next() { + var yt YearTotal + if err := rows.Scan(&yt.Year, &yt.TotalKM); err != nil { + return nil, err + } + results = append(results, yt) + } + return results, rows.Err() +} + +type YearTotal struct { + Year int + TotalKM float64 +} + +// GetYearlyLeaderboard returns the top N users for a given calendar year. +func (d *DB) GetYearlyLeaderboard(ctx context.Context, guildID string, year, limit int) ([]*UserStats, error) { + rows, err := d.conn.QueryContext(ctx, ` + SELECT user_id, username, SUM(km), COUNT(*), MAX(logged_at) + FROM distance_logs + WHERE guild_id = ? AND strftime('%Y', logged_at) = ? + GROUP BY user_id + ORDER BY SUM(km) DESC + LIMIT ? + `, guildID, fmt.Sprintf("%d", year), limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []*UserStats + for rows.Next() { + s := &UserStats{} + if err := rows.Scan(&s.UserID, &s.Username, &s.TotalKM, &s.LogCount, &s.LastUpdated); err != nil { + return nil, err + } + results = append(results, s) + } + return results, rows.Err() +} + +// GetUserYearlyStats returns a user's stats for a given calendar year. +func (d *DB) GetUserYearlyStats(ctx context.Context, guildID, userID string, year int) (*UserStats, error) { + s := &UserStats{UserID: userID} + err := d.conn.QueryRowContext(ctx, ` + SELECT user_id, username, COALESCE(SUM(km), 0), COUNT(*), COALESCE(MAX(logged_at), '') + FROM distance_logs + WHERE guild_id = ? AND user_id = ? AND strftime('%Y', logged_at) = ? + GROUP BY user_id, username + `, guildID, userID, fmt.Sprintf("%d", year)).Scan( + &s.UserID, &s.Username, &s.TotalKM, &s.LogCount, &s.LastUpdated) + if err == sql.ErrNoRows { + return s, nil + } + return s, err +} + +// ── Personal Stats ──────────────────────────────────────────────────────────── + +// GetUserHistory returns the last N ride logs for a user. +func (d *DB) GetUserHistory(ctx context.Context, guildID, userID string, limit int) ([]*RideLog, error) { + rows, err := d.conn.QueryContext(ctx, ` + SELECT id, km, message_id, logged_at + FROM distance_logs + WHERE guild_id = ? AND user_id = ? + ORDER BY logged_at DESC + LIMIT ? + `, guildID, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var logs []*RideLog + for rows.Next() { + l := &RideLog{} + var loggedAt string + if err := rows.Scan(&l.ID, &l.KM, &l.MessageID, &loggedAt); err != nil { + return nil, err + } + l.LoggedAt, _ = time.Parse("2006-01-02 15:04:05", loggedAt) + logs = append(logs, l) + } + return logs, rows.Err() +} + +// GetUserPB returns a user's personal best single-ride distance. +func (d *DB) GetUserPB(ctx context.Context, guildID, userID string) (float64, error) { + var pb float64 + err := d.conn.QueryRowContext(ctx, + `SELECT COALESCE(MAX(km), 0) FROM distance_logs WHERE guild_id = ? AND user_id = ?`, + guildID, userID).Scan(&pb) + return pb, err +} + +// GetUserStreak returns the number of consecutive days a user has logged a ride. +func (d *DB) GetUserStreak(ctx context.Context, userID string) (int, error) { + rows, err := d.conn.QueryContext(ctx, ` + SELECT DISTINCT DATE(logged_at) as ride_date + FROM distance_logs + WHERE user_id = ? + ORDER BY ride_date DESC + `, userID) + if err != nil { + return 0, err + } + defer rows.Close() + + var dates []time.Time + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + return 0, err + } + t, err := time.Parse("2006-01-02", s) + if err != nil { + continue + } + dates = append(dates, t) + } + if len(dates) == 0 { + return 0, nil + } + + today := time.Now().UTC().Truncate(24 * time.Hour) + // Streak must include today or yesterday + if dates[0].Before(today.Add(-24 * time.Hour)) { + return 0, nil + } + + streak := 1 + for i := 1; i < len(dates); i++ { + if dates[i-1].Sub(dates[i]) == 24*time.Hour { + streak++ + } else { + break + } + } + return streak, nil +} + +// ── User Preferences ────────────────────────────────────────────────────────── + +func (d *DB) GetUserPreference(ctx context.Context, userID, guildID string) (string, error) { + var unit string + err := d.conn.QueryRowContext(ctx, + `SELECT unit FROM user_preferences WHERE user_id = ? AND guild_id = ?`, + userID, guildID).Scan(&unit) + if err == sql.ErrNoRows { + return "km", nil + } + return unit, err +} + +func (d *DB) SetUserPreference(ctx context.Context, userID, guildID, unit string) error { + _, err := d.conn.ExecContext(ctx, ` + INSERT INTO user_preferences (user_id, guild_id, unit) VALUES (?, ?, ?) + ON CONFLICT(user_id, guild_id) DO UPDATE SET unit = excluded.unit + `, userID, guildID, unit) + return err +} + +// ── Kudos ───────────────────────────────────────────────────────────────────── + +func (d *DB) GiveKudos(ctx context.Context, guildID, fromUserID, fromUsername, toUserID, toUsername string) error { + _, err := d.conn.ExecContext(ctx, ` + INSERT INTO kudos (guild_id, from_user_id, from_username, to_user_id, to_username) + VALUES (?, ?, ?, ?, ?) + `, guildID, fromUserID, fromUsername, toUserID, toUsername) + return err +} + +func (d *DB) GetKudosReceived(ctx context.Context, guildID, toUserID string) (int, error) { + var count int + err := d.conn.QueryRowContext(ctx, + `SELECT COUNT(*) FROM kudos WHERE guild_id = ? AND to_user_id = ?`, + guildID, toUserID).Scan(&count) + return count, err +} + +// ── Challenge Management ────────────────────────────────────────────────────── + +// GetChallengeStart returns the start time of the current challenge period. +func (d *DB) GetChallengeStart(ctx context.Context, guildID string) (time.Time, bool, error) { + val, ok, err := d.GetSetting(ctx, guildID, "challenge_start") + if !ok || err != nil { + return time.Time{}, false, err + } + t, err := time.Parse(time.RFC3339, val) + return t, err == nil, err +} + +// ResetChallenge archives current stats and starts a new challenge period. +func (d *DB) ResetChallenge(ctx context.Context, guildID, name string) error { + challengeStart, hasPrev, _ := d.GetChallengeStart(ctx, guildID) + + // Calculate current totals before reset + total, err := d.GetTotalKM(ctx, guildID, challengeStart) + if err != nil { + return err + } + + entries, err := d.GetLeaderboard(ctx, guildID, challengeStart, 1000) + if err != nil { + return err + } + + // Archive if there was a previous period with data + if hasPrev && total > 0 { + archiveName := name + if archiveName == "" { + archiveName = "Challenge" + } + _, err = d.conn.ExecContext(ctx, ` + INSERT INTO challenge_archive (guild_id, name, total_km, riders, start_date) + VALUES (?, ?, ?, ?, ?) + `, guildID, archiveName, total, len(entries), challengeStart) + if err != nil { + return err + } + } + + // Set new challenge start + return d.SetSetting(ctx, guildID, "challenge_start", time.Now().UTC().Format(time.RFC3339)) +} + +// GetChallengeArchive returns past challenge records for a guild. +func (d *DB) GetChallengeArchive(ctx context.Context, guildID string) ([]*ChallengeArchive, error) { + rows, err := d.conn.QueryContext(ctx, ` + SELECT name, total_km, riders, start_date, end_date + FROM challenge_archive + WHERE guild_id = ? + ORDER BY end_date DESC + `, guildID) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []*ChallengeArchive + for rows.Next() { + a := &ChallengeArchive{} + var start, end string + if err := rows.Scan(&a.Name, &a.TotalKM, &a.Riders, &start, &end); err != nil { + return nil, err + } + a.StartDate, _ = time.Parse("2006-01-02 15:04:05", start) + a.EndDate, _ = time.Parse("2006-01-02 15:04:05", end) + results = append(results, a) + } + return results, rows.Err() +} + +// ── Admin ───────────────────────────────────────────────────────────────────── + +// GetUserLogs returns all ride logs for a user (for audit). +func (d *DB) GetUserLogs(ctx context.Context, guildID, userID string, limit int) ([]*RideLog, error) { + return d.GetUserHistory(ctx, guildID, userID, limit) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..10897f4 --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module cycling-discord-bot + +go 1.22 + +require ( + github.com/bwmarrin/discordgo v0.28.1 + github.com/joho/godotenv v1.5.1 + modernc.org/sqlite v1.29.10 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect + golang.org/x/sys v0.19.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.49.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..798ec3c --- /dev/null +++ b/go.sum @@ -0,0 +1,62 @@ +github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4= +github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +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/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +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/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +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 v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/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= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +modernc.org/cc/v4 v4.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk= +modernc.org/cc/v4 v4.20.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.16.0 h1:ofwORa6vx2FMm0916/CkZjpFPSR70VwTjUCe2Eg5BnA= +modernc.org/ccgo/v4 v4.16.0/go.mod h1:dkNyWIjFrVIZ68DTo36vHK+6/ShBn4ysU61So6PIqCI= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.49.3 h1:j2MRCRdwJI2ls/sGbeSk0t2bypOG/uvPZUsGQFDulqg= +modernc.org/libc v1.49.3/go.mod h1:yMZuGkn7pXbKfoT/M35gFJOAEdSKdxL0q64sF7KqCDo= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg= +modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..761e195 --- /dev/null +++ b/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/joho/godotenv" + + "cycling-discord-bot/bot" + "cycling-discord-bot/db" +) + +func main() { + _ = godotenv.Load() + + token := mustEnv("DISCORD_TOKEN") + dbPath := getEnv("DB_PATH", "cycling_bot.db") + guildID := getEnv("GUILD_ID", "") // empty = global commands (takes ~1h to propagate) + + database, err := db.Open(dbPath) + if err != nil { + log.Fatalf("open database: %v", err) + } + defer database.Close() + + b, err := bot.New(token, database, guildID) + if err != nil { + log.Fatalf("create bot: %v", err) + } + + if err := b.Open(); err != nil { + log.Fatalf("open connection: %v", err) + } + defer b.Close() + + // Give the session a moment to identify before registering commands + time.Sleep(500 * time.Millisecond) + + if err := b.RegisterCommands(); err != nil { + log.Fatalf("register commands: %v", err) + } + + log.Println("bot is running — press Ctrl+C to stop") + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + log.Println("shutting down") +} + +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("missing required env var: %s", key) + } + return v +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/parser/parser.go b/parser/parser.go new file mode 100644 index 0000000..0cd3d9c --- /dev/null +++ b/parser/parser.go @@ -0,0 +1,77 @@ +// Package parser extracts cycling distances from free-text Discord messages. +package parser + +import ( + "regexp" + "strconv" + "strings" +) + +const miToKM = 1.60934 + +var ( + // Matches: 25km, 25.5 km, 25,5km, 25KM, 25 kilometers, 25 kilometres + kmPattern = regexp.MustCompile( + `(?i)\b(\d+(?:[.,]\d+)?)\s*(?:km|kms|kilometer|kilometers|kilometre|kilometres)\b`, + ) + + // Matches standalone "k" used in cycling context: "did a 100k", "50k ride" + // Only match when followed by a word boundary and a non-unit word (ride, loop, etc.) + // or preceded by cycling verbs. + kPattern = regexp.MustCompile( + `(?i)\b(\d+(?:[.,]\d+)?)\s*k\b`, + ) + + // Matches: 25mi, 25 mi, 25 miles, 25mile + miPattern = regexp.MustCompile( + `(?i)\b(\d+(?:[.,]\d+)?)\s*(?:mi|mile|miles)\b`, + ) +) + +// ParseKM extracts the first distance found in text and returns it in KM. +// Returns 0, false if no distance could be parsed. +func ParseKM(text string) (float64, bool) { + if km, ok := firstMatch(kmPattern, text, 1.0); ok { + return km, true + } + if km, ok := firstMatch(miPattern, text, miToKM); ok { + return km, true + } + // "k" alone is ambiguous; only accept it when the message looks cycling-related + if looksLikeCycling(text) { + if km, ok := firstMatch(kPattern, text, 1.0); ok { + return km, true + } + } + return 0, false +} + +func firstMatch(re *regexp.Regexp, text string, multiplier float64) (float64, bool) { + m := re.FindStringSubmatch(text) + if m == nil { + return 0, false + } + // Normalise comma decimal separator + numStr := strings.ReplaceAll(m[1], ",", ".") + v, err := strconv.ParseFloat(numStr, 64) + if err != nil { + return 0, false + } + return v * multiplier, true +} + +var cyclingKeywords = []string{ + "ride", "rode", "cycl", "bike", "biked", "biking", "cycle", + "zwift", "strava", "trainer", "gravel", "mtb", "road", "spin", + "century", "loop", "route", "segment", "climb", "climbing", +} + +func looksLikeCycling(text string) bool { + lower := strings.ToLower(text) + for _, kw := range cyclingKeywords { + if strings.Contains(lower, kw) { + return true + } + } + return false +} diff --git a/parser/parser_test.go b/parser/parser_test.go new file mode 100644 index 0000000..f0ba0f9 --- /dev/null +++ b/parser/parser_test.go @@ -0,0 +1,41 @@ +package parser + +import ( + "math" + "testing" +) + +func TestParseKM(t *testing.T) { + tests := []struct { + input string + wantKM float64 + wantOK bool + }{ + {"Rode 25km today!", 25, true}, + {"Great 25.5 km ride", 25.5, true}, + {"Did 25,5km on Zwift", 25.5, true}, + {"50KM morning loop", 50, true}, + {"100 kilometers on the gravel bike", 100, true}, + {"30 miles today", 30 * miToKM, true}, + {"Did a 100k ride", 100, true}, + {"Went for a 80k loop", 80, true}, + // Should NOT match — no cycling context for bare "k" + {"Listened to 100k songs", 0, false}, + // No distance at all + {"Great weather today!", 0, false}, + {"PR on the climb!", 0, false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, ok := ParseKM(tt.input) + if ok != tt.wantOK { + t.Errorf("ParseKM(%q) ok=%v, want %v", tt.input, ok, tt.wantOK) + return + } + if tt.wantOK && math.Abs(got-tt.wantKM) > 0.01 { + t.Errorf("ParseKM(%q) = %.2f, want %.2f", tt.input, got, tt.wantKM) + } + }) + } +} diff --git a/rc.d/cyclingbot b/rc.d/cyclingbot new file mode 100644 index 0000000..70b4e16 --- /dev/null +++ b/rc.d/cyclingbot @@ -0,0 +1,51 @@ +#!/bin/sh +# +# PROVIDE: cyclingbot +# REQUIRE: NETWORKING +# KEYWORD: shutdown +# +# Add the following to /etc/rc.conf inside your jail to enable: +# cyclingbot_enable="YES" + +. /etc/rc.subr + +name="cyclingbot" +rcvar="cyclingbot_enable" + +desc="Cycling Discord Bot" +command="/usr/local/bin/cycling-bot" +pidfile="/var/run/${name}.pid" + +cyclingbot_enable="${cyclingbot_enable:-NO}" +cyclingbot_user="cyclingbot" +cyclingbot_chdir="/var/db/cyclingbot" + +# Use daemon(8) to daemonize the process and write a pidfile +start_cmd="${name}_start" +stop_cmd="${name}_stop" + +cyclingbot_start() +{ + echo "Starting ${desc}." + /usr/sbin/daemon \ + -u "${cyclingbot_user}" \ + -p "${pidfile}" \ + -o /var/log/cyclingbot.log \ + /bin/sh -c "cd ${cyclingbot_chdir} && exec ${command}" +} + +cyclingbot_stop() +{ + echo "Stopping ${desc}." + if [ ! -f "${pidfile}" ]; then + echo "${name} is not running." + return 0 + fi + pid=$(cat "${pidfile}") + kill -TERM "${pid}" 2>/dev/null + wait_for_pids "${pid}" + rm -f "${pidfile}" +} + +load_rc_config "${name}" +run_rc_command "$1"