commit 6b4258d3b660bf698caf3285597d3586bcaff52f Author: phlux Date: Tue Jan 6 14:55:03 2026 -0500 First commit diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6970150 --- /dev/null +++ b/Makefile @@ -0,0 +1,108 @@ +# Wordle Bot Makefile + +BINARY_NAME=wordle-bot +GO=go +INSTALL_DIR=/usr/local/bin +SERVICE_DIR=/usr/local/etc/rc.d +SERVICE_NAME=wordle_bot + +.PHONY: all build clean install uninstall deps test run + +all: build + +# Download dependencies +deps: + $(GO) mod tidy + $(GO) mod download + $(GO) mod verify + +# Build the binary +build: deps + $(GO) build -ldflags="-s -w" -o $(BINARY_NAME) ./cmd/wordle-bot + +# Build with debug symbols +build-debug: + $(GO) build -o $(BINARY_NAME) ./cmd/wordle-bot + +# Run tests (when you add them) +test: + $(GO) test -v ./... + +# Run the bot (for testing) +run: build + ./$(BINARY_NAME) + +# Clean build artifacts +clean: + rm -f $(BINARY_NAME) + rm -f wordle_scores.json + rm -f bot_config.json + rm -f wordle-bot.log + rm -f wordle-bot.pid + +# Install the binary (requires root/sudo) +# Note: Service installation varies by OS - see README for details +install: build + install -m 755 $(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME) + @echo "" + @echo "Installation complete!" + @echo "Binary installed to $(INSTALL_DIR)/$(BINARY_NAME)" + @echo "" + @echo "See README.md for service setup instructions for your OS" + +# Uninstall the binary (requires root/sudo) +uninstall: + rm -f $(INSTALL_DIR)/$(BINARY_NAME) + @echo "Binary uninstalled from $(INSTALL_DIR)/$(BINARY_NAME)" + +# Build for different platforms +build-linux-amd64: + GOOS=linux GOARCH=amd64 $(GO) build -ldflags="-s -w" -o $(BINARY_NAME)-linux-amd64 ./cmd/wordle-bot + +build-linux-arm64: + GOOS=linux GOARCH=arm64 $(GO) build -ldflags="-s -w" -o $(BINARY_NAME)-linux-arm64 ./cmd/wordle-bot + +build-darwin-amd64: + GOOS=darwin GOARCH=amd64 $(GO) build -ldflags="-s -w" -o $(BINARY_NAME)-darwin-amd64 ./cmd/wordle-bot + +build-darwin-arm64: + GOOS=darwin GOARCH=arm64 $(GO) build -ldflags="-s -w" -o $(BINARY_NAME)-darwin-arm64 ./cmd/wordle-bot + +build-windows-amd64: + GOOS=windows GOARCH=amd64 $(GO) build -ldflags="-s -w" -o $(BINARY_NAME)-windows-amd64.exe ./cmd/wordle-bot + +build-freebsd-amd64: + GOOS=freebsd GOARCH=amd64 $(GO) build -ldflags="-s -w" -o $(BINARY_NAME)-freebsd-amd64 ./cmd/wordle-bot + +build-freebsd-arm64: + GOOS=freebsd GOARCH=arm64 $(GO) build -ldflags="-s -w" -o $(BINARY_NAME)-freebsd-arm64 ./cmd/wordle-bot + +build-all: build-linux-amd64 build-linux-arm64 build-darwin-amd64 build-darwin-arm64 build-windows-amd64 build-freebsd-amd64 build-freebsd-arm64 + @echo "Built binaries for all platforms" + +# Compress binary with upx (if installed) +compress: build + @if command -v upx >/dev/null 2>&1; then \ + upx --best $(BINARY_NAME); \ + else \ + echo "upx not installed. Install with: pkg install upx"; \ + fi + +help: + @echo "Available targets:" + @echo " make build - Build the bot binary" + @echo " make install - Install bot binary (requires root/sudo)" + @echo " make uninstall - Remove bot binary (requires root/sudo)" + @echo " make clean - Remove build artifacts" + @echo " make deps - Download Go dependencies" + @echo " make test - Run tests" + @echo " make run - Build and run the bot" + @echo " make compress - Compress binary with upx" + @echo " make build-linux-amd64 - Cross-compile for Linux x86_64" + @echo " make build-linux-arm64 - Cross-compile for Linux ARM64" + @echo " make build-darwin-amd64 - Cross-compile for macOS x86_64" + @echo " make build-darwin-arm64 - Cross-compile for macOS ARM64 (Apple Silicon)" + @echo " make build-windows-amd64 - Cross-compile for Windows x86_64" + @echo " make build-freebsd-amd64 - Cross-compile for FreeBSD x86_64" + @echo " make build-freebsd-arm64 - Cross-compile for FreeBSD ARM64" + @echo " make build-all - Build for all platforms" diff --git a/README.md b/README.md new file mode 100644 index 0000000..e0b9d4f --- /dev/null +++ b/README.md @@ -0,0 +1,461 @@ +# Wordle Score Tracker Slack Bot + +A high-performance Slack bot written in Go that automatically tracks Wordle scores and provides daily summaries. + +## Features + +- šŸŽÆ **Automatic Detection**: Detects Wordle scores in any channel the bot is added to +- šŸ“Š **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 +- āš™ļø **Configurable**: Set custom summary times and channels +- āœ… **Reaction Confirmation**: Adds a checkmark reaction when it tracks a score +- šŸš€ **High Performance**: Written in Go for minimal resource usage + +## Quick Start + +### Prerequisites + +- Go 1.21 or higher +- A Slack workspace where you can create apps + +### Project Structure + +This project follows standard Go project layout: +- `cmd/wordle-bot/` - Main application entry point +- `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 +cd wordle-bot + +# Download dependencies and generate go.sum +go mod tidy + +# Build the binary +go build -o wordle-bot ./cmd/wordle-bot + +# Set environment variables +export SLACK_BOT_TOKEN="xoxb-your-bot-token" +export SLACK_APP_TOKEN="xapp-your-app-token" + +# Run the bot +./wordle-bot +``` + +Or use the included Makefile: + +```bash +make build +make run +``` + +## Slack App Setup + +### 1. Create a Slack App + +1. Go to [api.slack.com/apps](https://api.slack.com/apps) +2. Click **"Create New App"** → **"From scratch"** +3. Give it a name (e.g., "Wordle Tracker") and select your workspace + +### 2. Configure Bot Permissions + +Navigate to **OAuth & Permissions** and add these **Bot Token Scopes**: + +- `channels:history` - Read messages in channels +- `channels:read` - View basic channel info +- `chat:write` - Post messages +- `commands` - Add slash commands +- `reactions:write` - Add reactions to messages +- `users:read` - Read user profile info + +### 3. Enable Socket Mode + +1. Go to **Socket Mode** in the sidebar +2. **Enable Socket Mode** +3. Click **Generate Token** and give it a name (e.g., "Socket Token") +4. Save the **App-Level Token** (starts with `xapp-`) + +### 4. Add Slash Commands + +Go to **Slash Commands** and create these commands: + +**Command 1: `/wordle-config`** +- Command: `/wordle-config` +- Request URL: (leave blank for Socket Mode) +- Short Description: `Configure Wordle bot settings` +- Usage Hint: `[time HH:MM | channel | status]` + +**Command 2: `/wordle-stats`** +- Command: `/wordle-stats` +- Request URL: (leave blank for Socket Mode) +- Short Description: `View Wordle statistics` +- Usage Hint: `[today | yesterday]` + +### 5. Enable Event Subscriptions + +1. Go to **Event Subscriptions** +2. **Enable Events** +3. Under **Subscribe to bot events**, add: + - `message.channels` - Listen to messages in channels + +### 6. Install the App to Your Workspace + +1. Go to **Install App** in the sidebar +2. Click **Install to Workspace** +3. Review permissions and click **Allow** +4. Save the **Bot User OAuth Token** (starts with `xoxb-`) + +### 7. Get Your Tokens + +You now have two tokens: +- **Bot Token** (from OAuth & Permissions): `xoxb-...` +- **App Token** (from Socket Mode): `xapp-...` + +### 8. Invite Bot to Channels + +In Slack, invite the bot to channels where you want it to track scores: + +``` +/invite @Wordle Tracker +``` + +### 9. Configure the Bot + +Use slash commands to configure: + +``` +# Set this channel for daily summaries +/wordle-config channel + +# Set summary time (24-hour format) +/wordle-config time 09:00 + +# Check current configuration +/wordle-config status +``` + +## Usage in Slack + +### Posting Wordle Scores + +Users just post their Wordle scores normally: + +``` +Wordle 1662 5/6 + +⬜⬜⬜⬜⬜ +⬜🟨⬜🟨🟨 +🟨🟨🟨⬜⬜ +🟨🟩⬜🟩🟩 +🟩🟩🟩🟩🟩 +``` + +The bot will automatically: +- Detect the score +- Track it for daily summary +- Add a āœ… reaction to confirm + +### Slash Commands + +**View today's scores:** +``` +/wordle-stats today +``` + +**View yesterday's summary:** +``` +/wordle-stats yesterday +``` + +**Configure summary time:** +``` +/wordle-config time 09:00 +``` + +**Set current channel for summaries:** +``` +/wordle-config channel +``` + +**Check configuration:** +``` +/wordle-config status +``` + +## Running as a Service + +### Linux (systemd) + +Create `/etc/systemd/system/wordle-bot.service`: + +```ini +[Unit] +Description=Wordle Score Tracker Bot +After=network.target + +[Service] +Type=simple +User=wordle-bot +WorkingDirectory=/opt/wordle-bot +Environment="SLACK_BOT_TOKEN=xoxb-your-token" +Environment="SLACK_APP_TOKEN=xapp-your-token" +ExecStart=/opt/wordle-bot/wordle-bot +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: + +```bash +sudo systemctl enable wordle-bot +sudo systemctl start wordle-bot +sudo systemctl status wordle-bot +``` + +### macOS (launchd) + +Create `~/Library/LaunchAgents/com.wordle-bot.plist`: + +```xml + + + + + Label + com.wordle-bot + ProgramArguments + + /path/to/wordle-bot + + EnvironmentVariables + + SLACK_BOT_TOKEN + xoxb-your-token + SLACK_APP_TOKEN + xapp-your-token + + RunAtLoad + + KeepAlive + + WorkingDirectory + /path/to/wordle-bot + + +``` + +Load and start: + +```bash +launchctl load ~/Library/LaunchAgents/com.wordle-bot.plist +launchctl start com.wordle-bot +``` + +### FreeBSD (rc.d) + +An rc.d service script is included (`wordle_bot.rc`). + +Copy to `/usr/local/etc/rc.d/wordle_bot`, make it executable, and configure `/etc/rc.conf.d/wordle_bot`: + +```bash +wordle_bot_enable="YES" +wordle_bot_user="wordle" +wordle_bot_dir="/path/to/bot" +wordle_bot_bot_token="xoxb-your-token" +wordle_bot_app_token="xapp-your-token" +``` + +Then: `service wordle_bot start` + +### Docker + +Build and run with Docker: + +```dockerfile +FROM golang:1.21-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY main.go ./ +RUN go build -o wordle-bot main.go + +FROM alpine:latest +RUN apk --no-cache add ca-certificates +WORKDIR /root/ +COPY --from=builder /app/wordle-bot . +CMD ["./wordle-bot"] +``` + +```bash +docker build -t wordle-bot . +docker run -d \ + --name wordle-bot \ + -e SLACK_BOT_TOKEN="xoxb-your-token" \ + -e SLACK_APP_TOKEN="xapp-your-token" \ + -v $(pwd)/data:/root \ + wordle-bot +``` + +## Data Storage + +The bot stores data in two JSON files: + +- `wordle_scores.json`: Historical score data +- `bot_config.json`: Bot configuration (summary time, channel) + +These files are created automatically in the working directory. + +**Backup recommendations:** +```bash +# Backup data files +cp wordle_scores.json wordle_scores.json.bak +cp bot_config.json bot_config.json.bak + +# Scheduled backup (add to crontab) +0 2 * * * cp /home/wordle/bot/*.json /home/wordle/backups/ +``` + +## Building for Production + +### Optimized Build + +```bash +# Build with optimizations (smaller binary) +go build -ldflags="-s -w" -o wordle-bot main.go + +# Or use the Makefile +make build +``` + +### Cross-Compilation + +Build for different platforms: + +```bash +# Linux amd64 +GOOS=linux GOARCH=amd64 go build -o wordle-bot-linux-amd64 ./cmd/wordle-bot + +# Linux arm64 +GOOS=linux GOARCH=arm64 go build -o wordle-bot-linux-arm64 ./cmd/wordle-bot + +# macOS amd64 +GOOS=darwin GOARCH=amd64 go build -o wordle-bot-darwin-amd64 ./cmd/wordle-bot + +# macOS arm64 (Apple Silicon) +GOOS=darwin GOARCH=arm64 go build -o wordle-bot-darwin-arm64 ./cmd/wordle-bot + +# Windows +GOOS=windows GOARCH=amd64 go build -o wordle-bot-windows-amd64.exe ./cmd/wordle-bot + +# FreeBSD amd64 +GOOS=freebsd GOARCH=amd64 go build -o wordle-bot-freebsd-amd64 ./cmd/wordle-bot +``` + +## Monitoring and Logs + +### Check Logs + +```bash +# View live logs (if running as service with log file) +tail -f wordle-bot.log + +# Search for errors +grep -i error wordle-bot.log + +# View last 50 lines +tail -n 50 wordle-bot.log +``` + +### Using systemd journalctl (Linux) + +```bash +# View logs +sudo journalctl -u wordle-bot -f + +# View last 100 lines +sudo journalctl -u wordle-bot -n 100 + +# View logs since boot +sudo journalctl -u wordle-bot -b +``` + +## Troubleshooting + +### Bot doesn't start + +```bash +# Check if tokens are set +echo $SLACK_BOT_TOKEN +echo $SLACK_APP_TOKEN + +# Check permissions on binary +ls -la wordle-bot + +# Run manually to see errors +export SLACK_BOT_TOKEN="xoxb-..." +export SLACK_APP_TOKEN="xapp-..." +./wordle-bot +``` + +### Bot doesn't respond in Slack + +- Verify bot is invited to channel: `/invite @Wordle Tracker` +- Check that Socket Mode is enabled in Slack app settings +- Verify event subscription for `message.channels` +- Check bot permissions include `channels:history` + +### Scores not detected + +- Ensure message contains "Wordle XXXX Y/6" format +- Check bot has `channels:history` permission +- Verify bot is running: `ps aux | grep wordle-bot` or check your service manager + +### Daily summary not posting + +- Run `/wordle-config status` to verify channel is set +- Check summary time is correct (24-hour format) +- Ensure bot has permissions in summary channel +- Check logs for errors +- Verify the system time is correct + +## Resource Usage + +Typical resource usage: +- **Memory**: ~15-25 MB +- **CPU**: <1% idle, ~2-5% when processing +- **Disk**: <10 MB (binary + data files) + +Lightweight and efficient! + +## Security Notes + +1. **Protect tokens**: Never commit tokens to version control +2. **Use environment variables**: Keep tokens in env vars or secure config files +3. **Run as non-root**: Always run as a dedicated user account +4. **File permissions**: Restrict access to config files containing tokens +5. **Regular updates**: Keep Go and dependencies updated + +Example secure setup: + +```bash +# Create dedicated user (Linux) +sudo useradd -r -s /bin/false wordle-bot + +# Secure config file +chmod 600 .env +chown wordle-bot:wordle-bot .env +``` + +## License + +MIT License - feel free to modify and use as needed! diff --git a/cmd/wordle-bot/main.go b/cmd/wordle-bot/main.go new file mode 100644 index 0000000..d930a9c --- /dev/null +++ b/cmd/wordle-bot/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "log" + "os" + + "wordle-bot/internal/bot" +) + +func main() { + botToken := os.Getenv("SLACK_BOT_TOKEN") + appToken := os.Getenv("SLACK_APP_TOKEN") + + if botToken == "" { + log.Fatal("SLACK_BOT_TOKEN environment variable not set") + } + + if appToken == "" { + log.Fatal("SLACK_APP_TOKEN environment variable not set") + } + + b, err := bot.New(botToken, appToken) + if err != nil { + log.Fatalf("Failed to create bot: %v", err) + } + + log.Println("āš”ļø Wordle Bot is starting...") + + if err := b.Run(); err != nil { + log.Fatalf("Failed to run bot: %v", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..beb781d --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module wordle-bot + +go 1.21 + +require ( + github.com/robfig/cron/v3 v3.0.1 + github.com/slack-go/slack v0.12.3 +) + +require github.com/gorilla/websocket v1.5.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cb86eb9 --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= +github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/slack-go/slack v0.12.3 h1:92/dfFU8Q5XP6Wp5rr5/T5JHLM5c5Smtn53fhToAP88= +github.com/slack-go/slack v0.12.3/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/bot/bot.go b/internal/bot/bot.go new file mode 100644 index 0000000..0936d7d --- /dev/null +++ b/internal/bot/bot.go @@ -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) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b5f8501 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/tracker/tracker.go b/internal/tracker/tracker.go new file mode 100644 index 0000000..7c46bd2 --- /dev/null +++ b/internal/tracker/tracker.go @@ -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 "āœ…" + } +} diff --git a/pkg/wordlepattern/pattern.go b/pkg/wordlepattern/pattern.go new file mode 100644 index 0000000..8e498c0 --- /dev/null +++ b/pkg/wordlepattern/pattern.go @@ -0,0 +1,33 @@ +package wordlepattern + +import ( + "regexp" + "strconv" + "strings" +) + +// Pattern matches "Wordle XXXX Y/6" format +var pattern = regexp.MustCompile(`(?i)Wordle\s+(\d+)\s+([X1-6])/6`) + +// Match represents a matched Wordle score +type Match struct { + PuzzleNumber int + Score string +} + +// 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) { + matches := pattern.FindStringSubmatch(text) + if matches == nil { + return nil, false + } + + puzzleNumber, _ := strconv.Atoi(matches[1]) + score := strings.ToUpper(matches[2]) + + return &Match{ + PuzzleNumber: puzzleNumber, + Score: score, + }, true +}