First commit
This commit is contained in:
108
Makefile
Normal file
108
Makefile
Normal 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
461
README.md
Normal 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
32
cmd/wordle-bot/main.go
Normal 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
10
go.mod
Normal 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
18
go.sum
Normal 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
304
internal/bot/bot.go
Normal 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
86
internal/config/config.go
Normal 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
236
internal/tracker/tracker.go
Normal 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 "✅"
|
||||||
|
}
|
||||||
|
}
|
||||||
33
pkg/wordlepattern/pattern.go
Normal file
33
pkg/wordlepattern/pattern.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user