From 020a4139b314e83b0e7d5e60ccd5ccd117bab6be Mon Sep 17 00:00:00 2001 From: Blake Ridgway Date: Sat, 25 Apr 2026 19:17:58 -0500 Subject: [PATCH] add lookback on start, added parsing for ','s --- bot/bot.go | 80 +++++++++++++++++++++++++++++++++++++++++++ db/db.go | 20 +++++++++++ parser/parser.go | 21 +++++++++--- parser/parser_test.go | 2 ++ 4 files changed, 118 insertions(+), 5 deletions(-) diff --git a/bot/bot.go b/bot/bot.go index cb487f3..24a1e7b 100644 --- a/bot/bot.go +++ b/bot/bot.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + "strconv" "strings" "sync" "time" @@ -144,6 +145,85 @@ func (b *Bot) RegisterCommands() error { 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) { diff --git a/db/db.go b/db/db.go index 9e17a82..80483c9 100644 --- a/db/db.go +++ b/db/db.go @@ -514,6 +514,26 @@ func (d *DB) GetChallengeArchive(ctx context.Context, guildID string) ([]*Challe return results, rows.Err() } +// GetAllChannelSettings returns a map of guildID -> channelID for all configured fitness channels. +func (d *DB) GetAllChannelSettings(ctx context.Context) (map[string]string, error) { + rows, err := d.conn.QueryContext(ctx, + `SELECT guild_id, value FROM settings WHERE key = 'fitness_channel_id'`) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make(map[string]string) + for rows.Next() { + var guildID, channelID string + if err := rows.Scan(&guildID, &channelID); err != nil { + return nil, err + } + result[guildID] = channelID + } + return result, rows.Err() +} + // ── Admin ───────────────────────────────────────────────────────────────────── func (d *DB) GetUserLogs(ctx context.Context, guildID, userID string, limit int) ([]*RideLog, error) { diff --git a/parser/parser.go b/parser/parser.go index 0cd3d9c..38d837b 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -9,22 +9,25 @@ import ( const miToKM = 1.60934 +// thousandsRe detects numbers where comma is a thousands separator (e.g. 1,000 or 5,981.9) +var thousandsRe = regexp.MustCompile(`^\d{1,3}(,\d{3})+`) + var ( // Matches: 25km, 25.5 km, 25,5km, 25KM, 25 kilometers, 25 kilometres kmPattern = regexp.MustCompile( - `(?i)\b(\d+(?:[.,]\d+)?)\s*(?:km|kms|kilometer|kilometers|kilometre|kilometres)\b`, + `(?i)\b(\d{1,3}(?:,\d{3})*(?:\.\d+)?|\d+(?:[.,]\d+)?)\s*(?:km|kms|kilometer|kilometers|kilometre|kilometres)\b`, ) // Matches standalone "k" used in cycling context: "did a 100k", "50k ride" // Only match when followed by a word boundary and a non-unit word (ride, loop, etc.) // or preceded by cycling verbs. kPattern = regexp.MustCompile( - `(?i)\b(\d+(?:[.,]\d+)?)\s*k\b`, + `(?i)\b(\d{1,3}(?:,\d{3})*(?:\.\d+)?|\d+(?:[.,]\d+)?)\s*k\b`, ) // Matches: 25mi, 25 mi, 25 miles, 25mile miPattern = regexp.MustCompile( - `(?i)\b(\d+(?:[.,]\d+)?)\s*(?:mi|mile|miles)\b`, + `(?i)\b(\d{1,3}(?:,\d{3})*(?:\.\d+)?|\d+(?:[.,]\d+)?)\s*(?:mi|mile|miles)\b`, ) ) @@ -51,8 +54,16 @@ func firstMatch(re *regexp.Regexp, text string, multiplier float64) (float64, bo if m == nil { return 0, false } - // Normalise comma decimal separator - numStr := strings.ReplaceAll(m[1], ",", ".") + numStr := m[1] + if strings.Contains(numStr, ",") { + if thousandsRe.MatchString(numStr) { + // e.g. "1,000" or "5,981.9" — comma is thousands separator + numStr = strings.ReplaceAll(numStr, ",", "") + } else { + // e.g. "25,5" — comma is decimal separator + numStr = strings.ReplaceAll(numStr, ",", ".") + } + } v, err := strconv.ParseFloat(numStr, 64) if err != nil { return 0, false diff --git a/parser/parser_test.go b/parser/parser_test.go index f0ba0f9..7519cd0 100644 --- a/parser/parser_test.go +++ b/parser/parser_test.go @@ -19,6 +19,8 @@ func TestParseKM(t *testing.T) { {"30 miles today", 30 * miToKM, true}, {"Did a 100k ride", 100, true}, {"Went for a 80k loop", 80, true}, + {"5,981.9 miles", 5981.9 * miToKM, true}, + {"1,000km ride", 1000, true}, // Should NOT match — no cycling context for bare "k" {"Listened to 100k songs", 0, false}, // No distance at all