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:]
|
||||
}
|
||||
Reference in New Issue
Block a user