Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1183d5867 |
36
README.md
36
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
|
- 📊 **Daily Summaries**: Posts a configurable morning summary of yesterday's results
|
||||||
- 🏆 **Leaderboard**: Shows who solved the puzzle and their scores
|
- 🏆 **Leaderboard**: Shows who solved the puzzle and their scores
|
||||||
- 📈 **Statistics**: Calculates success rate and average 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
|
- ⚙️ **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
|
- 🚀 **High Performance**: Written in Go for minimal resource usage
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@ -26,11 +30,13 @@ This project follows standard Go project layout:
|
|||||||
- `internal/` - Private application packages (bot, config, tracker)
|
- `internal/` - Private application packages (bot, config, tracker)
|
||||||
- `pkg/` - Public library code (reusable pattern matching)
|
- `pkg/` - Public library code (reusable pattern matching)
|
||||||
|
|
||||||
|
See [PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md) for detailed documentation.
|
||||||
|
|
||||||
### Build and Run
|
### Build and Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
# Clone the repository
|
||||||
git clone https://git.ewnix.net/phlux/slack-wordle-scores.git
|
git clone <your-repo-url>
|
||||||
cd wordle-bot
|
cd wordle-bot
|
||||||
|
|
||||||
# Download dependencies and generate go.sum
|
# Download dependencies and generate go.sum
|
||||||
@ -47,7 +53,7 @@ export SLACK_APP_TOKEN="xapp-your-app-token"
|
|||||||
./wordle-bot
|
./wordle-bot
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the included Makefile:
|
Or use the included Makefile (handles `go mod tidy` automatically):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make build
|
make build
|
||||||
@ -157,8 +163,15 @@ Wordle 1662 5/6
|
|||||||
|
|
||||||
The bot will automatically:
|
The bot will automatically:
|
||||||
- Detect the score
|
- 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
|
- 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
|
### Slash Commands
|
||||||
|
|
||||||
@ -172,6 +185,21 @@ The bot will automatically:
|
|||||||
/wordle-stats yesterday
|
/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:**
|
**Configure summary time:**
|
||||||
```
|
```
|
||||||
/wordle-config time 09:00
|
/wordle-config time 09:00
|
||||||
|
|||||||
@ -77,11 +77,14 @@ func (b *Bot) Run() error {
|
|||||||
|
|
||||||
func (b *Bot) handleEvents(client *socketmode.Client) {
|
func (b *Bot) handleEvents(client *socketmode.Client) {
|
||||||
for evt := range client.Events {
|
for evt := range client.Events {
|
||||||
|
log.Printf("DEBUG: Received event type: %s", evt.Type)
|
||||||
switch evt.Type {
|
switch evt.Type {
|
||||||
case socketmode.EventTypeEventsAPI:
|
case socketmode.EventTypeEventsAPI:
|
||||||
b.handleEventsAPI(client, evt)
|
b.handleEventsAPI(client, evt)
|
||||||
case socketmode.EventTypeSlashCommand:
|
case socketmode.EventTypeSlashCommand:
|
||||||
b.handleSlashCommand(client, evt)
|
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) {
|
func (b *Bot) handleEventsAPI(client *socketmode.Client, evt socketmode.Event) {
|
||||||
eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent)
|
eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Printf("Ignored %+v\n", evt)
|
log.Printf("DEBUG: Could not cast to EventsAPIEvent: %+v\n", evt)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("DEBUG: EventsAPI type: %s", eventsAPIEvent.Type)
|
||||||
|
|
||||||
client.Ack(*evt.Request)
|
client.Ack(*evt.Request)
|
||||||
|
|
||||||
if eventsAPIEvent.Type == slackevents.CallbackEvent {
|
if eventsAPIEvent.Type == slackevents.CallbackEvent {
|
||||||
innerEvent := eventsAPIEvent.InnerEvent
|
innerEvent := eventsAPIEvent.InnerEvent
|
||||||
|
log.Printf("DEBUG: InnerEvent type: %s", innerEvent.Type)
|
||||||
if msgEvent, ok := innerEvent.Data.(*slackevents.MessageEvent); ok {
|
if msgEvent, ok := innerEvent.Data.(*slackevents.MessageEvent); ok {
|
||||||
b.handleMessage(msgEvent)
|
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
|
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
|
// Get user info
|
||||||
user, err := b.client.GetUserInfo(ev.User)
|
user, err := b.client.GetUserInfo(ev.User)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -127,10 +166,6 @@ func (b *Bot) handleMessage(ev *slackevents.MessageEvent) {
|
|||||||
userName = user.Name
|
userName = user.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert timestamp
|
|
||||||
tsFloat, _ := strconv.ParseFloat(ev.TimeStamp, 64)
|
|
||||||
timestamp := time.Unix(int64(tsFloat), 0)
|
|
||||||
|
|
||||||
// Track the score
|
// Track the score
|
||||||
if err := b.tracker.Add(ev.User, userName, match.PuzzleNumber, match.Score, timestamp); err != nil {
|
if err := b.tracker.Add(ev.User, userName, match.PuzzleNumber, match.Score, timestamp); err != nil {
|
||||||
log.Printf("Error tracking score: %v", err)
|
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)
|
log.Printf("Tracked score for %s: Wordle %d %s/6", userName, match.PuzzleNumber, match.Score)
|
||||||
|
|
||||||
// React to the message
|
// Get updated today's scores
|
||||||
if err := b.client.AddReaction("white_check_mark", slack.ItemRef{
|
todayScores := b.tracker.GetToday()
|
||||||
Channel: ev.Channel,
|
|
||||||
Timestamp: ev.TimeStamp,
|
// Update with current usernames
|
||||||
}); err != nil {
|
tracker.UpdateDailyScoresNames(todayScores, func(userID string) string {
|
||||||
log.Printf("Error adding reaction: %v", err)
|
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) {
|
func (b *Bot) handleStatsCommand(cmd slack.SlashCommand) (string, error) {
|
||||||
args := strings.TrimSpace(cmd.Text)
|
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 {
|
switch args {
|
||||||
case "", "yesterday":
|
case "", "yesterday":
|
||||||
scores := b.tracker.GetYesterday()
|
scores := b.tracker.GetYesterday()
|
||||||
|
tracker.UpdateDailyScoresNames(scores, getCurrentName)
|
||||||
return tracker.FormatSummary(scores), nil
|
return tracker.FormatSummary(scores), nil
|
||||||
|
|
||||||
case "today":
|
case "today":
|
||||||
scores := b.tracker.GetToday()
|
scores := b.tracker.GetToday()
|
||||||
|
tracker.UpdateDailyScoresNames(scores, getCurrentName)
|
||||||
return tracker.FormatToday(scores), nil
|
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:
|
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()
|
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)
|
summary := tracker.FormatSummary(yesterdayScores)
|
||||||
|
|
||||||
_, _, err := b.client.PostMessage(
|
_, _, err := b.client.PostMessage(
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package tracker
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -20,6 +21,29 @@ type Score struct {
|
|||||||
Timestamp time.Time `json:"timestamp"`
|
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
|
// DailyScores maps user IDs to their scores for a specific date
|
||||||
type DailyScores map[string]Score
|
type DailyScores map[string]Score
|
||||||
|
|
||||||
@ -88,6 +112,16 @@ func (t *Tracker) Add(userID, userName string, puzzleNumber int, score string, t
|
|||||||
return t.save()
|
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
|
// GetForDate retrieves all scores for a specific date
|
||||||
func (t *Tracker) GetForDate(date string) DailyScores {
|
func (t *Tracker) GetForDate(date string) DailyScores {
|
||||||
scores, ok := t.scores[date]
|
scores, ok := t.scores[date]
|
||||||
@ -109,6 +143,202 @@ func (t *Tracker) GetToday() DailyScores {
|
|||||||
return t.GetForDate(today)
|
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
|
// ScoreEntry is used for sorting
|
||||||
type ScoreEntry struct {
|
type ScoreEntry struct {
|
||||||
UserID string
|
UserID string
|
||||||
@ -214,6 +444,152 @@ func FormatToday(scores DailyScores) string {
|
|||||||
return strings.Join(lines, "\n")
|
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)
|
// scoreValue returns the numeric value for sorting (X = 7)
|
||||||
func scoreValue(score string) int {
|
func scoreValue(score string) int {
|
||||||
if score == "X" {
|
if score == "X" {
|
||||||
|
|||||||
@ -4,10 +4,14 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Pattern matches "Wordle XXXX Y/6" format
|
// Pattern matches "Wordle XXXX Y/6" format (with optional comma in number)
|
||||||
var pattern = regexp.MustCompile(`(?i)Wordle\s+(\d+)\s+([X1-6])/6`)
|
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
|
// Match represents a matched Wordle score
|
||||||
type Match struct {
|
type Match struct {
|
||||||
@ -15,6 +19,20 @@ type Match struct {
|
|||||||
Score string
|
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
|
// Find searches for a Wordle pattern in the given text
|
||||||
// Returns the match and a boolean indicating if a match was found
|
// Returns the match and a boolean indicating if a match was found
|
||||||
func Find(text string) (*Match, bool) {
|
func Find(text string) (*Match, bool) {
|
||||||
@ -23,7 +41,9 @@ func Find(text string) (*Match, bool) {
|
|||||||
return nil, false
|
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])
|
score := strings.ToUpper(matches[2])
|
||||||
|
|
||||||
return &Match{
|
return &Match{
|
||||||
|
|||||||
Reference in New Issue
Block a user