First commit

This commit is contained in:
2026-01-06 14:55:03 -05:00
commit 6b4258d3b6
9 changed files with 1288 additions and 0 deletions

304
internal/bot/bot.go Normal file
View File

@ -0,0 +1,304 @@
package bot
import (
"fmt"
"log"
"os"
"strconv"
"strings"
"time"
"github.com/robfig/cron/v3"
"github.com/slack-go/slack"
"github.com/slack-go/slack/slackevents"
"github.com/slack-go/slack/socketmode"
"wordle-bot/internal/config"
"wordle-bot/internal/tracker"
"wordle-bot/pkg/wordlepattern"
)
// Bot represents the Slack bot
type Bot struct {
client *slack.Client
tracker *tracker.Tracker
config *config.Manager
cron *cron.Cron
}
// New creates a new bot instance
func New(botToken, appToken string) (*Bot, error) {
t, err := tracker.New("")
if err != nil {
return nil, fmt.Errorf("failed to create tracker: %w", err)
}
cfg, err := config.New("")
if err != nil {
return nil, fmt.Errorf("failed to create config manager: %w", err)
}
client := slack.New(
botToken,
slack.OptionAppLevelToken(appToken),
)
bot := &Bot{
client: client,
tracker: t,
config: cfg,
cron: cron.New(),
}
return bot, nil
}
// Run starts the bot
func (b *Bot) Run() error {
// Setup cron for daily summaries
if err := b.setupCron(); err != nil {
return fmt.Errorf("failed to setup cron: %w", err)
}
b.cron.Start()
defer b.cron.Stop()
// Create socket mode client
client := socketmode.New(
b.client,
socketmode.OptionLog(log.New(os.Stdout, "socketmode: ", log.Lshortfile|log.LstdFlags)),
)
// Handle events
go b.handleEvents(client)
log.Println("⚡️ Wordle Bot is running!")
return client.Run()
}
func (b *Bot) handleEvents(client *socketmode.Client) {
for evt := range client.Events {
switch evt.Type {
case socketmode.EventTypeEventsAPI:
b.handleEventsAPI(client, evt)
case socketmode.EventTypeSlashCommand:
b.handleSlashCommand(client, evt)
}
}
}
func (b *Bot) handleEventsAPI(client *socketmode.Client, evt socketmode.Event) {
eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent)
if !ok {
log.Printf("Ignored %+v\n", evt)
return
}
client.Ack(*evt.Request)
if eventsAPIEvent.Type == slackevents.CallbackEvent {
innerEvent := eventsAPIEvent.InnerEvent
if msgEvent, ok := innerEvent.Data.(*slackevents.MessageEvent); ok {
b.handleMessage(msgEvent)
}
}
}
func (b *Bot) handleMessage(ev *slackevents.MessageEvent) {
// Skip bot messages
if ev.BotID != "" {
return
}
// Look for Wordle score
match, found := wordlepattern.Find(ev.Text)
if !found {
return
}
// Get user info
user, err := b.client.GetUserInfo(ev.User)
if err != nil {
log.Printf("Error getting user info: %v", err)
return
}
userName := user.RealName
if userName == "" {
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)
return
}
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)
}
}
func (b *Bot) handleSlashCommand(client *socketmode.Client, evt socketmode.Event) {
cmd, ok := evt.Data.(slack.SlashCommand)
if !ok {
log.Printf("Ignored %+v\n", evt)
return
}
var response string
var err error
switch cmd.Command {
case "/wordle-config":
response, err = b.handleConfigCommand(cmd)
case "/wordle-stats":
response, err = b.handleStatsCommand(cmd)
default:
response = fmt.Sprintf("Unknown command: %s", cmd.Command)
}
if err != nil {
log.Printf("Error handling command: %v", err)
response = fmt.Sprintf("Error: %v", err)
}
client.Ack(*evt.Request, map[string]interface{}{
"text": response,
})
}
func (b *Bot) handleConfigCommand(cmd slack.SlashCommand) (string, error) {
args := strings.Fields(cmd.Text)
if len(args) == 0 || args[0] == "help" {
return `*Wordle Bot Configuration Commands*
` + "`/wordle-config time HH:MM`" + ` - Set daily summary time (24-hour format)
` + "`/wordle-config channel`" + ` - Set this channel for daily summaries
` + "`/wordle-config status`" + ` - Show current configuration`, nil
}
switch args[0] {
case "time":
if len(args) < 2 {
return "Please specify time in HH:MM format (e.g., 09:00)", nil
}
if err := b.config.SetSummaryTime(args[1]); err != nil {
return "❌ Invalid time format. Use HH:MM (e.g., 09:00)", nil
}
// Reschedule cron
if err := b.setupCron(); err != nil {
return fmt.Sprintf("❌ Error rescheduling: %v", err), nil
}
return fmt.Sprintf("✅ Daily summary time set to %s", args[1]), nil
case "channel":
if err := b.config.SetSummaryChannel(cmd.ChannelID); err != nil {
return fmt.Sprintf("❌ Error setting channel: %v", err), nil
}
cfg := b.config.Get()
return fmt.Sprintf("✅ Daily summaries will be posted to this channel at %s", cfg.SummaryTime), nil
case "status":
cfg := b.config.Get()
channelText := "Not set"
if cfg.SummaryChannel != "" {
channelText = fmt.Sprintf("<#%s>", cfg.SummaryChannel)
}
return fmt.Sprintf(`*Current Configuration*
Summary Time: %s
Summary Channel: %s`, cfg.SummaryTime, channelText), nil
default:
return fmt.Sprintf("Unknown command: %s. Use `/wordle-config help` for usage.", args[0]), nil
}
}
func (b *Bot) handleStatsCommand(cmd slack.SlashCommand) (string, error) {
args := strings.TrimSpace(cmd.Text)
switch args {
case "", "yesterday":
scores := b.tracker.GetYesterday()
return tracker.FormatSummary(scores), nil
case "today":
scores := b.tracker.GetToday()
return tracker.FormatToday(scores), nil
default:
return "Usage: `/wordle-stats` or `/wordle-stats yesterday` or `/wordle-stats today`", nil
}
}
func (b *Bot) setupCron() error {
// Remove all existing jobs
for _, entry := range b.cron.Entries() {
b.cron.Remove(entry.ID)
}
cfg := b.config.Get()
// Parse time
parts := strings.Split(cfg.SummaryTime, ":")
if len(parts) != 2 {
return fmt.Errorf("invalid time format: %s", cfg.SummaryTime)
}
hour := parts[0]
minute := parts[1]
// Create cron schedule (minute hour * * *)
schedule := fmt.Sprintf("%s %s * * *", minute, hour)
_, err := b.cron.AddFunc(schedule, func() {
b.postDailySummary()
})
if err != nil {
return fmt.Errorf("failed to add cron job: %w", err)
}
log.Printf("Scheduled daily summary for %s", cfg.SummaryTime)
return nil
}
func (b *Bot) postDailySummary() {
cfg := b.config.Get()
if cfg.SummaryChannel == "" {
log.Println("No summary channel configured, skipping daily summary")
return
}
yesterdayScores := b.tracker.GetYesterday()
summary := tracker.FormatSummary(yesterdayScores)
_, _, err := b.client.PostMessage(
cfg.SummaryChannel,
slack.MsgOptionText(summary, false),
slack.MsgOptionDisableLinkUnfurl(),
)
if err != nil {
log.Printf("Error posting summary: %v", err)
return
}
log.Printf("Posted daily summary to %s", cfg.SummaryChannel)
}

86
internal/config/config.go Normal file
View File

@ -0,0 +1,86 @@
package config
import (
"encoding/json"
"fmt"
"os"
"time"
)
const defaultFile = "bot_config.json"
// Config holds bot configuration settings
type Config struct {
SummaryTime string `json:"summary_time"` // HH:MM format
SummaryChannel string `json:"summary_channel"` // Channel ID
Timezone string `json:"timezone"`
}
// Manager handles configuration persistence
type Manager struct {
config Config
filename string
}
// New creates a new config manager
func New(filename string) (*Manager, error) {
if filename == "" {
filename = defaultFile
}
cm := &Manager{
filename: filename,
config: Config{
SummaryTime: "09:00",
SummaryChannel: "",
Timezone: "America/New_York",
},
}
if err := cm.load(); err != nil {
if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to load config: %w", err)
}
}
return cm, nil
}
func (cm *Manager) load() error {
data, err := os.ReadFile(cm.filename)
if err != nil {
return err
}
return json.Unmarshal(data, &cm.config)
}
func (cm *Manager) save() error {
data, err := json.MarshalIndent(cm.config, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
return os.WriteFile(cm.filename, data, 0644)
}
// SetSummaryTime sets the time for daily summaries
func (cm *Manager) SetSummaryTime(timeStr string) error {
if _, err := time.Parse("15:04", timeStr); err != nil {
return fmt.Errorf("invalid time format, use HH:MM: %w", err)
}
cm.config.SummaryTime = timeStr
return cm.save()
}
// SetSummaryChannel sets the channel for daily summaries
func (cm *Manager) SetSummaryChannel(channelID string) error {
cm.config.SummaryChannel = channelID
return cm.save()
}
// Get returns the current configuration
func (cm *Manager) Get() Config {
return cm.config
}

236
internal/tracker/tracker.go Normal file
View File

@ -0,0 +1,236 @@
package tracker
import (
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
)
const defaultFile = "wordle_scores.json"
// Score represents a single Wordle score entry
type Score struct {
UserName string `json:"user_name"`
PuzzleNumber int `json:"puzzle_number"`
Score string `json:"score"`
Timestamp time.Time `json:"timestamp"`
}
// DailyScores maps user IDs to their scores for a specific date
type DailyScores map[string]Score
// scoresDB maps dates (YYYY-MM-DD) to daily scores
type scoresDB map[string]DailyScores
// Tracker manages score tracking and persistence
type Tracker struct {
scores scoresDB
filename string
}
// New creates a new tracker instance
func New(filename string) (*Tracker, error) {
if filename == "" {
filename = defaultFile
}
tracker := &Tracker{
filename: filename,
scores: make(scoresDB),
}
if err := tracker.load(); err != nil {
if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to load scores: %w", err)
}
}
return tracker, nil
}
func (t *Tracker) load() error {
data, err := os.ReadFile(t.filename)
if err != nil {
return err
}
return json.Unmarshal(data, &t.scores)
}
func (t *Tracker) save() error {
data, err := json.MarshalIndent(t.scores, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal scores: %w", err)
}
return os.WriteFile(t.filename, data, 0644)
}
// Add adds a Wordle score for a user
func (t *Tracker) Add(userID, userName string, puzzleNumber int, score string, timestamp time.Time) error {
date := timestamp.Format("2006-01-02")
if t.scores[date] == nil {
t.scores[date] = make(DailyScores)
}
t.scores[date][userID] = Score{
UserName: userName,
PuzzleNumber: puzzleNumber,
Score: score,
Timestamp: timestamp,
}
return t.save()
}
// GetForDate retrieves all scores for a specific date
func (t *Tracker) GetForDate(date string) DailyScores {
scores, ok := t.scores[date]
if !ok {
return make(DailyScores)
}
return scores
}
// GetYesterday retrieves scores from yesterday
func (t *Tracker) GetYesterday() DailyScores {
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
return t.GetForDate(yesterday)
}
// GetToday retrieves scores from today
func (t *Tracker) GetToday() DailyScores {
today := time.Now().Format("2006-01-02")
return t.GetForDate(today)
}
// ScoreEntry is used for sorting
type ScoreEntry struct {
UserID string
Score Score
}
// FormatSummary formats scores into a nice summary message
func FormatSummary(scores DailyScores) string {
if len(scores) == 0 {
return "No Wordle scores were posted yesterday. 😢"
}
// Get puzzle number from first entry
var puzzleNum int
for _, score := range scores {
puzzleNum = score.PuzzleNumber
break
}
// Sort scores
var entries []ScoreEntry
for userID, score := range scores {
entries = append(entries, ScoreEntry{UserID: userID, Score: score})
}
sort.Slice(entries, func(i, j int) bool {
return scoreValue(entries[i].Score.Score) < scoreValue(entries[j].Score.Score)
})
// Group by score
scoreGroups := make(map[string][]string)
for _, entry := range entries {
score := entry.Score.Score
scoreGroups[score] = append(scoreGroups[score], entry.Score.UserName)
}
// Build summary
var lines []string
lines = append(lines, fmt.Sprintf("📊 *Wordle %d Results* 📊\n", puzzleNum))
// Get sorted score keys
var scoreKeys []string
for score := range scoreGroups {
scoreKeys = append(scoreKeys, score)
}
sort.Slice(scoreKeys, func(i, j int) bool {
return scoreValue(scoreKeys[i]) < scoreValue(scoreKeys[j])
})
// Display grouped results
for _, score := range scoreKeys {
users := scoreGroups[score]
emoji := getEmoji(score)
userList := strings.Join(users, ", ")
lines = append(lines, fmt.Sprintf("%s *%s/6*: %s", emoji, score, userList))
}
// Statistics
totalPlayers := len(scores)
successful := 0
totalScore := 0
for _, score := range scores {
if score.Score != "X" {
successful++
val, _ := strconv.Atoi(score.Score)
totalScore += val
}
}
lines = append(lines, fmt.Sprintf("\n📈 *Stats*: %d/%d solved", successful, totalPlayers))
if successful > 0 {
avgScore := float64(totalScore) / float64(successful)
lines = append(lines, fmt.Sprintf("Average score: %.2f", avgScore))
}
return strings.Join(lines, "\n")
}
// FormatToday formats today's scores
func FormatToday(scores DailyScores) string {
if len(scores) == 0 {
return "No scores posted yet today!"
}
// Get puzzle number
var puzzleNum int
for _, score := range scores {
puzzleNum = score.PuzzleNumber
break
}
var lines []string
lines = append(lines, fmt.Sprintf("📊 *Today's Wordle %d Scores So Far* 📊\n", puzzleNum))
for _, score := range scores {
lines = append(lines, fmt.Sprintf("• %s: %s/6", score.UserName, score.Score))
}
return strings.Join(lines, "\n")
}
// scoreValue returns the numeric value for sorting (X = 7)
func scoreValue(score string) int {
if score == "X" {
return 7
}
val, _ := strconv.Atoi(score)
return val
}
// getEmoji returns the appropriate emoji for a score
func getEmoji(score string) string {
switch score {
case "1", "2", "3":
return "🎯"
case "X":
return "❌"
default:
return "✅"
}
}