Files
slack-wordle-scores/internal/bot/bot.go

420 lines
10 KiB
Go

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 {
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)
}
}
}
func (b *Bot) handleEventsAPI(client *socketmode.Client, evt socketmode.Event) {
eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent)
if !ok {
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)
}
}
}
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
}
// 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 {
log.Printf("Error getting user info: %v", err)
return
}
userName := user.RealName
if userName == "" {
userName = user.Name
}
// 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)
// 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)
}
}
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)
// 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`" + ` - 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
}
}
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()
// 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(
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)
}