first commit
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal 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
19
.gitignore
vendored
Normal 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
66
Makefile
Normal 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
61
README.md
Normal 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
87
assets/avatar.svg
Normal 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
370
bot/bot.go
Normal 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
81
bot/format.go
Normal 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
76
bot/handlers_admin.go
Normal 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
48
bot/handlers_challenge.go
Normal 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))
|
||||||
|
}
|
||||||
91
bot/handlers_leaderboard.go
Normal file
91
bot/handlers_leaderboard.go
Normal 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
118
bot/handlers_personal.go
Normal 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
43
bot/handlers_reports.go
Normal 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
21
bot/handlers_setup.go
Normal 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
73
bot/handlers_social.go
Normal 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
540
db/db.go
Normal 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
27
go.mod
Normal 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
62
go.sum
Normal 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
66
main.go
Normal 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
77
parser/parser.go
Normal 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
41
parser/parser_test.go
Normal 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
51
rc.d/cyclingbot
Normal 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"
|
||||||
Reference in New Issue
Block a user