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

108
Makefile Normal file
View File

@ -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"

461
README.md Normal file
View File

@ -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 <your-repo-url>
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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.wordle-bot</string>
<key>ProgramArguments</key>
<array>
<string>/path/to/wordle-bot</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>SLACK_BOT_TOKEN</key>
<string>xoxb-your-token</string>
<key>SLACK_APP_TOKEN</key>
<string>xapp-your-token</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>WorkingDirectory</key>
<string>/path/to/wordle-bot</string>
</dict>
</plist>
```
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!

32
cmd/wordle-bot/main.go Normal file
View File

@ -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)
}
}

10
go.mod Normal file
View File

@ -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

18
go.sum Normal file
View File

@ -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=

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 "✅"
}
}

View File

@ -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
}