package bot import ( "context" "fmt" "log" "strconv" "strings" "sync" "time" "github.com/bwmarrin/discordgo" "cycling-discord-bot/db" "cycling-discord-bot/parser" ) const ( reactionOK = "✅" settingChannel = "fitness_channel_id" ) type Bot struct { session *discordgo.Session db *db.DB guildID string version string topicMu sync.Mutex topicTimer *time.Timer } func New(token string, database *db.DB, guildID, version 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, version: version} 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"}, // ── Utility ────────────────────────────────────────────────────────── {Name: "version", Description: "Show the bot's build version"}, // ── 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 🚴") go b.recoverMissingLogs(context.Background()) } func (b *Bot) recoverMissingLogs(ctx context.Context) { channels, err := b.db.GetAllChannelSettings(ctx) if err != nil { log.Printf("recoverMissingLogs: get channels: %v", err) return } cutoff := time.Now().Add(-7 * 24 * time.Hour) for guildID, channelID := range channels { recovered := 0 beforeID := "" for { msgs, err := b.session.ChannelMessages(channelID, 100, beforeID, "", "") if err != nil { log.Printf("recoverMissingLogs: fetch messages %s: %v", channelID, err) break } if len(msgs) == 0 { break } done := false for _, msg := range msgs { if msg.Author == nil || msg.Author.Bot { continue } if snowflakeTime(msg.ID).Before(cutoff) { done = true continue } if hasCheckmark(msg) { continue } km, ok := parser.ParseKM(msg.Content) if !ok { continue } added, err := b.db.AddLog(ctx, guildID, msg.Author.ID, displayName(msg.Member, msg.Author), msg.ID, channelID, km) if err != nil { log.Printf("recoverMissingLogs: AddLog: %v", err) continue } if added { _ = b.session.MessageReactionAdd(channelID, msg.ID, reactionOK) recovered++ log.Printf("recoverMissingLogs: recovered %.2f km from %s", km, msg.Author.Username) } } if done { break } beforeID = msgs[len(msgs)-1].ID } log.Printf("recoverMissingLogs: guild %s done, recovered %d rides", guildID, recovered) } } func snowflakeTime(id string) time.Time { sf, err := strconv.ParseInt(id, 10, 64) if err != nil { return time.Time{} } return time.Unix(0, ((sf>>22)+1420070400000)*int64(time.Millisecond)) } func hasCheckmark(msg *discordgo.Message) bool { for _, r := range msg.Reactions { if r.Emoji.Name == reactionOK && r.Me { return true } } return false } func (b *Bot) onMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { if m.Author == nil || m.Author.Bot { return } // Skip messages the bot has already reacted to. // This prevents duplicate reactions from Discord reconnection event replays. if hasCheckmark(m.Message) { 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 { log.Printf("duplicate ride detected for %s, skipping reaction", m.Author.Username) } } 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 "version": b.handleVersion(ctx, s, i) case "addkm": b.handleAddKM(ctx, s, i) case "removelog": b.handleRemoveLog(ctx, s, i) case "audit": b.handleAudit(ctx, s, i) } } // ── Version ─────────────────────────────────────────────────────────────────── func (b *Bot) handleVersion(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { respond(s, i, fmt.Sprintf("Cycling Bot version **%s**", b.version)) } // ── 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:] }