diff --git a/README.md b/README.md index f50f737..8ce09c0 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,12 @@ A high-performance Slack bot written in Go that automatically tracks Wordle scor - šŸ“Š **Daily Summaries**: Posts a configurable morning summary of yesterday's results - šŸ† **Leaderboard**: Shows who solved the puzzle and their scores - šŸ“ˆ **Statistics**: Calculates success rate and average scores +- šŸ”„ **Streaks**: Track individual and group solve streaks +- šŸ“… **Weekly Reports**: View the last 7 days of results +- šŸ‘¤ **Personal Stats**: See your own average, win rate, and distribution +- šŸ›”ļø **Anti-Cheat**: Validates puzzle numbers and prevents duplicate submissions - āš™ļø **Configurable**: Set custom summary times and channels -- āœ… **Reaction Confirmation**: Adds a checkmark reaction when it tracks a score +- šŸ’¬ **Thread Replies**: Replies with updated standings when you post a score - šŸš€ **High Performance**: Written in Go for minimal resource usage ## Quick Start @@ -26,11 +30,13 @@ This project follows standard Go project layout: - `internal/` - Private application packages (bot, config, tracker) - `pkg/` - Public library code (reusable pattern matching) +See [PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md) for detailed documentation. + ### Build and Run ```bash # Clone the repository -git clone https://git.ewnix.net/phlux/slack-wordle-scores.git +git clone cd wordle-bot # Download dependencies and generate go.sum @@ -47,7 +53,7 @@ export SLACK_APP_TOKEN="xapp-your-app-token" ./wordle-bot ``` -Or use the included Makefile: +Or use the included Makefile (handles `go mod tidy` automatically): ```bash make build @@ -157,8 +163,15 @@ Wordle 1662 5/6 The bot will automatically: - Detect the score +- Validate the puzzle number (must be today's puzzle ±1 day for timezones) +- Check for duplicates (only one score per person per day) - Track it for daily summary -- Add a āœ… reaction to confirm +- **Reply in thread** with confirmation and updated standings + +**Anti-Cheat Reactions:** +- Thread reply with āœ… = Score accepted and tracked (includes today's standings) +- āŒ reaction = Invalid puzzle number (not today's Wordle) +- šŸ” reaction = Duplicate (you already posted today) ### Slash Commands @@ -172,6 +185,21 @@ The bot will automatically: /wordle-stats yesterday ``` +**View last 7 days:** +``` +/wordle-stats week +``` + +**View all-time leaderboard:** +``` +/wordle-stats leaderboard +``` + +**View your personal stats (with streaks!):** +``` +/wordle-stats me +``` + **Configure summary time:** ``` /wordle-config time 09:00 diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 0936d7d..8dcef8d 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -77,11 +77,14 @@ func (b *Bot) Run() error { func (b *Bot) handleEvents(client *socketmode.Client) { for evt := range client.Events { + log.Printf("DEBUG: Received event type: %s", evt.Type) switch evt.Type { case socketmode.EventTypeEventsAPI: b.handleEventsAPI(client, evt) case socketmode.EventTypeSlashCommand: b.handleSlashCommand(client, evt) + default: + log.Printf("DEBUG: Unhandled event type: %s", evt.Type) } } } @@ -89,16 +92,21 @@ func (b *Bot) handleEvents(client *socketmode.Client) { func (b *Bot) handleEventsAPI(client *socketmode.Client, evt socketmode.Event) { eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) if !ok { - log.Printf("Ignored %+v\n", evt) + log.Printf("DEBUG: Could not cast to EventsAPIEvent: %+v\n", evt) return } + log.Printf("DEBUG: EventsAPI type: %s", eventsAPIEvent.Type) + client.Ack(*evt.Request) if eventsAPIEvent.Type == slackevents.CallbackEvent { innerEvent := eventsAPIEvent.InnerEvent + log.Printf("DEBUG: InnerEvent type: %s", innerEvent.Type) if msgEvent, ok := innerEvent.Data.(*slackevents.MessageEvent); ok { b.handleMessage(msgEvent) + } else { + log.Printf("DEBUG: InnerEvent was not a MessageEvent: %T", innerEvent.Data) } } } @@ -115,6 +123,37 @@ func (b *Bot) handleMessage(ev *slackevents.MessageEvent) { return } + // Convert timestamp + tsFloat, _ := strconv.ParseFloat(ev.TimeStamp, 64) + timestamp := time.Unix(int64(tsFloat), 0) + + // Validate puzzle number (allow ±1 day for timezones) + if !wordlepattern.IsValidPuzzleNumber(match.PuzzleNumber, timestamp) { + log.Printf("Invalid puzzle number %d for date %s (expected ~%d)", + match.PuzzleNumber, + timestamp.Format("2006-01-02"), + wordlepattern.GetExpectedPuzzleNumber(timestamp)) + + // React with āŒ to indicate invalid score + b.client.AddReaction("x", slack.ItemRef{ + Channel: ev.Channel, + Timestamp: ev.TimeStamp, + }) + return + } + + // Check if user already posted today + if b.tracker.HasPostedToday(ev.User) { + log.Printf("User %s already posted a score today, ignoring duplicate", ev.User) + + // React with šŸ” to indicate duplicate + b.client.AddReaction("repeat", slack.ItemRef{ + Channel: ev.Channel, + Timestamp: ev.TimeStamp, + }) + return + } + // Get user info user, err := b.client.GetUserInfo(ev.User) if err != nil { @@ -127,10 +166,6 @@ func (b *Bot) handleMessage(ev *slackevents.MessageEvent) { userName = user.Name } - // Convert timestamp - tsFloat, _ := strconv.ParseFloat(ev.TimeStamp, 64) - timestamp := time.Unix(int64(tsFloat), 0) - // Track the score if err := b.tracker.Add(ev.User, userName, match.PuzzleNumber, match.Score, timestamp); err != nil { log.Printf("Error tracking score: %v", err) @@ -139,12 +174,35 @@ func (b *Bot) handleMessage(ev *slackevents.MessageEvent) { log.Printf("Tracked score for %s: Wordle %d %s/6", userName, match.PuzzleNumber, match.Score) - // React to the message - if err := b.client.AddReaction("white_check_mark", slack.ItemRef{ - Channel: ev.Channel, - Timestamp: ev.TimeStamp, - }); err != nil { - log.Printf("Error adding reaction: %v", err) + // Get updated today's scores + todayScores := b.tracker.GetToday() + + // Update with current usernames + tracker.UpdateDailyScoresNames(todayScores, func(userID string) string { + u, err := b.client.GetUserInfo(userID) + if err != nil { + return "" + } + if u.RealName != "" { + return u.RealName + } + return u.Name + }) + + // Format a nice response + var response strings.Builder + response.WriteString(fmt.Sprintf("āœ… Score recorded for *%s*: %s/6\n\n", userName, match.Score)) + response.WriteString(tracker.FormatToday(todayScores)) + + // Reply in thread + _, _, err = b.client.PostMessage( + ev.Channel, + slack.MsgOptionText(response.String(), false), + slack.MsgOptionTS(ev.TimeStamp), // Reply in thread + ) + + if err != nil { + log.Printf("Error posting thread reply: %v", err) } } @@ -232,17 +290,61 @@ Summary Channel: %s`, cfg.SummaryTime, channelText), nil func (b *Bot) handleStatsCommand(cmd slack.SlashCommand) (string, error) { args := strings.TrimSpace(cmd.Text) + // Helper function to get current username + getCurrentName := func(userID string) string { + user, err := b.client.GetUserInfo(userID) + if err != nil { + return "" // Keep stored name + } + if user.RealName != "" { + return user.RealName + } + return user.Name + } + switch args { case "", "yesterday": scores := b.tracker.GetYesterday() + tracker.UpdateDailyScoresNames(scores, getCurrentName) return tracker.FormatSummary(scores), nil case "today": scores := b.tracker.GetToday() + tracker.UpdateDailyScoresNames(scores, getCurrentName) return tracker.FormatToday(scores), nil + case "week": + weekScores := b.tracker.GetWeek() + // Update names for each day + for _, dailyScores := range weekScores { + tracker.UpdateDailyScoresNames(dailyScores, getCurrentName) + } + return tracker.FormatWeek(weekScores), nil + + case "leaderboard", "lb": + entries := b.tracker.GetLeaderboard() + tracker.UpdateLeaderboardNames(entries, getCurrentName) + return tracker.FormatLeaderboard(entries, 10), nil + + case "me", "my", "mystats": + stats := b.tracker.GetUserStats(cmd.UserID) + groupStreak := b.tracker.GetGroupStreak() + + // Get current user name + stats.UserName = getCurrentName(cmd.UserID) + if stats.UserName == "" { + stats.UserName = "You" + } + + return tracker.FormatUserStats(stats, groupStreak), nil + default: - return "Usage: `/wordle-stats` or `/wordle-stats yesterday` or `/wordle-stats today`", nil + return `Usage: +` + "`/wordle-stats`" + ` or ` + "`/wordle-stats yesterday`" + ` - Yesterday's results +` + "`/wordle-stats today`" + ` - Today's scores so far +` + "`/wordle-stats week`" + ` - Last 7 days summary +` + "`/wordle-stats leaderboard`" + ` - All-time leaderboard +` + "`/wordle-stats me`" + ` - Your personal statistics`, nil } } @@ -287,6 +389,19 @@ func (b *Bot) postDailySummary() { } yesterdayScores := b.tracker.GetYesterday() + + // Update with current usernames + tracker.UpdateDailyScoresNames(yesterdayScores, func(userID string) string { + user, err := b.client.GetUserInfo(userID) + if err != nil { + return "" + } + if user.RealName != "" { + return user.RealName + } + return user.Name + }) + summary := tracker.FormatSummary(yesterdayScores) _, _, err := b.client.PostMessage( diff --git a/internal/tracker/tracker.go b/internal/tracker/tracker.go index 7c46bd2..4226b4e 100644 --- a/internal/tracker/tracker.go +++ b/internal/tracker/tracker.go @@ -3,6 +3,7 @@ package tracker import ( "encoding/json" "fmt" + "math" "os" "sort" "strconv" @@ -20,6 +21,29 @@ type Score struct { Timestamp time.Time `json:"timestamp"` } +// UserStats represents statistics for a single user +type UserStats struct { + UserID string + UserName string + TotalPlayed int + TotalSolved int + AverageScore float64 + ScoreDistribution map[string]int // "3" -> count, "4" -> count, etc. + CurrentStreak int + LongestStreak int +} + +// LeaderboardEntry represents a user's stats for the leaderboard +type LeaderboardEntry struct { + UserID string + UserName string + TotalPlayed int + TotalSolved int + TotalScore int + AverageScore float64 + WinRate float64 +} + // DailyScores maps user IDs to their scores for a specific date type DailyScores map[string]Score @@ -88,6 +112,16 @@ func (t *Tracker) Add(userID, userName string, puzzleNumber int, score string, t return t.save() } +// HasPostedToday checks if a user has already posted a score today +func (t *Tracker) HasPostedToday(userID string) bool { + today := time.Now().Format("2006-01-02") + if dailyScores, exists := t.scores[today]; exists { + _, hasPosted := dailyScores[userID] + return hasPosted + } + return false +} + // GetForDate retrieves all scores for a specific date func (t *Tracker) GetForDate(date string) DailyScores { scores, ok := t.scores[date] @@ -109,6 +143,202 @@ func (t *Tracker) GetToday() DailyScores { return t.GetForDate(today) } +// GetWeek retrieves scores from the last 7 days +func (t *Tracker) GetWeek() map[string]DailyScores { + result := make(map[string]DailyScores) + for i := 0; i < 7; i++ { + date := time.Now().AddDate(0, 0, -i).Format("2006-01-02") + if scores := t.GetForDate(date); len(scores) > 0 { + result[date] = scores + } + } + return result +} + +// GetAllDates returns all dates that have scores +func (t *Tracker) GetAllDates() []string { + dates := make([]string, 0, len(t.scores)) + for date := range t.scores { + dates = append(dates, date) + } + sort.Strings(dates) + return dates +} + +// GetUserStats returns statistics for a specific user +func (t *Tracker) GetUserStats(userID string) *UserStats { + stats := &UserStats{ + UserID: userID, + TotalPlayed: 0, + TotalSolved: 0, + ScoreDistribution: make(map[string]int), + CurrentStreak: 0, + LongestStreak: 0, + } + + dates := t.GetAllDates() + if len(dates) == 0 { + return stats + } + + // Calculate totals and distribution + var totalScore int + for _, date := range dates { + if score, exists := t.scores[date][userID]; exists { + stats.TotalPlayed++ + stats.ScoreDistribution[score.Score]++ + + if score.Score != "X" { + stats.TotalSolved++ + val, _ := strconv.Atoi(score.Score) + totalScore += val + } + } + } + + if stats.TotalSolved > 0 { + stats.AverageScore = float64(totalScore) / float64(stats.TotalSolved) + } + + // Calculate streaks + currentStreak := 0 + longestStreak := 0 + + // Start from most recent and work backwards for current streak + for i := len(dates) - 1; i >= 0; i-- { + if score, exists := t.scores[dates[i]][userID]; exists && score.Score != "X" { + currentStreak++ + } else if currentStreak > 0 { + break // Current streak ended + } + } + stats.CurrentStreak = currentStreak + + // Calculate longest streak + streak := 0 + for _, date := range dates { + if score, exists := t.scores[date][userID]; exists && score.Score != "X" { + streak++ + if streak > longestStreak { + longestStreak = streak + } + } else { + streak = 0 + } + } + stats.LongestStreak = longestStreak + + return stats +} + +// GetLeaderboard returns all-time leaderboard statistics +func (t *Tracker) GetLeaderboard() []*LeaderboardEntry { + userMap := make(map[string]*LeaderboardEntry) + + // Collect all users and their stats + for _, dailyScores := range t.scores { + for userID, score := range dailyScores { + if _, exists := userMap[userID]; !exists { + userMap[userID] = &LeaderboardEntry{ + UserID: userID, + UserName: score.UserName, // Use stored name as fallback + } + } + + entry := userMap[userID] + entry.TotalPlayed++ + + if score.Score != "X" { + entry.TotalSolved++ + val, _ := strconv.Atoi(score.Score) + entry.TotalScore += val + } + } + } + + // Calculate averages and convert to slice + entries := make([]*LeaderboardEntry, 0, len(userMap)) + for _, entry := range userMap { + if entry.TotalSolved > 0 { + entry.AverageScore = float64(entry.TotalScore) / float64(entry.TotalSolved) + } + entry.WinRate = float64(entry.TotalSolved) / float64(entry.TotalPlayed) * 100 + entries = append(entries, entry) + } + + // Sort by average score (lower is better), then by win rate + sort.Slice(entries, func(i, j int) bool { + if entries[i].TotalSolved == 0 && entries[j].TotalSolved == 0 { + return entries[i].WinRate > entries[j].WinRate + } + if entries[i].TotalSolved == 0 { + return false + } + if entries[j].TotalSolved == 0 { + return true + } + if math.Abs(entries[i].AverageScore-entries[j].AverageScore) < 0.01 { + return entries[i].WinRate > entries[j].WinRate + } + return entries[i].AverageScore < entries[j].AverageScore + }) + + return entries +} + +// UpdateLeaderboardNames updates the display names in leaderboard entries +// using the provided name resolver function +func UpdateLeaderboardNames(entries []*LeaderboardEntry, nameResolver func(userID string) string) { + for _, entry := range entries { + if name := nameResolver(entry.UserID); name != "" { + entry.UserName = name + } + } +} + +// UpdateDailyScoresNames updates the display names in daily scores +// using the provided name resolver function +func UpdateDailyScoresNames(scores DailyScores, nameResolver func(userID string) string) { + for userID, score := range scores { + if name := nameResolver(userID); name != "" { + score.UserName = name + scores[userID] = score + } + } +} + +// GetGroupStreak returns the current group streak (consecutive days with at least one score) +func (t *Tracker) GetGroupStreak() int { + dates := t.GetAllDates() + if len(dates) == 0 { + return 0 + } + + streak := 0 + today := time.Now().Format("2006-01-02") + + // Check backwards from today + for i := 0; ; i++ { + date := time.Now().AddDate(0, 0, -i).Format("2006-01-02") + if scores := t.GetForDate(date); len(scores) > 0 { + streak++ + } else if date == today { + // No scores today yet, that's ok, continue + continue + } else { + // Streak broken + break + } + + // Stop if we've gone through all our data + if date < dates[0] { + break + } + } + + return streak +} + // ScoreEntry is used for sorting type ScoreEntry struct { UserID string @@ -214,6 +444,152 @@ func FormatToday(scores DailyScores) string { return strings.Join(lines, "\n") } +// FormatWeek formats the last 7 days of scores +func FormatWeek(weekScores map[string]DailyScores) string { + if len(weekScores) == 0 { + return "No scores posted in the last 7 days." + } + + var lines []string + lines = append(lines, "šŸ“… *Last 7 Days Summary* šŸ“…\n") + + // Get sorted dates (most recent first) + dates := make([]string, 0, len(weekScores)) + for date := range weekScores { + dates = append(dates, date) + } + sort.Sort(sort.Reverse(sort.StringSlice(dates))) + + totalPlayers := make(map[string]bool) + totalSolved := 0 + totalAttempts := 0 + + for _, date := range dates { + scores := weekScores[date] + solved := 0 + + for userID, score := range scores { + totalPlayers[userID] = true + if score.Score != "X" { + solved++ + totalSolved++ + } + totalAttempts++ + } + + // Format date nicely + t, _ := time.Parse("2006-01-02", date) + dateStr := t.Format("Mon, Jan 2") + + lines = append(lines, fmt.Sprintf("*%s*: %d played, %d solved", dateStr, len(scores), solved)) + } + + lines = append(lines, fmt.Sprintf("\nšŸ“ˆ *Week Stats*:")) + lines = append(lines, fmt.Sprintf("• Unique players: %d", len(totalPlayers))) + lines = append(lines, fmt.Sprintf("• Total puzzles: %d", totalAttempts)) + lines = append(lines, fmt.Sprintf("• Solved: %d (%.1f%%)", totalSolved, float64(totalSolved)/float64(totalAttempts)*100)) + + return strings.Join(lines, "\n") +} + +// FormatLeaderboard formats the all-time leaderboard +func FormatLeaderboard(entries []*LeaderboardEntry, limit int) string { + if len(entries) == 0 { + return "No leaderboard data available yet." + } + + var lines []string + lines = append(lines, "šŸ† *All-Time Leaderboard* šŸ†\n") + + if limit > len(entries) { + limit = len(entries) + } + + for i := 0; i < limit; i++ { + entry := entries[i] + medal := "" + switch i { + case 0: + medal = "šŸ„‡" + case 1: + medal = "🄈" + case 2: + medal = "šŸ„‰" + default: + medal = fmt.Sprintf("%d.", i+1) + } + + if entry.TotalSolved > 0 { + lines = append(lines, fmt.Sprintf("%s *%s* - Avg: %.2f, Win Rate: %.0f%% (%d/%d)", + medal, entry.UserName, entry.AverageScore, entry.WinRate, entry.TotalSolved, entry.TotalPlayed)) + } else { + lines = append(lines, fmt.Sprintf("%s *%s* - 0/%d solved", + medal, entry.UserName, entry.TotalPlayed)) + } + } + + return strings.Join(lines, "\n") +} + +// FormatUserStats formats personal statistics +func FormatUserStats(stats *UserStats, groupStreak int) string { + if stats.TotalPlayed == 0 { + return "You haven't posted any Wordle scores yet!" + } + + var lines []string + lines = append(lines, fmt.Sprintf("šŸ“Š *Your Wordle Stats* šŸ“Š\n")) + + // Overall stats + lines = append(lines, fmt.Sprintf("*Games Played:* %d", stats.TotalPlayed)) + lines = append(lines, fmt.Sprintf("*Games Solved:* %d (%.0f%%)", + stats.TotalSolved, + float64(stats.TotalSolved)/float64(stats.TotalPlayed)*100)) + + if stats.TotalSolved > 0 { + lines = append(lines, fmt.Sprintf("*Average Score:* %.2f", stats.AverageScore)) + } + + // Streaks + lines = append(lines, fmt.Sprintf("\nšŸ”„ *Streaks:*")) + lines = append(lines, fmt.Sprintf("• Current: %d day%s", stats.CurrentStreak, plural(stats.CurrentStreak))) + lines = append(lines, fmt.Sprintf("• Longest: %d day%s", stats.LongestStreak, plural(stats.LongestStreak))) + lines = append(lines, fmt.Sprintf("• Group: %d day%s", groupStreak, plural(groupStreak))) + + // Score distribution + if len(stats.ScoreDistribution) > 0 { + lines = append(lines, fmt.Sprintf("\nšŸ“ˆ *Score Distribution:*")) + + // Sort keys + keys := make([]string, 0, len(stats.ScoreDistribution)) + for k := range stats.ScoreDistribution { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + return scoreValue(keys[i]) < scoreValue(keys[j]) + }) + + for _, score := range keys { + count := stats.ScoreDistribution[score] + emoji := getEmoji(score) + bar := strings.Repeat("ā–“", count) + strings.Repeat("ā–‘", stats.TotalPlayed-count) + if len(bar) > 20 { + bar = bar[:20] + } + lines = append(lines, fmt.Sprintf("%s %s/6: %s %d", emoji, score, bar, count)) + } + } + + return strings.Join(lines, "\n") +} + +func plural(n int) string { + if n == 1 { + return "" + } + return "s" +} + // scoreValue returns the numeric value for sorting (X = 7) func scoreValue(score string) int { if score == "X" { diff --git a/pkg/wordlepattern/pattern.go b/pkg/wordlepattern/pattern.go index 8e498c0..e31263b 100644 --- a/pkg/wordlepattern/pattern.go +++ b/pkg/wordlepattern/pattern.go @@ -4,10 +4,14 @@ import ( "regexp" "strconv" "strings" + "time" ) -// Pattern matches "Wordle XXXX Y/6" format -var pattern = regexp.MustCompile(`(?i)Wordle\s+(\d+)\s+([X1-6])/6`) +// Pattern matches "Wordle XXXX Y/6" format (with optional comma in number) +var pattern = regexp.MustCompile(`(?i)Wordle\s+([\d,]+)\s+([X1-6])/6`) + +// Wordle #1 was June 19, 2021 +var wordleStartDate = time.Date(2021, 6, 19, 0, 0, 0, 0, time.UTC) // Match represents a matched Wordle score type Match struct { @@ -15,6 +19,20 @@ type Match struct { Score string } +// GetExpectedPuzzleNumber returns the expected Wordle puzzle number for a given date +func GetExpectedPuzzleNumber(date time.Time) int { + daysSinceStart := int(date.Sub(wordleStartDate).Hours() / 24) + return daysSinceStart + 1 +} + +// IsValidPuzzleNumber checks if a puzzle number is valid for the given date +// Allows for timezone differences (±1 day) +func IsValidPuzzleNumber(puzzleNumber int, date time.Time) bool { + expected := GetExpectedPuzzleNumber(date) + // Allow ±1 day for timezone differences + return puzzleNumber >= expected-1 && puzzleNumber <= expected+1 +} + // Find searches for a Wordle pattern in the given text // Returns the match and a boolean indicating if a match was found func Find(text string) (*Match, bool) { @@ -23,7 +41,9 @@ func Find(text string) (*Match, bool) { return nil, false } - puzzleNumber, _ := strconv.Atoi(matches[1]) + // Remove commas from puzzle number before parsing + puzzleNumberStr := strings.ReplaceAll(matches[1], ",", "") + puzzleNumber, _ := strconv.Atoi(puzzleNumberStr) score := strings.ToUpper(matches[2]) return &Match{