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