first commit

This commit is contained in:
Blake Ridgway
2026-04-11 14:06:59 -05:00
commit ba1770b493
21 changed files with 2027 additions and 0 deletions

9
.env.example Normal file
View File

@@ -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

19
.gitignore vendored Normal file
View File

@@ -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

66
Makefile Normal file
View File

@@ -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)

61
README.md Normal file
View File

@@ -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 |

87
assets/avatar.svg Normal file
View File

@@ -0,0 +1,87 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<radialGradient id="bg" cx="38%" cy="35%" r="75%">
<stop offset="0%" stop-color="#1a2a4a"/>
<stop offset="100%" stop-color="#060d1a"/>
</radialGradient>
</defs>
<!-- Background -->
<circle cx="256" cy="256" r="256" fill="url(#bg)"/>
<!-- Subtle border ring -->
<circle cx="256" cy="256" r="244" fill="none" stroke="#00d4ff" stroke-width="1.5" opacity="0.35"/>
<!-- ═══════════════════════════════════════ -->
<!-- BICYCLE -->
<!-- ═══════════════════════════════════════ -->
<!-- Wheels — clean rings, no spokes -->
<circle cx="152" cy="318" r="86" fill="none" stroke="#00d4ff" stroke-width="17"/>
<circle cx="360" cy="318" r="86" fill="none" stroke="#00d4ff" stroke-width="17"/>
<!-- Hubs -->
<circle cx="152" cy="318" r="9" fill="#00d4ff"/>
<circle cx="360" cy="318" r="9" fill="#00d4ff"/>
<!-- ── Frame ── -->
<!-- Chain stay (rear hub → BB) -->
<line x1="152" y1="318" x2="252" y2="288"
stroke="#ffffff" stroke-width="13" stroke-linecap="round"/>
<!-- Seat tube (BB → seat junction) -->
<line x1="252" y1="288" x2="228" y2="196"
stroke="#ffffff" stroke-width="13" stroke-linecap="round"/>
<!-- Top tube (seat junction → head tube) -->
<line x1="228" y1="196" x2="324" y2="210"
stroke="#ffffff" stroke-width="11" stroke-linecap="round"/>
<!-- Down tube (head tube → BB) -->
<line x1="324" y1="210" x2="252" y2="288"
stroke="#ffffff" stroke-width="13" stroke-linecap="round"/>
<!-- Seat stay (seat junction → rear hub) -->
<line x1="228" y1="196" x2="152" y2="318"
stroke="#ffffff" stroke-width="10" stroke-linecap="round"/>
<!-- Head tube -->
<line x1="324" y1="210" x2="330" y2="246"
stroke="#ffffff" stroke-width="14" stroke-linecap="round"/>
<!-- Fork (head tube → front hub) -->
<line x1="330" y1="246" x2="360" y2="318"
stroke="#ffffff" stroke-width="11" stroke-linecap="round"/>
<!-- ── Cockpit ── -->
<!-- Seat post -->
<line x1="228" y1="196" x2="224" y2="172"
stroke="#ffffff" stroke-width="11" stroke-linecap="round"/>
<!-- Saddle -->
<rect x="200" y="165" width="54" height="11" rx="5.5" fill="#ffffff"/>
<!-- Stem -->
<line x1="324" y1="210" x2="328" y2="184"
stroke="#ffffff" stroke-width="11" stroke-linecap="round"/>
<!-- Drop handlebar -->
<path d="M 312,184 L 344,184 Q 354,184 354,196 L 354,210"
fill="none" stroke="#ffffff" stroke-width="10"
stroke-linecap="round" stroke-linejoin="round"/>
<!-- ── Drivetrain ── -->
<!-- Bottom bracket -->
<circle cx="252" cy="288" r="15" fill="#00d4ff"/>
<!-- Crank arm -->
<line x1="252" y1="288" x2="234" y2="308"
stroke="#00d4ff" stroke-width="9" stroke-linecap="round"/>
<!-- Pedal -->
<rect x="220" y="305" width="24" height="8" rx="3" fill="#00d4ff"/>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

370
bot/bot.go Normal file
View File

@@ -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:]
}

81
bot/format.go Normal file
View File

@@ -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"
}

76
bot/handlers_admin.go Normal file
View File

@@ -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())
}

48
bot/handlers_challenge.go Normal file
View File

@@ -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))
}

View File

@@ -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()
}

118
bot/handlers_personal.go Normal file
View File

@@ -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))
}

43
bot/handlers_reports.go Normal file
View File

@@ -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())
}

21
bot/handlers_setup.go Normal file
View File

@@ -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))
}

73
bot/handlers_social.go Normal file
View File

@@ -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())
}

540
db/db.go Normal file
View File

@@ -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)
}

27
go.mod Normal file
View File

@@ -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
)

62
go.sum Normal file
View File

@@ -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=

66
main.go Normal file
View File

@@ -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
}

77
parser/parser.go Normal file
View File

@@ -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
}

41
parser/parser_test.go Normal file
View File

@@ -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)
}
})
}
}

51
rc.d/cyclingbot Normal file
View File

@@ -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"