first commit
This commit is contained in:
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())
|
||||
}
|
||||
Reference in New Issue
Block a user