add lookback on start, added parsing for ','s

This commit is contained in:
Blake Ridgway
2026-04-25 19:17:58 -05:00
parent b6ef3a73f2
commit 020a4139b3
4 changed files with 118 additions and 5 deletions

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -144,6 +145,85 @@ func (b *Bot) RegisterCommands() error {
func (b *Bot) onReady(s *discordgo.Session, r *discordgo.Ready) { func (b *Bot) onReady(s *discordgo.Session, r *discordgo.Ready) {
log.Printf("logged in as %s#%s", r.User.Username, r.User.Discriminator) log.Printf("logged in as %s#%s", r.User.Username, r.User.Discriminator)
_ = s.UpdateGameStatus(0, "tracking your KMs 🚴") _ = 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) { func (b *Bot) onMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) {

View File

@@ -514,6 +514,26 @@ func (d *DB) GetChallengeArchive(ctx context.Context, guildID string) ([]*Challe
return results, rows.Err() 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 ───────────────────────────────────────────────────────────────────── // ── Admin ─────────────────────────────────────────────────────────────────────
func (d *DB) GetUserLogs(ctx context.Context, guildID, userID string, limit int) ([]*RideLog, error) { func (d *DB) GetUserLogs(ctx context.Context, guildID, userID string, limit int) ([]*RideLog, error) {

View File

@@ -9,22 +9,25 @@ import (
const miToKM = 1.60934 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 ( var (
// Matches: 25km, 25.5 km, 25,5km, 25KM, 25 kilometers, 25 kilometres // Matches: 25km, 25.5 km, 25,5km, 25KM, 25 kilometers, 25 kilometres
kmPattern = regexp.MustCompile( 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" // 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.) // Only match when followed by a word boundary and a non-unit word (ride, loop, etc.)
// or preceded by cycling verbs. // or preceded by cycling verbs.
kPattern = regexp.MustCompile( 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 // Matches: 25mi, 25 mi, 25 miles, 25mile
miPattern = regexp.MustCompile( 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 { if m == nil {
return 0, false return 0, false
} }
// Normalise comma decimal separator numStr := m[1]
numStr := strings.ReplaceAll(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) v, err := strconv.ParseFloat(numStr, 64)
if err != nil { if err != nil {
return 0, false return 0, false

View File

@@ -19,6 +19,8 @@ func TestParseKM(t *testing.T) {
{"30 miles today", 30 * miToKM, true}, {"30 miles today", 30 * miToKM, true},
{"Did a 100k ride", 100, true}, {"Did a 100k ride", 100, true},
{"Went for a 80k loop", 80, 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" // Should NOT match — no cycling context for bare "k"
{"Listened to 100k songs", 0, false}, {"Listened to 100k songs", 0, false},
// No distance at all // No distance at all