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

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