Merge pull request #1 from calcorum/readme-to-claude-auto

Readme to claude auto
This commit is contained in:
Cal Corum 2025-10-20 20:25:31 -05:00 committed by GitHub
commit 64e60232dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 6158 additions and 6662 deletions

View File

@ -70,6 +70,7 @@ docs/
# CI/CD
.github/
.gitlab-ci.yml
.gitlab/
# OS
.DS_Store

237
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,237 @@
stages:
- test
- build
- deploy
variables:
DOCKER_IMAGE: yourusername/discord-bot-v2
DOCKER_DRIVER: overlay2
# Semantic versioning - update these for releases
VERSION_MAJOR: "2"
VERSION_MINOR: "1"
# Test on all branches
test:
stage: test
image: python:3.11-slim
before_script:
- cd discord-app-v2
- pip install --cache-dir .cache/pip -r requirements.txt
script:
- python -m pytest --tb=short -q --cov=. --cov-report=term-missing
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .cache/pip
only:
- branches
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: discord-app-v2/coverage.xml
# Build with versioned tags
build:
stage: build
image: docker:24-dind
services:
- docker:24-dind
before_script:
- docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
script:
- cd discord-app-v2
# Calculate version tags
- export VERSION_PATCH=${CI_PIPELINE_IID}
- export FULL_VERSION="v${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}"
- export SHORT_SHA=${CI_COMMIT_SHORT_SHA}
- export BRANCH_TAG="${CI_COMMIT_REF_SLUG}-${SHORT_SHA}"
# Build once, tag multiple times
- |
docker build \
--build-arg VERSION=${FULL_VERSION} \
--build-arg GIT_COMMIT=${CI_COMMIT_SHA} \
--build-arg BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
-t ${DOCKER_IMAGE}:${FULL_VERSION} \
-t ${DOCKER_IMAGE}:${SHORT_SHA} \
-t ${DOCKER_IMAGE}:${BRANCH_TAG} \
.
# Tag as latest only for main branch
- |
if [ "$CI_COMMIT_BRANCH" == "main" ]; then
docker tag ${DOCKER_IMAGE}:${FULL_VERSION} ${DOCKER_IMAGE}:latest
fi
# Tag as staging for develop branch
- |
if [ "$CI_COMMIT_BRANCH" == "develop" ]; then
docker tag ${DOCKER_IMAGE}:${FULL_VERSION} ${DOCKER_IMAGE}:staging
fi
# Push all tags
- docker push ${DOCKER_IMAGE}:${FULL_VERSION}
- docker push ${DOCKER_IMAGE}:${SHORT_SHA}
- docker push ${DOCKER_IMAGE}:${BRANCH_TAG}
- |
if [ "$CI_COMMIT_BRANCH" == "main" ]; then
docker push ${DOCKER_IMAGE}:latest
fi
- |
if [ "$CI_COMMIT_BRANCH" == "develop" ]; then
docker push ${DOCKER_IMAGE}:staging
fi
# Save version info for deployment
- echo "FULL_VERSION=${FULL_VERSION}" > version.env
- echo "SHORT_SHA=${SHORT_SHA}" >> version.env
- echo "BRANCH_TAG=${BRANCH_TAG}" >> version.env
artifacts:
reports:
dotenv: discord-app-v2/version.env
only:
- main
- develop
- tags
# Deploy to staging (automatic for develop branch)
deploy:staging:
stage: deploy
image: alpine:latest
needs:
- build
before_script:
- apk add --no-cache openssh-client
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- ssh-keyscan -H $VPS_HOST >> ~/.ssh/known_hosts
script:
- echo "Deploying version ${FULL_VERSION} to staging..."
- |
ssh $VPS_USER@$VPS_HOST << EOF
cd /path/to/discord-bot-staging
# Backup current version
docker inspect discord-bot-staging --format='{{.Image}}' > .last_version || true
# Update docker-compose with specific version
sed -i 's|image: ${DOCKER_IMAGE}:.*|image: ${DOCKER_IMAGE}:staging|' docker-compose.yml
# Pull and deploy
docker-compose pull
docker-compose up -d
# Wait for health check
sleep 10
if docker-compose ps | grep -q "Up (healthy)"; then
echo "✅ Deployment successful!"
docker image prune -f
else
echo "❌ Health check failed!"
exit 1
fi
EOF
environment:
name: staging
url: https://staging-bot.yourdomain.com
only:
- develop
# Deploy to production (manual approval required)
deploy:production:
stage: deploy
image: alpine:latest
needs:
- build
before_script:
- apk add --no-cache openssh-client
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- ssh-keyscan -H $VPS_HOST >> ~/.ssh/known_hosts
script:
- echo "Deploying version ${FULL_VERSION} to production..."
- |
ssh $VPS_USER@$VPS_HOST << EOF
cd /path/to/discord-bot
# Backup current version for rollback
docker inspect discord-bot --format='{{.Image}}' > .last_version || true
echo "${FULL_VERSION}" > .deployed_version
# Create deployment record
echo "$(date -Iseconds) | ${FULL_VERSION} | ${CI_COMMIT_SHORT_SHA} | ${CI_COMMIT_MESSAGE}" >> deployments.log
# Update docker-compose with specific version tag
sed -i 's|image: ${DOCKER_IMAGE}:.*|image: ${DOCKER_IMAGE}:${FULL_VERSION}|' docker-compose.yml
# Pull and deploy
docker-compose pull
docker-compose up -d
# Wait for health check
sleep 10
if docker-compose ps | grep -q "Up (healthy)"; then
echo "✅ Deployment successful!"
echo "Deployed: ${FULL_VERSION}"
docker image prune -f
else
echo "❌ Health check failed! Rolling back..."
LAST_VERSION=\$(cat .last_version)
sed -i "s|image: ${DOCKER_IMAGE}:.*|image: \${LAST_VERSION}|" docker-compose.yml
docker-compose up -d
exit 1
fi
EOF
environment:
name: production
url: https://bot.yourdomain.com
when: manual # Require manual approval
only:
- main
- tags
# Rollback job (manual trigger)
rollback:production:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- ssh-keyscan -H $VPS_HOST >> ~/.ssh/known_hosts
script:
- |
ssh $VPS_USER@$VPS_HOST << 'EOF'
cd /path/to/discord-bot
# Show recent deployments
echo "Recent deployments:"
tail -n 10 deployments.log
# Get last successful version
LAST_VERSION=$(cat .last_version)
echo ""
echo "Rolling back to: ${LAST_VERSION}"
# Rollback
sed -i "s|image: ${DOCKER_IMAGE}:.*|image: ${LAST_VERSION}|" docker-compose.yml
docker-compose up -d
# Record rollback
echo "$(date -Iseconds) | ROLLBACK | ${LAST_VERSION}" >> deployments.log
echo "✅ Rollback complete!"
EOF
environment:
name: production
action: rollback
when: manual
only:
- main

536
.gitlab/DEPLOYMENT_SETUP.md Normal file
View File

@ -0,0 +1,536 @@
# GitLab CI/CD Deployment Setup Guide
This guide will help you set up the complete CI/CD pipeline for Discord Bot v2.0.
---
## 📋 Prerequisites
- GitLab account (free tier)
- Docker Hub account
- SSH access to your Ubuntu VPS
- Git repository with Discord Bot v2.0 code
---
## 🚀 Step 1: GitLab Setup (5 minutes)
### 1.1 Create GitLab Project
```bash
# Option A: Mirror from existing GitHub repo
git remote add gitlab git@gitlab.com:yourusername/discord-bot.git
git push gitlab main
# Option B: Create new GitLab repo and push
# 1. Go to gitlab.com
# 2. Click "New Project"
# 3. Name it "discord-bot"
# 4. Set visibility to "Private"
# 5. Create project
# 6. Follow instructions to push existing repository
```
### 1.2 Add CI/CD Variables
Go to: **Settings > CI/CD > Variables**
Add the following variables (all marked as "Protected" and "Masked"):
| Variable | Value | Description |
|----------|-------|-------------|
| `DOCKER_USERNAME` | your-docker-hub-username | Docker Hub login |
| `DOCKER_PASSWORD` | your-docker-hub-token | Docker Hub access token (NOT password) |
| `SSH_PRIVATE_KEY` | your-ssh-private-key | SSH key for VPS access (see below) |
| `VPS_HOST` | your.vps.ip.address | VPS IP or hostname |
| `VPS_USER` | your-vps-username | SSH username (usually `ubuntu` or `root`) |
**Important Notes:**
- For `DOCKER_PASSWORD`: Use a Docker Hub access token, not your password
- Go to hub.docker.com > Account Settings > Security > New Access Token
- For `SSH_PRIVATE_KEY`: Copy your entire private key including headers
- `cat ~/.ssh/id_rsa` (or whatever key you use)
- Include `-----BEGIN OPENSSH PRIVATE KEY-----` and `-----END OPENSSH PRIVATE KEY-----`
---
## 🔑 Step 2: SSH Key Setup for VPS
### 2.1 Generate SSH Key (if you don't have one)
```bash
# On your local machine
ssh-keygen -t ed25519 -C "gitlab-ci@discord-bot" -f ~/.ssh/gitlab_ci_bot
# Copy public key to VPS
ssh-copy-id -i ~/.ssh/gitlab_ci_bot.pub your-user@your-vps-host
```
### 2.2 Add Private Key to GitLab
```bash
# Copy private key
cat ~/.ssh/gitlab_ci_bot
# Paste entire output (including headers) into GitLab CI/CD variable SSH_PRIVATE_KEY
```
### 2.3 Test SSH Access
```bash
ssh -i ~/.ssh/gitlab_ci_bot your-user@your-vps-host "echo 'Connection successful!'"
```
---
## 🐳 Step 3: Docker Hub Setup
### 3.1 Create Access Token
1. Go to https://hub.docker.com/settings/security
2. Click "New Access Token"
3. Name: "GitLab CI/CD"
4. Permissions: "Read, Write, Delete"
5. Copy token immediately (you won't see it again!)
### 3.2 Create Repository
1. Go to https://hub.docker.com/repositories
2. Click "Create Repository"
3. Name: "discord-bot-v2"
4. Visibility: Private or Public (your choice)
5. Create
---
## 🖥️ Step 4: VPS Setup
### 4.1 Create Directory Structure
```bash
# SSH into your VPS
ssh your-user@your-vps-host
# Create production directory
sudo mkdir -p /opt/discord-bot
sudo chown $USER:$USER /opt/discord-bot
cd /opt/discord-bot
# Create staging directory (optional)
sudo mkdir -p /opt/discord-bot-staging
sudo chown $USER:$USER /opt/discord-bot-staging
```
### 4.2 Create docker-compose.yml (Production)
```bash
cd /opt/discord-bot
nano docker-compose.yml
```
Paste:
```yaml
version: '3.8'
services:
bot:
image: yourusername/discord-bot-v2:latest
container_name: discord-bot
restart: unless-stopped
env_file:
- .env.production
volumes:
- ./logs:/app/logs
- ./storage:/app/storage
networks:
- bot-network
healthcheck:
test: ["CMD", "python", "-c", "import discord; print('ok')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
redis:
image: redis:7-alpine
container_name: discord-redis
restart: unless-stopped
volumes:
- redis-data:/data
networks:
- bot-network
volumes:
redis-data:
networks:
bot-network:
```
### 4.3 Create Environment File
```bash
nano .env.production
```
Paste:
```bash
BOT_TOKEN=your_discord_bot_token
API_TOKEN=your_database_api_token
DB_URL=http://your-api-url:8000
GUILD_ID=your_discord_server_id
LOG_LEVEL=INFO
REDIS_URL=redis://redis:6379
REDIS_CACHE_TTL=300
```
### 4.4 Create Rollback Script
```bash
nano rollback.sh
chmod +x rollback.sh
```
Paste:
```bash
#!/bin/bash
set -e
COMPOSE_FILE="docker-compose.yml"
LOG_FILE="deployments.log"
echo "=== Discord Bot Rollback ==="
echo ""
# Show recent deployments
echo "Recent deployments:"
tail -n 10 $LOG_FILE | column -t -s '|'
echo ""
# Show current version
CURRENT=$(grep "image:" $COMPOSE_FILE | awk '{print $2}')
echo "Current version: $CURRENT"
echo ""
# Show last version
if [ -f .last_version ]; then
LAST=$(cat .last_version)
echo "Last version: $LAST"
echo ""
read -p "Rollback to this version? (y/N): " confirm
if [ "$confirm" != "y" ]; then
echo "Rollback cancelled."
exit 0
fi
# Perform rollback
echo "Rolling back..."
sed -i "s|image:.*|image: $LAST|" $COMPOSE_FILE
docker-compose up -d
# Record rollback
echo "$(date -Iseconds) | ROLLBACK | $LAST" >> $LOG_FILE
echo "✅ Rollback complete!"
else
echo "❌ No previous version found!"
exit 1
fi
```
### 4.5 Initialize Deployment Log
```bash
touch deployments.log
echo "$(date -Iseconds) | INIT | Manual Setup" >> deployments.log
```
---
## 📁 Step 5: Update Project Files
### 5.1 Copy GitLab CI Configuration
```bash
# On your local machine, in project root
cp discord-app-v2/.gitlab-ci.yml .gitlab-ci.yml
# Update DOCKER_IMAGE variable with your Docker Hub username
sed -i 's/yourusername/YOUR_ACTUAL_USERNAME/' .gitlab-ci.yml
```
### 5.2 Update Dockerfile
```bash
# Replace existing Dockerfile with versioned one
cd discord-app-v2
mv Dockerfile Dockerfile.old
cp Dockerfile.versioned Dockerfile
```
### 5.3 Add Version Command to Bot
Edit `discord-app-v2/bot.py` and add:
```python
import os
BOT_VERSION = os.getenv('BOT_VERSION', 'dev')
GIT_COMMIT = os.getenv('BOT_GIT_COMMIT', 'unknown')
BUILD_DATE = os.getenv('BOT_BUILD_DATE', 'unknown')
@bot.tree.command(name="version", description="Display bot version info")
async def version_command(interaction: discord.Interaction):
embed = discord.Embed(
title="🤖 Bot Version Information",
color=0x00ff00
)
embed.add_field(name="Version", value=BOT_VERSION, inline=False)
embed.add_field(name="Git Commit", value=GIT_COMMIT[:8], inline=True)
embed.add_field(name="Build Date", value=BUILD_DATE, inline=True)
await interaction.response.send_message(embed=embed, ephemeral=True)
```
---
## 🧪 Step 6: Test the Pipeline
### 6.1 Initial Commit
```bash
git add .
git commit -m "Setup GitLab CI/CD pipeline"
git push gitlab main
```
### 6.2 Watch Pipeline Execute
1. Go to GitLab project page
2. Click "CI/CD > Pipelines"
3. Watch your pipeline run:
- ✅ Test stage should run
- ✅ Build stage should run
- ⏸️ Deploy stage waits for manual trigger
### 6.3 Manual Production Deploy
1. In GitLab pipeline view, find "deploy:production" job
2. Click the "Play" button ▶️
3. Watch deployment execute
4. Verify on VPS:
```bash
ssh your-user@your-vps-host
cd /opt/discord-bot
docker-compose ps
tail -f logs/discord_bot_v2.log
```
---
## ✅ Step 7: Verify Everything Works
### 7.1 Check Bot Status
```bash
# On VPS
docker-compose ps
# Should show:
# NAME STATUS
# discord-bot Up (healthy)
# discord-redis Up
```
### 7.2 Check Version in Discord
In your Discord server:
```
/version
```
Should show something like:
```
Version: v2.1.1
Git Commit: a1b2c3d4
Build Date: 2025-01-19T10:30:00Z
```
### 7.3 Check Deployment Log
```bash
# On VPS
cat /opt/discord-bot/deployments.log
```
---
## 🔄 Step 8: Create Development Workflow
### 8.1 Create Develop Branch
```bash
git checkout -b develop
git push gitlab develop
```
### 8.2 Set Up Branch Protection (Optional)
In GitLab:
1. Settings > Repository > Protected Branches
2. Protect `main`: Require merge requests, maintainers can push
3. Protect `develop`: Developers can push
---
## 🎯 Usage Workflows
### Regular Feature Development
```bash
# Create feature branch
git checkout -b feature/new-feature develop
# Make changes, commit
git add .
git commit -m "Add new feature"
git push gitlab feature/new-feature
# Merge to develop (auto-deploys to staging if configured)
git checkout develop
git merge feature/new-feature
git push gitlab develop
# After testing, merge to main
git checkout main
git merge develop
git push gitlab main
# In GitLab UI, manually trigger production deploy
```
### Hotfix
```bash
# Create from main
git checkout -b hotfix/critical-bug main
# Fix and commit
git add .
git commit -m "Fix critical bug"
git push gitlab hotfix/critical-bug
# Merge to main
git checkout main
git merge hotfix/critical-bug
git push gitlab main
# Manually deploy in GitLab
```
### Rollback
**Option 1 - GitLab UI:**
1. CI/CD > Pipelines
2. Find pipeline with working version
3. Click "Rollback" on deploy:production job
**Option 2 - VPS Script:**
```bash
ssh your-user@your-vps-host
cd /opt/discord-bot
./rollback.sh
```
**Option 3 - Manual Job:**
1. CI/CD > Pipelines > Latest
2. Click "Play" on rollback:production job
---
## 🐛 Troubleshooting
### Pipeline Fails at Build Stage
**Error**: "Cannot connect to Docker daemon"
**Fix**: GitLab runners need Docker-in-Docker enabled (already configured in `.gitlab-ci.yml`)
**Error**: "Permission denied for Docker Hub"
**Fix**: Check `DOCKER_USERNAME` and `DOCKER_PASSWORD` variables are correct
### Pipeline Fails at Deploy Stage
**Error**: "Permission denied (publickey)"
**Fix**:
1. Check `SSH_PRIVATE_KEY` variable includes headers
2. Verify public key is in VPS `~/.ssh/authorized_keys`
3. Test: `ssh -i ~/.ssh/gitlab_ci_bot your-user@your-vps-host`
**Error**: "docker-compose: command not found"
**Fix**: Install docker-compose on VPS:
```bash
sudo apt-get update
sudo apt-get install docker-compose-plugin
```
### Bot Doesn't Start on VPS
**Check logs:**
```bash
cd /opt/discord-bot
docker-compose logs -f bot
```
**Common issues:**
- Missing/wrong `.env.production` values
- Bot token expired
- Database API unreachable
---
## 📊 Version Bumping
Update version in `.gitlab-ci.yml`:
```yaml
variables:
VERSION_MAJOR: "2"
VERSION_MINOR: "1" # ← Change this for new features
```
**Rules:**
- **Patch**: Auto-increments each pipeline
- **Minor**: Manual bump for new features
- **Major**: Manual bump for breaking changes
---
## 🎓 What You Get
**Automated Testing**: Every push runs tests
**Automated Builds**: Docker images built on CI
**Semantic Versioning**: v2.1.X format
**Manual Production Deploys**: Approval required
**Automatic Rollback**: On health check failure
**Quick Manual Rollback**: 3 methods available
**Deployment History**: Full audit trail
**Version Visibility**: `/version` command
---
## 📞 Support
If you get stuck:
1. Check GitLab pipeline logs
2. Check VPS docker logs: `docker-compose logs`
3. Check deployment log: `cat deployments.log`
4. Verify all CI/CD variables are set correctly
---
**Setup Time**: ~30 minutes
**Deployment Time After Setup**: ~2-3 minutes
**Rollback Time**: ~1-2 minutes
**You're all set! 🚀**

315
.gitlab/QUICK_REFERENCE.md Normal file
View File

@ -0,0 +1,315 @@
# GitLab CI/CD Quick Reference
Quick commands and reminders for daily development.
---
## 🔄 Common Workflows
### Deploy Feature to Production
```bash
# 1. Develop feature
git checkout -b feature/my-feature develop
# ... make changes ...
git commit -m "Add my feature"
git push gitlab feature/my-feature
# 2. Merge to develop for staging test (optional)
git checkout develop
git merge feature/my-feature
git push gitlab develop
# → Auto-deploys to staging
# 3. Merge to main
git checkout main
git merge develop
git push gitlab main
# 4. In GitLab UI: CI/CD > Pipelines > Click ▶️ on deploy:production
```
### Emergency Rollback
```bash
# Option 1: VPS Script (fastest)
ssh user@vps "cd /opt/discord-bot && ./rollback.sh"
# Option 2: GitLab UI
# CI/CD > Pipelines > Click ▶️ on rollback:production
# Option 3: Manual
ssh user@vps
cd /opt/discord-bot
# Edit docker-compose.yml to previous version
docker-compose up -d
```
### Check Deployment Status
```bash
# Check running version on VPS
ssh user@vps "cd /opt/discord-bot && docker inspect discord-bot --format '{{.Config.Labels}}' | grep version"
# Check recent deployments
ssh user@vps "cd /opt/discord-bot && tail -10 deployments.log"
# Check bot health
ssh user@vps "cd /opt/discord-bot && docker-compose ps"
```
---
## 🏷️ Version Management
### Current Version Strategy
| Format | Example | Auto/Manual | When |
|--------|---------|-------------|------|
| Major | `v2.x.x` | Manual | Breaking changes |
| Minor | `v2.1.x` | Manual | New features |
| Patch | `v2.1.123` | Auto | Every build |
### Bump Version
Edit `.gitlab-ci.yml`:
```yaml
variables:
VERSION_MAJOR: "2"
VERSION_MINOR: "2" # ← Change this
```
Then:
```bash
git add .gitlab-ci.yml
git commit -m "Bump version to v2.2.x"
git push gitlab main
```
---
## 🐳 Docker Tags Generated
Every build creates:
- `v2.1.123` - Full semantic version
- `a1b2c3d` - Git commit SHA
- `main-a1b2c3d` - Branch + SHA
- `latest` - Latest main branch (production)
- `staging` - Latest develop branch (staging)
---
## 🔍 Useful Commands
### Check Pipeline Status
```bash
# From CLI (requires gitlab-ci-lint or gitlab CLI)
gitlab-ci-lint .gitlab-ci.yml
# Or visit:
# https://gitlab.com/yourusername/discord-bot/-/pipelines
```
### View Logs
```bash
# Bot logs
ssh user@vps "cd /opt/discord-bot && docker-compose logs -f bot"
# Redis logs
ssh user@vps "cd /opt/discord-bot && docker-compose logs -f redis"
# Deployment history
ssh user@vps "cd /opt/discord-bot && cat deployments.log | column -t -s '|'"
```
### Test Locally Before Push
```bash
cd discord-app-v2
python -m pytest --tb=short -q
```
### Build Docker Image Locally
```bash
cd discord-app-v2
docker build \
--build-arg VERSION="dev" \
--build-arg GIT_COMMIT=$(git rev-parse --short HEAD) \
--build-arg BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
-t discord-bot-v2:local .
```
---
## 🎯 GitLab CI/CD Variables
**Required Variables** (Settings > CI/CD > Variables):
| Variable | Type | Example |
|----------|------|---------|
| `DOCKER_USERNAME` | Masked | `youruser` |
| `DOCKER_PASSWORD` | Masked | `dckr_pat_abc123...` |
| `SSH_PRIVATE_KEY` | Masked | `-----BEGIN OPENSSH...` |
| `VPS_HOST` | Plain | `123.456.789.0` |
| `VPS_USER` | Plain | `ubuntu` |
---
## 🚨 Emergency Procedures
### Build Failing
1. Check GitLab pipeline logs
2. Run tests locally: `pytest`
3. Check Docker build: `docker build ...`
4. Fix issues
5. Push again
### Deploy Failing
1. Check SSH access: `ssh user@vps`
2. Check docker-compose.yml exists
3. Check .env.production has all vars
4. Check VPS disk space: `df -h`
5. Check Docker is running: `docker ps`
### Bot Not Starting After Deploy
```bash
# SSH to VPS
ssh user@vps
cd /opt/discord-bot
# Check logs
docker-compose logs bot | tail -50
# Check health
docker-compose ps
# Restart
docker-compose restart bot
# Nuclear option: full restart
docker-compose down
docker-compose up -d
```
### Rollback Needed Immediately
```bash
# Fastest: VPS script
ssh user@vps "cd /opt/discord-bot && ./rollback.sh"
# Confirm version
ssh user@vps "cd /opt/discord-bot && docker-compose ps"
```
---
## 📊 Health Checks
### Bot Health
```bash
# Check if bot is healthy
ssh user@vps "docker inspect discord-bot --format '{{.State.Health.Status}}'"
# Should show: healthy
# Check Discord connection (in Discord)
/version
```
### Redis Health
```bash
ssh user@vps "docker exec discord-redis redis-cli ping"
# Should show: PONG
```
### Full System Check
```bash
ssh user@vps << 'EOF'
cd /opt/discord-bot
echo "=== Container Status ==="
docker-compose ps
echo ""
echo "=== Recent Logs ==="
docker-compose logs --tail=10 bot
echo ""
echo "=== Deployment History ==="
tail -5 deployments.log
EOF
```
---
## 🔐 Security Reminders
- ✅ Never commit `.env` files
- ✅ Use GitLab CI/CD variables for secrets
- ✅ Mark all secrets as "Masked" in GitLab
- ✅ Rotate SSH keys periodically
- ✅ Use Docker Hub access tokens, not passwords
- ✅ Keep VPS firewall enabled
---
## 📈 Monitoring
### Check Metrics
```bash
# If Prometheus is set up
curl http://vps-ip:8000/metrics
# Check bot uptime
ssh user@vps "docker inspect discord-bot --format '{{.State.StartedAt}}'"
```
### Watch Live Logs
```bash
ssh user@vps "cd /opt/discord-bot && docker-compose logs -f --tail=100"
```
---
## 🎓 Tips & Tricks
### Skip CI for Minor Changes
```bash
git commit -m "Update README [skip ci]"
```
### Test in Staging First
```bash
# Push to develop → auto-deploys to staging
git push gitlab develop
# Test thoroughly, then merge to main
```
### View All Available Versions
```bash
# On Docker Hub
docker search yourusername/discord-bot-v2
# On VPS
ssh user@vps "docker images yourusername/discord-bot-v2"
```
### Clean Up Old Images
```bash
# On VPS (run monthly)
ssh user@vps "docker image prune -a -f"
```
---
## 📞 Getting Help
1. **Check Logs**: Always start with logs
2. **GitLab Pipeline**: Look at failed job output
3. **Docker Logs**: `docker-compose logs`
4. **Deployment Log**: `cat deployments.log`
---
**Last Updated**: January 2025
**Bot Version**: v2.1.x
**CI/CD Platform**: GitLab CI/CD

517
.gitlab/VPS_SCRIPTS.md Normal file
View File

@ -0,0 +1,517 @@
# VPS Helper Scripts
Collection of useful scripts for managing the Discord bot on your VPS.
---
## 📍 Script Locations
All scripts should be placed in `/opt/discord-bot/` on your VPS.
```bash
/opt/discord-bot/
├── docker-compose.yml
├── .env.production
├── rollback.sh # Rollback to previous version
├── deploy-manual.sh # Manual deployment script
├── health-check.sh # Check bot health
├── logs-view.sh # View logs easily
├── cleanup.sh # Clean up old Docker images
└── deployments.log # Auto-generated deployment history
```
---
## 🔄 rollback.sh
Already created during setup. For reference:
```bash
#!/bin/bash
set -e
COMPOSE_FILE="docker-compose.yml"
LOG_FILE="deployments.log"
echo "=== Discord Bot Rollback ==="
echo ""
# Show recent deployments
echo "Recent deployments:"
tail -n 10 $LOG_FILE | column -t -s '|'
echo ""
# Show current version
CURRENT=$(grep "image:" $COMPOSE_FILE | awk '{print $2}')
echo "Current version: $CURRENT"
echo ""
# Show last version
if [ -f .last_version ]; then
LAST=$(cat .last_version)
echo "Last version: $LAST"
echo ""
read -p "Rollback to this version? (y/N): " confirm
if [ "$confirm" != "y" ]; then
echo "Rollback cancelled."
exit 0
fi
# Perform rollback
echo "Rolling back..."
sed -i "s|image:.*|image: $LAST|" $COMPOSE_FILE
docker-compose up -d
# Record rollback
echo "$(date -Iseconds) | ROLLBACK | $LAST" >> $LOG_FILE
echo "✅ Rollback complete!"
else
echo "❌ No previous version found!"
exit 1
fi
```
---
## 🚀 deploy-manual.sh
For manual deployments (bypassing GitLab):
```bash
#!/bin/bash
set -e
COMPOSE_FILE="docker-compose.yml"
LOG_FILE="deployments.log"
IMAGE="yourusername/discord-bot-v2"
echo "=== Manual Discord Bot Deployment ==="
echo ""
# Show available versions
echo "Available versions on Docker Hub:"
echo "(Showing last 10 tags)"
curl -s "https://hub.docker.com/v2/repositories/${IMAGE}/tags?page_size=10" | \
grep -o '"name":"[^"]*' | \
grep -o '[^"]*$'
echo ""
# Prompt for version
read -p "Enter version to deploy (or 'latest'): " VERSION
if [ -z "$VERSION" ]; then
echo "❌ No version specified!"
exit 1
fi
# Backup current version
docker inspect discord-bot --format='{{.Image}}' > .last_version || true
# Update docker-compose
sed -i "s|image: ${IMAGE}:.*|image: ${IMAGE}:${VERSION}|" $COMPOSE_FILE
# Pull and deploy
echo "Pulling ${IMAGE}:${VERSION}..."
docker-compose pull
echo "Deploying..."
docker-compose up -d
# Wait for health check
echo "Waiting for health check..."
sleep 10
if docker-compose ps | grep -q "Up (healthy)"; then
echo "✅ Deployment successful!"
echo "$(date -Iseconds) | MANUAL | ${VERSION} | Manual deployment" >> $LOG_FILE
docker image prune -f
else
echo "❌ Health check failed! Rolling back..."
LAST_VERSION=$(cat .last_version)
sed -i "s|image: ${IMAGE}:.*|image: ${LAST_VERSION}|" $COMPOSE_FILE
docker-compose up -d
exit 1
fi
```
**Usage:**
```bash
cd /opt/discord-bot
./deploy-manual.sh
```
---
## 🏥 health-check.sh
Comprehensive health check:
```bash
#!/bin/bash
echo "=== Discord Bot Health Check ==="
echo ""
# Container status
echo "📦 Container Status:"
docker-compose ps
echo ""
# Bot health
BOT_HEALTH=$(docker inspect discord-bot --format '{{.State.Health.Status}}' 2>/dev/null || echo "unknown")
echo "🤖 Bot Health: $BOT_HEALTH"
# Redis health
REDIS_HEALTH=$(docker exec discord-redis redis-cli ping 2>/dev/null || echo "unreachable")
echo "💾 Redis Health: $REDIS_HEALTH"
echo ""
# Uptime
BOT_STARTED=$(docker inspect discord-bot --format '{{.State.StartedAt}}' 2>/dev/null || echo "unknown")
echo "⏱️ Bot Started: $BOT_STARTED"
echo ""
# Resource usage
echo "💻 Resource Usage:"
docker stats --no-stream discord-bot discord-redis
echo ""
# Recent errors
echo "⚠️ Recent Errors (last 10):"
docker-compose logs --tail=100 bot 2>&1 | grep -i error | tail -10 || echo "No recent errors"
echo ""
# Deployment history
echo "📜 Recent Deployments:"
tail -5 deployments.log | column -t -s '|'
echo ""
# Summary
echo "=== Summary ==="
if [ "$BOT_HEALTH" = "healthy" ] && [ "$REDIS_HEALTH" = "PONG" ]; then
echo "✅ All systems operational"
exit 0
else
echo "❌ Issues detected"
exit 1
fi
```
**Usage:**
```bash
cd /opt/discord-bot
./health-check.sh
```
**Cron for daily checks:**
```bash
# Run health check daily at 6 AM
0 6 * * * /opt/discord-bot/health-check.sh | mail -s "Bot Health Report" you@email.com
```
---
## 📋 logs-view.sh
Easy log viewing:
```bash
#!/bin/bash
echo "Discord Bot Logs Viewer"
echo ""
echo "Select option:"
echo "1) Live bot logs (follow)"
echo "2) Last 100 bot logs"
echo "3) Last 50 error logs"
echo "4) All logs (bot + redis)"
echo "5) Deployment history"
echo "6) Search logs"
echo ""
read -p "Choice [1-6]: " choice
case $choice in
1)
echo "Following live logs (Ctrl+C to exit)..."
docker-compose logs -f --tail=50 bot
;;
2)
docker-compose logs --tail=100 bot
;;
3)
docker-compose logs --tail=500 bot | grep -i error | tail -50
;;
4)
docker-compose logs --tail=100
;;
5)
cat deployments.log | column -t -s '|'
;;
6)
read -p "Search term: " term
docker-compose logs bot | grep -i "$term" | tail -50
;;
*)
echo "Invalid option"
exit 1
;;
esac
```
**Usage:**
```bash
cd /opt/discord-bot
./logs-view.sh
```
---
## 🧹 cleanup.sh
Clean up old Docker images and data:
```bash
#!/bin/bash
set -e
echo "=== Discord Bot Cleanup ==="
echo ""
# Show current disk usage
echo "💾 Current Disk Usage:"
df -h /var/lib/docker
echo ""
# Show Docker disk usage
echo "🐳 Docker Disk Usage:"
docker system df
echo ""
read -p "Proceed with cleanup? (y/N): " confirm
if [ "$confirm" != "y" ]; then
echo "Cleanup cancelled."
exit 0
fi
# Stop containers temporarily
echo "Stopping containers..."
docker-compose down
# Prune images (keep recent ones)
echo "Pruning old images..."
docker image prune -a -f --filter "until=720h" # Keep images from last 30 days
# Prune volumes (be careful!)
# Uncomment if you want to clean volumes
# echo "Pruning unused volumes..."
# docker volume prune -f
# Prune build cache
echo "Pruning build cache..."
docker builder prune -f
# Restart containers
echo "Restarting containers..."
docker-compose up -d
# Show new disk usage
echo ""
echo "✅ Cleanup complete!"
echo ""
echo "💾 New Disk Usage:"
df -h /var/lib/docker
echo ""
docker system df
```
**Usage:**
```bash
cd /opt/discord-bot
./cleanup.sh
```
**Cron for monthly cleanup:**
```bash
# Run cleanup first Sunday of month at 3 AM
0 3 1-7 * 0 /opt/discord-bot/cleanup.sh
```
---
## 🔍 version-info.sh
Show detailed version information:
```bash
#!/bin/bash
echo "=== Version Information ==="
echo ""
# Docker image version
echo "🐳 Docker Image:"
docker inspect discord-bot --format '{{.Config.Image}}'
echo ""
# Image labels
echo "🏷️ Build Metadata:"
docker inspect discord-bot --format '{{json .Config.Labels}}' | jq '.'
echo ""
# Environment variables (version info only)
echo "🔧 Environment:"
docker inspect discord-bot --format '{{range .Config.Env}}{{println .}}{{end}}' | grep BOT_
echo ""
# Currently deployed
echo "📦 Currently Deployed:"
cat .deployed_version 2>/dev/null || echo "Unknown"
echo ""
# Last deployment
echo "📅 Last Deployment:"
tail -1 deployments.log | column -t -s '|'
echo ""
# Available for rollback
echo "⏮️ Available for Rollback:"
cat .last_version 2>/dev/null || echo "None"
```
**Usage:**
```bash
cd /opt/discord-bot
./version-info.sh
```
---
## 📊 status-dashboard.sh
Combined status dashboard:
```bash
#!/bin/bash
clear
echo "╔════════════════════════════════════════════╗"
echo "║ Discord Bot Status Dashboard ║"
echo "╚════════════════════════════════════════════╝"
echo ""
# Version
echo "📦 Version: $(cat .deployed_version 2>/dev/null || echo 'Unknown')"
echo ""
# Health
BOT_HEALTH=$(docker inspect discord-bot --format '{{.State.Health.Status}}' 2>/dev/null || echo "down")
REDIS_HEALTH=$(docker exec discord-redis redis-cli ping 2>/dev/null || echo "DOWN")
if [ "$BOT_HEALTH" = "healthy" ]; then
echo "✅ Bot: $BOT_HEALTH"
else
echo "❌ Bot: $BOT_HEALTH"
fi
if [ "$REDIS_HEALTH" = "PONG" ]; then
echo "✅ Redis: UP"
else
echo "❌ Redis: $REDIS_HEALTH"
fi
echo ""
# Uptime
STARTED=$(docker inspect discord-bot --format '{{.State.StartedAt}}' 2>/dev/null || echo "unknown")
echo "⏱️ Uptime: $STARTED"
echo ""
# Resource usage
echo "💻 Resources:"
docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}" discord-bot discord-redis
echo ""
# Recent deployments
echo "📜 Recent Deployments:"
tail -3 deployments.log | column -t -s '|'
echo ""
# Errors
ERROR_COUNT=$(docker-compose logs --tail=1000 bot 2>&1 | grep -ic error || echo 0)
echo "⚠️ Errors (last 1000 lines): $ERROR_COUNT"
echo ""
echo "╚════════════════════════════════════════════╝"
echo "Press Ctrl+C to exit, or run with 'watch' for live updates"
```
**Usage:**
```bash
# One-time view
cd /opt/discord-bot
./status-dashboard.sh
# Live updating (every 2 seconds)
watch -n 2 /opt/discord-bot/status-dashboard.sh
```
---
## 🚀 Quick Setup
Install all scripts at once:
```bash
ssh user@vps << 'EOF'
cd /opt/discord-bot
# Make scripts executable
chmod +x rollback.sh
chmod +x deploy-manual.sh
chmod +x health-check.sh
chmod +x logs-view.sh
chmod +x cleanup.sh
chmod +x version-info.sh
chmod +x status-dashboard.sh
echo "✅ All scripts are ready!"
ls -lah *.sh
EOF
```
---
## 🎯 Useful Aliases
Add to `~/.bashrc` on VPS:
```bash
# Discord Bot aliases
alias bot-status='cd /opt/discord-bot && ./status-dashboard.sh'
alias bot-logs='cd /opt/discord-bot && ./logs-view.sh'
alias bot-health='cd /opt/discord-bot && ./health-check.sh'
alias bot-rollback='cd /opt/discord-bot && ./rollback.sh'
alias bot-deploy='cd /opt/discord-bot && ./deploy-manual.sh'
alias bot-restart='cd /opt/discord-bot && docker-compose restart bot'
alias bot-down='cd /opt/discord-bot && docker-compose down'
alias bot-up='cd /opt/discord-bot && docker-compose up -d'
# Quick status
alias bs='bot-status'
alias bl='bot-logs'
```
Then:
```bash
source ~/.bashrc
# Now you can use:
bs # Status dashboard
bl # View logs
bot-health # Health check
```
---
**Tip**: Create a `README.txt` in `/opt/discord-bot/` listing all available scripts and their purposes!

13
.mcp.json Normal file
View File

@ -0,0 +1,13 @@
{
"mcpServers": {
"readme-context": {
"command": "node",
"args": [
"/home/cal/.claude/mcp-servers/readme-context/dist/index.js"
],
"env": {
"PROJECT_ROOT": "/mnt/NV2/Development/major-domo/discord-app-v2"
}
}
}
}

49
Dockerfile.versioned Normal file
View File

@ -0,0 +1,49 @@
# Enhanced Dockerfile with Version Metadata
# Rename to Dockerfile when ready to use
# Build stage
FROM python:3.11-slim as builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# Runtime stage
FROM python:3.11-slim
WORKDIR /app
# Copy dependencies from builder
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
# Add version metadata as build args
ARG VERSION="dev"
ARG GIT_COMMIT="unknown"
ARG BUILD_DATE="unknown"
# Store as labels (visible via `docker inspect`)
LABEL org.opencontainers.image.version="${VERSION}"
LABEL org.opencontainers.image.revision="${GIT_COMMIT}"
LABEL org.opencontainers.image.created="${BUILD_DATE}"
LABEL org.opencontainers.image.title="Discord Bot v2.0"
LABEL org.opencontainers.image.description="SBA Discord Bot - Modernized"
# Store as environment variables (accessible in bot)
ENV BOT_VERSION="${VERSION}"
ENV BOT_GIT_COMMIT="${GIT_COMMIT}"
ENV BOT_BUILD_DATE="${BUILD_DATE}"
# Copy application
COPY . .
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD python -c "import discord; print('ok')" || exit 1
CMD ["python", "bot.py"]

View File

@ -7,7 +7,7 @@ Provides connection pooling, proper error handling, and session management.
import aiohttp
import logging
from typing import Optional, List, Dict, Any, Union
from urllib.parse import urljoin
from urllib.parse import urljoin, quote
from contextlib import asynccontextmanager
from config import get_config
@ -60,26 +60,28 @@ class APIClient:
'User-Agent': 'SBA-Discord-Bot-v2/1.0'
}
def _build_url(self, endpoint: str, api_version: int = 3, object_id: Optional[int] = None) -> str:
def _build_url(self, endpoint: str, api_version: int = 3, object_id: Optional[Union[int, str]] = None) -> str:
"""
Build complete API URL from components.
Args:
endpoint: API endpoint path
api_version: API version number (default: 3)
object_id: Optional object ID to append
object_id: Optional object ID to append (int for numeric IDs, str for moveids)
Returns:
Complete URL for API request
"""
# Handle already complete URLs
if endpoint.startswith(('http://', 'https://')) or '/api/' in endpoint:
return endpoint
path = f"v{api_version}/{endpoint}"
if object_id is not None:
path += f"/{object_id}"
# URL-encode the object_id to handle special characters (e.g., colons in moveids)
encoded_id = quote(str(object_id), safe='')
path += f"/{encoded_id}"
return urljoin(self.base_url.rstrip('/') + '/', path)
def _add_params(self, url: str, params: Optional[List[tuple]] = None) -> str:
@ -121,9 +123,9 @@ class APIClient:
logger.debug("Created new aiohttp session with connection pooling")
async def get(
self,
endpoint: str,
object_id: Optional[int] = None,
self,
endpoint: str,
object_id: Optional[Union[int, str]] = None,
params: Optional[List[tuple]] = None,
api_version: int = 3,
timeout: Optional[int] = None
@ -251,10 +253,10 @@ class APIClient:
raise APIException(f"POST failed: {e}")
async def put(
self,
endpoint: str,
self,
endpoint: str,
data: Dict[str, Any],
object_id: Optional[int] = None,
object_id: Optional[Union[int, str]] = None,
api_version: int = 3,
timeout: Optional[int] = None
) -> Optional[Dict[str, Any]]:
@ -313,7 +315,7 @@ class APIClient:
self,
endpoint: str,
data: Optional[Dict[str, Any]] = None,
object_id: Optional[int] = None,
object_id: Optional[Union[int, str]] = None,
api_version: int = 3,
timeout: Optional[int] = None,
use_query_params: bool = False
@ -339,13 +341,17 @@ class APIClient:
# Add data as query parameters if requested
if use_query_params and data:
params = [(k, str(v)) for k, v in data.items()]
# Handle None values by converting to empty string
# The database API's PATCH endpoint treats empty strings as NULL for nullable fields
# Example: {'il_return': None} → ?il_return= → Database sets il_return to NULL
params = [(k, '' if v is None else str(v)) for k, v in data.items()]
url = self._add_params(url, params)
await self._ensure_session()
try:
logger.debug(f"PATCH: {endpoint} id: {object_id} data: {data} use_query_params: {use_query_params}")
logger.debug(f"PATCH URL: {url}")
request_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None
@ -353,6 +359,7 @@ class APIClient:
kwargs = {}
if data is not None and not use_query_params:
kwargs['json'] = data
logger.debug(f"PATCH JSON body: {data}")
async with self._session.patch(url, timeout=request_timeout, **kwargs) as response:
if response.status == 401:
@ -381,9 +388,9 @@ class APIClient:
raise APIException(f"PATCH failed: {e}")
async def delete(
self,
endpoint: str,
object_id: Optional[int] = None,
self,
endpoint: str,
object_id: Optional[Union[int, str]] = None,
api_version: int = 3,
timeout: Optional[int] = None
) -> bool:

22
bot.py
View File

@ -93,7 +93,7 @@ class SBABot(commands.Bot):
# Initialize cleanup tasks
await self._setup_background_tasks()
# Smart command syncing: auto-sync in development if changes detected
# Smart command syncing: auto-sync in development if changes detected; !admin-sync for first sync
config = get_config()
if config.is_development:
if await self._should_sync_commands():
@ -104,7 +104,7 @@ class SBABot(commands.Bot):
self.logger.info("Development mode: no command changes detected, skipping sync")
else:
self.logger.info("Production mode: commands loaded but not auto-synced")
self.logger.info("Use /sync command to manually sync when needed")
self.logger.info("Use /admin-sync command to manually sync when needed")
async def _load_command_packages(self):
"""Load all command packages with resilient error handling."""
@ -173,6 +173,11 @@ class SBABot(commands.Bot):
from tasks.custom_command_cleanup import setup_cleanup_task
self.custom_command_cleanup = setup_cleanup_task(self)
# Initialize transaction freeze/thaw task
from tasks.transaction_freeze import setup_freeze_task
self.transaction_freeze = setup_freeze_task(self)
self.logger.info("✅ Transaction freeze/thaw task started")
# Initialize voice channel cleanup service
from commands.voice.cleanup_service import VoiceChannelCleanupService
self.voice_cleanup_service = VoiceChannelCleanupService()
@ -285,7 +290,7 @@ class SBABot(commands.Bot):
async def _sync_commands(self):
"""Internal method to sync commands."""
config = get_config()
if config.guild_id:
if config.testing and config.guild_id:
guild = discord.Object(id=config.guild_id)
self.tree.copy_global_to(guild=guild)
synced = await self.tree.sync(guild=guild)
@ -313,7 +318,7 @@ class SBABot(commands.Bot):
async def close(self):
"""Clean shutdown of the bot."""
self.logger.info("Bot shutting down...")
# Stop background tasks
if hasattr(self, 'custom_command_cleanup'):
try:
@ -322,13 +327,20 @@ class SBABot(commands.Bot):
except Exception as e:
self.logger.error(f"Error stopping cleanup task: {e}")
if hasattr(self, 'transaction_freeze'):
try:
self.transaction_freeze.weekly_loop.cancel()
self.logger.info("Transaction freeze/thaw task stopped")
except Exception as e:
self.logger.error(f"Error stopping transaction freeze task: {e}")
if hasattr(self, 'voice_cleanup_service'):
try:
self.voice_cleanup_service.stop_monitoring()
self.logger.info("Voice channel cleanup service stopped")
except Exception as e:
self.logger.error(f"Error stopping voice cleanup service: {e}")
# Call parent close method
await super().close()
self.logger.info("Bot shutdown complete")

View File

@ -1,555 +0,0 @@
# Commands Package Documentation
**Discord Bot v2.0 - Scalable Command Architecture**
This document outlines the command architecture, patterns, and best practices established for the SBA Discord Bot v2.0.
## 📁 Architecture Overview
### **Package Structure**
```
commands/
├── README.md # This documentation
├── __init__.py # Future: Global command utilities
└── players/ # Player-related commands
├── __init__.py # Package setup with resilient loading
└── info.py # Player information commands
```
### **Future Expansion (Phase 2+)**
```
commands/
├── README.md
├── __init__.py
├── players/ # ✅ COMPLETED
│ ├── __init__.py
│ ├── info.py # /player command
│ ├── search.py # /player-search, /player-lookup
│ ├── stats.py # /player-stats, /player-compare
│ └── rankings.py # /player-rankings, /leaderboard
├── teams/ # 🔄 PLANNED
│ ├── __init__.py
│ ├── roster.py # /team-roster, /team-depth
│ ├── stats.py # /team-stats, /team-leaders
│ └── schedule.py # /team-schedule, /team-record
├── league/ # 🔄 PLANNED
│ ├── __init__.py
│ ├── standings.py # /standings, /playoff-race
│ ├── schedule.py # /schedule, /scores
│ └── leaders.py # /leaders, /awards
├── draft/ # 🔄 PLANNED
│ ├── __init__.py
│ ├── picks.py # /draft-pick, /draft-order
│ ├── board.py # /draft-board, /draft-list
│ └── timer.py # /draft-status, /draft-timer
├── transactions/ # 🔄 PLANNED
│ ├── __init__.py
│ ├── trades.py # /trade, /trade-history
│ ├── waivers.py # /waivers, /free-agents
│ └── history.py # /transaction-history
├── admin/ # 🔄 PLANNED
│ ├── __init__.py
│ ├── league.py # /admin-season, /admin-week
│ ├── draft.py # /admin-draft, /admin-timer
│ └── system.py # /health, /sync-commands
└── utils/ # 🔄 PLANNED
├── __init__.py
├── dice.py # /roll, /dice
└── fun.py # Fun/misc commands
```
## 🏗️ Design Principles
### **1. Single Responsibility**
- Each file handles 2-4 closely related commands
- Clear logical grouping by domain (players, teams, etc.)
- Focused functionality reduces complexity
### **2. Resilient Loading**
- One failed cog doesn't break the entire package
- Loop-based loading with comprehensive error handling
- Clear logging for debugging and monitoring
### **3. Scalable Architecture**
- Easy to add new packages and cogs
- Consistent patterns across all command groups
- Future-proof structure for bot growth
### **4. Modern Discord.py Patterns**
- Application commands (slash commands) only
- Proper error handling with user-friendly messages
- Async/await throughout
- Type hints and comprehensive documentation
## 🔧 Implementation Patterns
### **Command Package Structure**
#### **Individual Command File (e.g., `players/info.py`)**
```python
"""
Player Information Commands
Implements slash commands for displaying player information and statistics.
"""
import logging
from typing import Optional
import discord
from discord.ext import commands
from services.player_service import player_service
from exceptions import BotException
logger = logging.getLogger(f'{__name__}.PlayerInfoCommands')
class PlayerInfoCommands(commands.Cog):
"""Player information and statistics command handlers."""
def __init__(self, bot: commands.Bot):
self.bot = bot
@discord.app_commands.command(
name="player",
description="Display player information and statistics"
)
@discord.app_commands.describe(
name="Player name to search for",
season="Season to show stats for (defaults to current season)"
)
async def player_info(
self,
interaction: discord.Interaction,
name: str,
season: Optional[int] = None
):
"""Display player card with statistics."""
try:
# Always defer for potentially slow API calls
await interaction.response.defer()
# Command implementation here
# Use logger for error logging
# Create Discord embeds for responses
except Exception as e:
logger.error(f"Player info command error: {e}", exc_info=True)
error_msg = "❌ Error retrieving player information."
if interaction.response.is_done():
await interaction.followup.send(error_msg, ephemeral=True)
else:
await interaction.response.send_message(error_msg, ephemeral=True)
async def setup(bot: commands.Bot):
"""Load the player info commands cog."""
await bot.add_cog(PlayerInfoCommands(bot))
```
#### **Package __init__.py with Resilient Loading**
```python
"""
Player Commands Package
This package contains all player-related Discord commands organized into focused modules.
"""
import logging
from discord.ext import commands
from .info import PlayerInfoCommands
# Future imports:
# from .search import PlayerSearchCommands
# from .stats import PlayerStatsCommands
logger = logging.getLogger(__name__)
async def setup_players(bot: commands.Bot):
"""
Setup all player command modules.
Returns:
tuple: (successful_count, failed_count, failed_modules)
"""
# Define all player command cogs to load
player_cogs = [
("PlayerInfoCommands", PlayerInfoCommands),
# Future cogs:
# ("PlayerSearchCommands", PlayerSearchCommands),
# ("PlayerStatsCommands", PlayerStatsCommands),
]
successful = 0
failed = 0
failed_modules = []
for cog_name, cog_class in player_cogs:
try:
await bot.add_cog(cog_class(bot))
logger.info(f"✅ Loaded {cog_name}")
successful += 1
except Exception as e:
logger.error(f"❌ Failed to load {cog_name}: {e}", exc_info=True)
failed += 1
failed_modules.append(cog_name)
# Log summary
if failed == 0:
logger.info(f"🎉 All {successful} player command modules loaded successfully")
else:
logger.warning(f"⚠️ Player commands loaded with issues: {successful} successful, {failed} failed")
return successful, failed, failed_modules
# Export the setup function for easy importing
__all__ = ['setup_players', 'PlayerInfoCommands']
```
## 🔄 Smart Command Syncing
### **Hash-Based Change Detection**
The bot implements smart command syncing that only updates Discord when commands actually change:
**Development Mode:**
- Automatically detects command changes using SHA-256 hashing
- Only syncs when changes are detected
- Saves hash to `.last_command_hash` for comparison
- Prevents unnecessary Discord API calls
**Production Mode:**
- No automatic syncing
- Commands must be manually synced using `/sync` command
- Prevents accidental command updates in production
### **How It Works**
1. **Hash Generation**: Creates hash of command names, descriptions, and parameters
2. **Comparison**: Compares current hash with stored hash from `.last_command_hash`
3. **Conditional Sync**: Only syncs if hashes differ or no previous hash exists
4. **Hash Storage**: Saves new hash after successful sync
### **Benefits**
- ✅ **API Efficiency**: Avoids Discord rate limits
- ✅ **Development Speed**: Fast restarts when no command changes
- ✅ **Production Safety**: No accidental command updates
- ✅ **Consistency**: Commands stay consistent across restarts
## 🚀 Bot Integration
### **Command Loading in bot.py**
```python
async def setup_hook(self):
"""Called when the bot is starting up."""
# Load command packages
await self._load_command_packages()
# Smart command syncing: auto-sync in development if changes detected
config = get_config()
if config.is_development:
if await self._should_sync_commands():
self.logger.info("Development mode: changes detected, syncing commands...")
await self._sync_commands()
await self._save_command_hash()
else:
self.logger.info("Development mode: no command changes detected, skipping sync")
else:
self.logger.info("Production mode: commands loaded but not auto-synced")
async def _load_command_packages(self):
"""Load all command packages with resilient error handling."""
from commands.players import setup_players
# Define command packages to load
command_packages = [
("players", setup_players),
# Future packages:
# ("teams", setup_teams),
# ("league", setup_league),
]
# Loop-based loading with error isolation
for package_name, setup_func in command_packages:
try:
successful, failed, failed_modules = await setup_func(self)
# Log results
except Exception as e:
self.logger.error(f"❌ Failed to load {package_name} package: {e}")
```
## 📋 Development Guidelines
### **Adding New Command Packages**
#### **1. Create Package Structure**
```bash
mkdir commands/teams
touch commands/teams/__init__.py
touch commands/teams/roster.py
```
#### **2. Implement Command Module**
- Follow the pattern from `players/info.py`
- Use module-level logger: `logger = logging.getLogger(f'{__name__}.ClassName')`
- Always defer responses: `await interaction.response.defer()`
- Comprehensive error handling with user-friendly messages
- Type hints and docstrings
#### **3. Create Package Setup Function**
- Follow the pattern from `players/__init__.py`
- Use loop-based cog loading with error isolation
- Return tuple: `(successful, failed, failed_modules)`
- Comprehensive logging with emojis for quick scanning
#### **4. Register in Bot**
- Add import to `_load_command_packages()` in `bot.py`
- Add to `command_packages` list
- Test in development environment
### **Adding Commands to Existing Packages**
#### **1. Create New Command Module**
```python
# commands/players/search.py
class PlayerSearchCommands(commands.Cog):
# Implementation
pass
async def setup(bot: commands.Bot):
await bot.add_cog(PlayerSearchCommands(bot))
```
#### **2. Update Package __init__.py**
```python
from .search import PlayerSearchCommands
# Add to player_cogs list
player_cogs = [
("PlayerInfoCommands", PlayerInfoCommands),
("PlayerSearchCommands", PlayerSearchCommands), # New cog
]
```
#### **3. Test Import Structure**
```python
# Verify imports work
from commands.players import setup_players
from commands.players.search import PlayerSearchCommands
```
## 🎯 Best Practices
### **Command Implementation**
1. **Always defer responses** for API calls: `await interaction.response.defer()`
2. **Use ephemeral responses** for errors: `ephemeral=True`
3. **Comprehensive error handling** with try/except blocks
4. **User-friendly error messages** with emojis
5. **Proper logging** with context and stack traces
6. **Type hints** on all parameters and return values
7. **Descriptive docstrings** for commands and methods
### **Package Organization**
1. **2-4 commands per file** maximum
2. **Logical grouping** by functionality/domain
3. **Consistent naming** patterns across packages
4. **Module-level logging** for clean, consistent logs
5. **Loop-based cog loading** for error resilience
6. **Comprehensive return values** from setup functions
### **Error Handling**
1. **Package-level isolation** - one failed cog doesn't break the package
2. **Clear error logging** with stack traces for debugging
3. **User-friendly messages** that don't expose internal errors
4. **Graceful degradation** when possible
5. **Metric reporting** for monitoring (success/failure counts)
## 📊 Monitoring & Metrics
### **Startup Logging**
The command loading system provides comprehensive metrics:
```
INFO - Loading players commands...
INFO - ✅ Loaded PlayerInfoCommands
INFO - 🎉 All 1 player command modules loaded successfully
INFO - ✅ players commands loaded successfully (1 cogs)
INFO - 🎉 All command packages loaded successfully (1 total cogs)
```
### **Error Scenarios**
```
ERROR - ❌ Failed to load PlayerInfoCommands: <error details>
WARNING - ⚠️ Player commands loaded with issues: 0 successful, 1 failed
WARNING - Failed modules: PlayerInfoCommands
```
### **Command Sync Logging**
```
INFO - Development mode: changes detected, syncing commands...
INFO - Synced 1 commands to guild 123456789
```
or
```
INFO - Development mode: no command changes detected, skipping sync
```
## 🔧 Troubleshooting
### **Common Issues**
#### **Import Errors**
- Check that `__init__.py` files exist in all packages
- Verify cog class names match imports
- Ensure service dependencies are available
#### **Command Not Loading**
- Check logs for specific error messages
- Verify cog is added to the package's cog list
- Test individual module imports in Python REPL
#### **Commands Not Syncing**
- Check if running in development mode (`config.is_development`)
- Verify `.last_command_hash` file permissions
- Use manual `/sync` command for troubleshooting
- Check Discord API rate limits
#### **Performance Issues**
- Monitor command loading times in logs
- Check for unnecessary API calls during startup
- Verify hash-based sync is working correctly
### **Debugging Tips**
1. **Use the logs** - comprehensive logging shows exactly what's happening
2. **Test imports individually** - isolate package/module issues
3. **Check hash file** - verify command change detection is working
4. **Monitor Discord API** - watch for rate limiting or errors
5. **Use development mode** - auto-sync helps debug command issues
## 📦 Command Groups Pattern
### **⚠️ CRITICAL: Use `app_commands.Group`, NOT `commands.GroupCog`**
Discord.py provides two ways to create command groups (e.g., `/injury roll`, `/injury clear`):
1. **`app_commands.Group`** ✅ **RECOMMENDED - Use this pattern**
2. **`commands.GroupCog`** ❌ **AVOID - Has interaction timing issues**
### **Why `commands.GroupCog` Fails**
`commands.GroupCog` has a critical bug that causes **duplicate interaction processing**, leading to:
- **404 "Unknown interaction" errors** when trying to defer/respond
- **Interaction already acknowledged errors** in error handlers
- **Commands fail randomly** even with proper error handling
**Root Cause:** GroupCog triggers the command handler twice for a single interaction, causing the first execution to consume the interaction token before the second execution can respond.
### **✅ Correct Pattern: `app_commands.Group`**
Use the same pattern as `ChartCategoryGroup` and `ChartManageGroup`:
```python
from discord import app_commands
from discord.ext import commands
from utils.decorators import logged_command
class InjuryGroup(app_commands.Group):
"""Injury management command group."""
def __init__(self):
super().__init__(
name="injury",
description="Injury management commands"
)
self.logger = get_contextual_logger(f'{__name__}.InjuryGroup')
@app_commands.command(name="roll", description="Roll for injury")
@logged_command("/injury roll")
async def injury_roll(self, interaction: discord.Interaction, player_name: str):
"""Roll for injury using player's injury rating."""
await interaction.response.defer()
# Command implementation
# No try/catch needed - @logged_command handles it
async def setup(bot: commands.Bot):
"""Setup function for loading the injury commands."""
bot.tree.add_command(InjuryGroup())
```
### **Key Differences**
| Feature | `app_commands.Group` ✅ | `commands.GroupCog` ❌ |
|---------|------------------------|------------------------|
| **Registration** | `bot.tree.add_command(Group())` | `await bot.add_cog(Cog(bot))` |
| **Initialization** | `__init__(self)` no bot param | `__init__(self, bot)` requires bot |
| **Decorator Support** | `@logged_command` works perfectly | Causes duplicate execution |
| **Interaction Handling** | Single execution, reliable | Duplicate execution, 404 errors |
| **Recommended Use** | ✅ All command groups | ❌ Never use |
### **Migration from GroupCog to Group**
If you have an existing `commands.GroupCog`, convert it:
1. **Change class inheritance:**
```python
# Before
class InjuryCog(commands.GroupCog, name="injury"):
def __init__(self, bot):
self.bot = bot
super().__init__()
# After
class InjuryGroup(app_commands.Group):
def __init__(self):
super().__init__(name="injury", description="...")
```
2. **Update registration:**
```python
# Before
await bot.add_cog(InjuryCog(bot))
# After
bot.tree.add_command(InjuryGroup())
```
3. **Remove duplicate interaction checks:**
```python
# Before (needed for GroupCog bug workaround)
if not interaction.response.is_done():
await interaction.response.defer()
# After (clean, simple)
await interaction.response.defer()
```
### **Working Examples**
**Good examples to reference:**
- `commands/utilities/charts.py` - `ChartManageGroup` and `ChartCategoryGroup`
- `commands/injuries/management.py` - `InjuryGroup`
Both use `app_commands.Group` successfully with `@logged_command` decorators.
## 🚦 Future Enhancements
### **Planned Features**
- **Permission Decorators**: Role-based command restrictions per package
- **Dynamic Loading**: Hot-reload commands without bot restart
- **Usage Metrics**: Command usage tracking and analytics
- **Rate Limiting**: Per-command rate limiting for resource management
### **Architecture Improvements**
- **Shared Utilities**: Common embed builders, decorators, helpers
- **Configuration**: Per-package configuration and feature flags
- **Testing**: Automated testing for command packages
- **Documentation**: Auto-generated command documentation
- **Monitoring**: Health checks and performance metrics per package
This architecture provides a solid foundation for scaling the Discord bot while maintaining code quality, reliability, and developer productivity.
---
**Last Updated:** Phase 2.1 - Command Package Conversion
**Next Review:** After implementing teams/ and league/ packages

View File

@ -11,6 +11,7 @@ from discord.ext import commands
from .management import AdminCommands
from .users import UserManagementCommands
from .league_management import LeagueManagementCommands
logger = logging.getLogger(f'{__name__}.setup_admin')
@ -25,6 +26,7 @@ async def setup_admin(bot: commands.Bot) -> Tuple[int, int, List[str]]:
admin_cogs: List[Tuple[str, Type[commands.Cog]]] = [
("AdminCommands", AdminCommands),
("UserManagementCommands", UserManagementCommands),
("LeagueManagementCommands", LeagueManagementCommands),
]
successful = 0

View File

@ -0,0 +1,560 @@
"""
Admin League Management Commands
Administrative commands for manual control of league state and transaction processing.
Provides manual override capabilities for the automated freeze/thaw system.
"""
from typing import Optional
import discord
from discord.ext import commands
from discord import app_commands
from config import get_config
from utils.logging import get_contextual_logger
from utils.decorators import logged_command
from views.embeds import EmbedColors, EmbedTemplate
from services.league_service import league_service
from services.transaction_service import transaction_service
from tasks.transaction_freeze import resolve_contested_transactions
class LeagueManagementCommands(commands.Cog):
"""Administrative commands for league state and transaction management."""
def __init__(self, bot: commands.Bot):
self.bot = bot
self.logger = get_contextual_logger(f'{__name__}.LeagueManagementCommands')
async def interaction_check(self, interaction: discord.Interaction) -> bool:
"""Check if user has admin permissions."""
if not interaction.user.guild_permissions.administrator:
await interaction.response.send_message(
"❌ You need administrator permissions to use admin commands.",
ephemeral=True
)
return False
return True
@app_commands.command(
name="admin-freeze-begin",
description="[ADMIN] Manually trigger freeze begin (increment week, set freeze)"
)
@logged_command("/admin-freeze-begin")
async def admin_freeze_begin(self, interaction: discord.Interaction):
"""Manually trigger the freeze begin process."""
await interaction.response.defer()
# Get current state
current = await league_service.get_current_state()
if not current:
await interaction.followup.send(
"❌ Could not retrieve current league state.",
ephemeral=True
)
return
# Check if already frozen
if current.freeze:
embed = EmbedTemplate.warning(
title="Already Frozen",
description=f"League is already in freeze period for week {current.week}."
)
embed.add_field(
name="Current State",
value=f"**Week:** {current.week}\n**Freeze:** {current.freeze}\n**Season:** {current.season}",
inline=False
)
await interaction.followup.send(embed=embed)
return
# Increment week and set freeze
new_week = current.week + 1
updated = await league_service.update_current_state(
week=new_week,
freeze=True
)
if not updated:
await interaction.followup.send(
"❌ Failed to update league state.",
ephemeral=True
)
return
# Create success embed
embed = EmbedTemplate.success(
title="Freeze Period Begun",
description=f"Manually triggered freeze begin for week {new_week}."
)
embed.add_field(
name="Previous State",
value=f"**Week:** {current.week}\n**Freeze:** {current.freeze}",
inline=True
)
embed.add_field(
name="New State",
value=f"**Week:** {new_week}\n**Freeze:** True",
inline=True
)
embed.add_field(
name="Actions Performed",
value="✅ Week incremented\n✅ Freeze flag set to True",
inline=False
)
embed.add_field(
name="⚠️ Manual Steps Required",
value="• Post freeze announcement to #transaction-log\n"
"• Post weekly info to #weekly-info (if weeks 1-18)\n"
"• Run regular transactions if needed",
inline=False
)
embed.set_footer(text=f"Triggered by {interaction.user.display_name}")
await interaction.followup.send(embed=embed)
@app_commands.command(
name="admin-freeze-end",
description="[ADMIN] Manually trigger freeze end (process transactions, unfreeze)"
)
@logged_command("/admin-freeze-end")
async def admin_freeze_end(self, interaction: discord.Interaction):
"""Manually trigger the freeze end process."""
await interaction.response.defer()
# Get current state
current = await league_service.get_current_state()
if not current:
await interaction.followup.send(
"❌ Could not retrieve current league state.",
ephemeral=True
)
return
# Check if currently frozen
if not current.freeze:
embed = EmbedTemplate.warning(
title="Not Frozen",
description=f"League is not currently in freeze period (week {current.week})."
)
embed.add_field(
name="Current State",
value=f"**Week:** {current.week}\n**Freeze:** {current.freeze}\n**Season:** {current.season}",
inline=False
)
await interaction.followup.send(embed=embed)
return
# Process frozen transactions
processing_msg = await interaction.followup.send(
"⏳ Processing frozen transactions...",
wait=True
)
# Get frozen transactions
transactions = await transaction_service.get_frozen_transactions_by_week(
season=current.season,
week_start=current.week,
week_end=current.week + 1
)
winning_count = 0
losing_count = 0
if transactions:
# Resolve contested transactions
winning_move_ids, losing_move_ids = await resolve_contested_transactions(
transactions,
current.season
)
# Cancel losing transactions (one API call per moveid, updates all transactions in group)
for losing_move_id in losing_move_ids:
await transaction_service.cancel_transaction(losing_move_id)
losing_count += 1
# Unfreeze winning transactions (one API call per moveid, updates all transactions in group)
for winning_move_id in winning_move_ids:
await transaction_service.unfreeze_transaction(winning_move_id)
winning_count += 1
# Update processing message
await processing_msg.edit(content="⏳ Updating league state...")
# Set freeze to False
updated = await league_service.update_current_state(freeze=False)
if not updated:
await interaction.followup.send(
"❌ Failed to update league state after processing transactions.",
ephemeral=True
)
return
# Create success embed
embed = EmbedTemplate.success(
title="Freeze Period Ended",
description=f"Manually triggered freeze end for week {current.week}."
)
embed.add_field(
name="Transaction Processing",
value=f"**Total Transactions:** {len(transactions) if transactions else 0}\n"
f"**Successful:** {winning_count}\n"
f"**Cancelled:** {losing_count}",
inline=True
)
embed.add_field(
name="League State",
value=f"**Week:** {current.week}\n"
f"**Freeze:** False\n"
f"**Season:** {current.season}",
inline=True
)
embed.add_field(
name="Actions Performed",
value=f"✅ Processed {len(transactions) if transactions else 0} frozen transactions\n"
f"✅ Resolved contested players\n"
f"✅ Freeze flag set to False",
inline=False
)
if transactions:
embed.add_field(
name="⚠️ Manual Steps Required",
value="• Post thaw announcement to #transaction-log\n"
"• Notify GMs of cancelled transactions\n"
"• Post successful transactions to #transaction-log",
inline=False
)
embed.set_footer(text=f"Triggered by {interaction.user.display_name}")
# Edit the processing message to show final results instead of deleting and sending new
await processing_msg.edit(content=None, embed=embed)
@app_commands.command(
name="admin-set-week",
description="[ADMIN] Manually set the current league week"
)
@app_commands.describe(
week="Week number to set (1-24)"
)
@logged_command("/admin-set-week")
async def admin_set_week(self, interaction: discord.Interaction, week: int):
"""Manually set the current league week."""
await interaction.response.defer()
# Validate week number
if week < 1 or week > 24:
await interaction.followup.send(
"❌ Week number must be between 1 and 24.",
ephemeral=True
)
return
# Get current state
current = await league_service.get_current_state()
if not current:
await interaction.followup.send(
"❌ Could not retrieve current league state.",
ephemeral=True
)
return
# Update week
updated = await league_service.update_current_state(week=week)
if not updated:
await interaction.followup.send(
"❌ Failed to update league week.",
ephemeral=True
)
return
# Create success embed
embed = EmbedTemplate.success(
title="League Week Updated",
description=f"Manually set league week to {week}."
)
embed.add_field(
name="Previous Week",
value=f"**Week:** {current.week}",
inline=True
)
embed.add_field(
name="New Week",
value=f"**Week:** {week}",
inline=True
)
embed.add_field(
name="Current State",
value=f"**Season:** {current.season}\n"
f"**Freeze:** {current.freeze}\n"
f"**Trade Deadline:** Week {current.trade_deadline}\n"
f"**Playoffs Begin:** Week {current.playoffs_begin}",
inline=False
)
embed.add_field(
name="⚠️ Warning",
value="Manual week changes bypass automated freeze/thaw processes.\n"
"Ensure you run appropriate admin commands for transaction management.",
inline=False
)
embed.set_footer(text=f"Changed by {interaction.user.display_name}")
await interaction.followup.send(embed=embed)
@app_commands.command(
name="admin-set-freeze",
description="[ADMIN] Manually toggle freeze status"
)
@app_commands.describe(
freeze="True to freeze transactions, False to unfreeze"
)
@app_commands.choices(freeze=[
app_commands.Choice(name="Freeze (True)", value=1),
app_commands.Choice(name="Unfreeze (False)", value=0)
])
@logged_command("/admin-set-freeze")
async def admin_set_freeze(self, interaction: discord.Interaction, freeze: int):
"""Manually toggle the freeze status."""
await interaction.response.defer()
freeze_bool = bool(freeze)
# Get current state
current = await league_service.get_current_state()
if not current:
await interaction.followup.send(
"❌ Could not retrieve current league state.",
ephemeral=True
)
return
# Check if already in desired state
if current.freeze == freeze_bool:
status = "frozen" if freeze_bool else "unfrozen"
embed = EmbedTemplate.warning(
title="No Change Needed",
description=f"League is already {status}."
)
embed.add_field(
name="Current State",
value=f"**Week:** {current.week}\n**Freeze:** {current.freeze}\n**Season:** {current.season}",
inline=False
)
await interaction.followup.send(embed=embed)
return
# Update freeze status
updated = await league_service.update_current_state(freeze=freeze_bool)
if not updated:
await interaction.followup.send(
"❌ Failed to update freeze status.",
ephemeral=True
)
return
# Create success embed
action = "Frozen" if freeze_bool else "Unfrozen"
embed = EmbedTemplate.success(
title=f"Transactions {action}",
description=f"Manually set freeze status to {freeze_bool}."
)
embed.add_field(
name="Previous Status",
value=f"**Freeze:** {current.freeze}",
inline=True
)
embed.add_field(
name="New Status",
value=f"**Freeze:** {freeze_bool}",
inline=True
)
embed.add_field(
name="Current State",
value=f"**Week:** {current.week}\n**Season:** {current.season}",
inline=False
)
if freeze_bool:
embed.add_field(
name="⚠️ Note",
value="Transactions are now frozen. Use `/admin-freeze-end` to process frozen transactions.",
inline=False
)
else:
embed.add_field(
name="⚠️ Warning",
value="Manual freeze toggle bypasses transaction processing.\n"
"Ensure frozen transactions were processed before unfreezing.",
inline=False
)
embed.set_footer(text=f"Changed by {interaction.user.display_name}")
await interaction.followup.send(embed=embed)
@app_commands.command(
name="admin-process-transactions",
description="[ADMIN] Manually process frozen transactions without changing freeze status"
)
@app_commands.describe(
week="Week to process transactions for (defaults to current week)",
dry_run="Preview results without making changes (default: False)"
)
@logged_command("/admin-process-transactions")
async def admin_process_transactions(
self,
interaction: discord.Interaction,
week: Optional[int] = None,
dry_run: bool = False
):
"""Manually process frozen transactions for a specific week."""
await interaction.response.defer()
# Get current state
current = await league_service.get_current_state()
if not current:
await interaction.followup.send(
"❌ Could not retrieve current league state.",
ephemeral=True
)
return
# Use provided week or current week
target_week = week if week is not None else current.week
# Validate week
if target_week < 1 or target_week > 24:
await interaction.followup.send(
"❌ Week number must be between 1 and 24.",
ephemeral=True
)
return
# Send processing message
mode_text = " (DRY RUN - No changes will be made)" if dry_run else ""
processing_msg = await interaction.followup.send(
f"⏳ Processing frozen transactions for week {target_week}{mode_text}...",
wait=True
)
# Get frozen transactions for the week
transactions = await transaction_service.get_frozen_transactions_by_week(
season=current.season,
week_start=target_week,
week_end=target_week + 1
)
if not transactions:
await processing_msg.edit(
content=f" No frozen transactions found for week {target_week}."
)
return
# Resolve contested transactions
winning_move_ids, losing_move_ids = await resolve_contested_transactions(
transactions,
current.season
)
# Process transactions (unless dry run)
if not dry_run:
# Cancel losing transactions (one API call per moveid, updates all transactions in group)
for losing_move_id in losing_move_ids:
await transaction_service.cancel_transaction(losing_move_id)
# Unfreeze winning transactions (one API call per moveid, updates all transactions in group)
for winning_move_id in winning_move_ids:
await transaction_service.unfreeze_transaction(winning_move_id)
# Create detailed results embed
if dry_run:
embed = EmbedTemplate.info(
title="Transaction Processing Preview",
description=f"Dry run results for week {target_week} (no changes made)."
)
else:
embed = EmbedTemplate.success(
title="Transactions Processed",
description=f"Successfully processed frozen transactions for week {target_week}."
)
embed.add_field(
name="Transaction Summary",
value=f"**Total Frozen:** {len(transactions)}\n"
f"**Successful:** {len(winning_move_ids)}\n"
f"**Cancelled:** {len(losing_move_ids)}",
inline=True
)
embed.add_field(
name="Processing Details",
value=f"**Week:** {target_week}\n"
f"**Season:** {current.season}\n"
f"**Mode:** {'Dry Run' if dry_run else 'Live'}",
inline=True
)
# Show contested transactions
if losing_move_ids:
contested_info = []
for losing_move_id in losing_move_ids:
losing_moves = [t for t in transactions if t.moveid == losing_move_id]
if losing_moves:
player_name = losing_moves[0].player.name
team_abbrev = losing_moves[0].newteam.abbrev
contested_info.append(f"{player_name} ({team_abbrev} - cancelled)")
if contested_info:
# Limit to first 10 contested transactions
display_info = contested_info[:10]
if len(contested_info) > 10:
display_info.append(f"... and {len(contested_info) - 10} more")
embed.add_field(
name="Contested Transactions",
value="\n".join(display_info),
inline=False
)
# Add warnings
if dry_run:
embed.add_field(
name=" Dry Run Mode",
value="No transactions were modified. Run without `dry_run` parameter to apply changes.",
inline=False
)
else:
embed.add_field(
name="⚠️ Manual Steps Required",
value="• Notify GMs of cancelled transactions\n"
"• Post successful transactions to #transaction-log\n"
"• Verify all transactions processed correctly",
inline=False
)
embed.set_footer(text=f"Processed by {interaction.user.display_name}")
# Edit the processing message to show final results instead of deleting and sending new
await processing_msg.edit(content=None, embed=embed)
async def setup(bot: commands.Bot):
"""Load the league management commands cog."""
await bot.add_cog(LeagueManagementCommands(bot))

View File

@ -3,7 +3,6 @@ Admin Management Commands
Administrative commands for league management and bot maintenance.
"""
from typing import Optional, Union
import asyncio
import discord
@ -25,6 +24,14 @@ class AdminCommands(commands.Cog):
async def interaction_check(self, interaction: discord.Interaction) -> bool:
"""Check if user has admin permissions."""
# Check if interaction is from a guild and user is a Member
if not isinstance(interaction.user, discord.Member):
await interaction.response.send_message(
"❌ Admin commands can only be used in a server.",
ephemeral=True
)
return False
if not interaction.user.guild_permissions.administrator:
await interaction.response.send_message(
"❌ You need administrator permissions to use admin commands.",
@ -196,20 +203,64 @@ class AdminCommands(commands.Cog):
name="admin-sync",
description="Sync application commands with Discord"
)
@app_commands.describe(
local="Sync to this guild only (fast, for development)",
clear_local="Clear locally synced commands (does not sync after clearing)"
)
@logged_command("/admin-sync")
async def admin_sync(self, interaction: discord.Interaction):
async def admin_sync(
self,
interaction: discord.Interaction,
local: bool = False,
clear_local: bool = False
):
"""Sync slash commands with Discord API."""
await interaction.response.defer()
try:
synced_commands = await self.bot.tree.sync()
# Clear local commands if requested
if clear_local:
if not interaction.guild_id:
raise ValueError("Cannot clear local commands outside of a guild")
self.logger.info(f"Clearing local commands for guild {interaction.guild_id}")
self.bot.tree.clear_commands(guild=discord.Object(id=interaction.guild_id))
embed = EmbedTemplate.create_base_embed(
title="✅ Local Commands Cleared",
description=f"Cleared all commands synced to this guild",
color=EmbedColors.SUCCESS
)
embed.add_field(
name="Clear Details",
value=f"**Guild ID:** {interaction.guild_id}\n"
f"**Time:** {discord.utils.utcnow().strftime('%H:%M:%S UTC')}\n"
f"**Note:** Commands not synced after clearing",
inline=False
)
await interaction.followup.send(embed=embed)
return
# Determine sync target
if local:
if not interaction.guild_id:
raise ValueError("Cannot sync locally outside of a guild")
guild = discord.Object(id=interaction.guild_id)
sync_type = "local guild"
else:
guild = None
sync_type = "globally"
# Perform sync
self.logger.info(f"Syncing commands {sync_type}")
synced_commands = await self.bot.tree.sync(guild=guild)
embed = EmbedTemplate.create_base_embed(
title="✅ Commands Synced Successfully",
description=f"Synced {len(synced_commands)} application commands",
description=f"Synced {len(synced_commands)} application commands {sync_type}",
color=EmbedColors.SUCCESS
)
# Show some of the synced commands
command_names = [cmd.name for cmd in synced_commands[:10]]
embed.add_field(
@ -218,23 +269,73 @@ class AdminCommands(commands.Cog):
(f"\n... and {len(synced_commands) - 10} more" if len(synced_commands) > 10 else ""),
inline=False
)
embed.add_field(
name="Sync Details",
value=f"**Total Commands:** {len(synced_commands)}\n"
f"**Guild ID:** {interaction.guild_id}\n"
f"**Sync Type:** {sync_type.title()}\n"
f"**Guild ID:** {interaction.guild_id or 'N/A'}\n"
f"**Time:** {discord.utils.utcnow().strftime('%H:%M:%S UTC')}",
inline=False
)
except Exception as e:
self.logger.error(f"Sync failed: {e}", exc_info=True)
embed = EmbedTemplate.create_base_embed(
title="❌ Sync Failed",
description=f"Failed to sync commands: {str(e)}",
color=EmbedColors.ERROR
)
await interaction.followup.send(embed=embed)
@commands.command(name="admin-sync")
@commands.has_permissions(administrator=True)
async def admin_sync_prefix(self, ctx: commands.Context):
"""
Prefix command version of admin-sync for bootstrap scenarios.
Use this when slash commands aren't synced yet and you can't access /admin-sync.
"""
self.logger.info(f"Prefix command !admin-sync invoked by {ctx.author} in {ctx.guild}")
try:
synced_commands = await self.bot.tree.sync()
embed = EmbedTemplate.create_base_embed(
title="✅ Commands Synced Successfully",
description=f"Synced {len(synced_commands)} application commands",
color=EmbedColors.SUCCESS
)
# Show some of the synced commands
command_names = [cmd.name for cmd in synced_commands[:10]]
embed.add_field(
name="Synced Commands",
value="\n".join([f"• /{name}" for name in command_names]) +
(f"\n... and {len(synced_commands) - 10} more" if len(synced_commands) > 10 else ""),
inline=False
)
embed.add_field(
name="Sync Details",
value=f"**Total Commands:** {len(synced_commands)}\n"
f"**Guild ID:** {ctx.guild.id}\n"
f"**Time:** {discord.utils.utcnow().strftime('%H:%M:%S UTC')}",
inline=False
)
embed.set_footer(text="💡 Use /admin-sync (slash command) for future syncs")
except Exception as e:
self.logger.error(f"Prefix command sync failed: {e}", exc_info=True)
embed = EmbedTemplate.create_base_embed(
title="❌ Sync Failed",
description=f"Failed to sync commands: {str(e)}",
color=EmbedColors.ERROR
)
await ctx.send(embed=embed)
@app_commands.command(
name="admin-clear",
@ -254,7 +355,15 @@ class AdminCommands(commands.Cog):
return
await interaction.response.defer()
# Verify channel type supports purge
if not isinstance(interaction.channel, (discord.TextChannel, discord.Thread, discord.VoiceChannel, discord.StageChannel)):
await interaction.followup.send(
"❌ Cannot purge messages in this channel type.",
ephemeral=True
)
return
try:
deleted = await interaction.channel.purge(limit=count)
@ -276,10 +385,11 @@ class AdminCommands(commands.Cog):
# Send confirmation and auto-delete after 5 seconds
message = await interaction.followup.send(embed=embed)
await asyncio.sleep(5)
try:
await message.delete()
except discord.NotFound:
pass # Message already deleted
if message:
try:
await message.delete()
except discord.NotFound:
pass # Message already deleted
except discord.Forbidden:
await interaction.followup.send(
@ -320,10 +430,12 @@ class AdminCommands(commands.Cog):
text=f"Announcement by {interaction.user.display_name}",
icon_url=interaction.user.display_avatar.url
)
content = "@everyone" if mention_everyone else None
await interaction.followup.send(content=content, embed=embed)
# Send with or without mention based on flag
if mention_everyone:
await interaction.followup.send(content="@everyone", embed=embed)
else:
await interaction.followup.send(embed=embed)
# Log the announcement
self.logger.info(

View File

@ -11,8 +11,12 @@ from dataclasses import dataclass
import discord
from discord.ext import commands
from models.team import Team
from services.team_service import team_service
from utils import team_utils
from utils.logging import get_contextual_logger
from utils.decorators import logged_command
from utils.team_utils import get_user_major_league_team
from views.embeds import EmbedColors, EmbedTemplate
@ -93,13 +97,20 @@ class DiceRollCommands(commands.Cog):
async def ab_dice(self, interaction: discord.Interaction):
"""Roll the standard baseball at-bat dice combination."""
await interaction.response.defer()
embed_color = await self._get_channel_embed_color(interaction)
# Use the standard baseball dice combination
dice_notation = "1d6;2d6;1d20"
roll_results = self._parse_and_roll_multiple_dice(dice_notation)
# Create embed for the roll results
embed = self._create_multi_roll_embed(dice_notation, roll_results, interaction.user, set_author=False)
embed = self._create_multi_roll_embed(
dice_notation,
roll_results,
interaction.user,
set_author=False,
embed_color=embed_color
)
embed.title = f'At bat roll for {interaction.user.display_name}'
await interaction.followup.send(embed=embed)
@ -107,6 +118,10 @@ class DiceRollCommands(commands.Cog):
async def ab_dice_prefix(self, ctx: commands.Context):
"""Roll baseball at-bat dice using prefix commands (!ab, !atbat)."""
self.logger.info(f"At Bat dice command started by {ctx.author.display_name}")
team = await get_user_major_league_team(user_id=ctx.author.id)
embed_color = EmbedColors.PRIMARY
if team is not None and team.color is not None:
embed_color = int(team.color,16)
# Use the standard baseball dice combination
dice_notation = "1d6;2d6;1d20"
@ -115,7 +130,7 @@ class DiceRollCommands(commands.Cog):
self.logger.info("At Bat dice rolled successfully", roll_count=len(roll_results))
# Create embed for the roll results
embed = self._create_multi_roll_embed(dice_notation, roll_results, ctx.author)
embed = self._create_multi_roll_embed(dice_notation, roll_results, ctx.author, set_author=False, embed_color=embed_color)
embed.title = f'At bat roll for {ctx.author.display_name}'
await ctx.send(embed=embed)
@ -158,6 +173,7 @@ class DiceRollCommands(commands.Cog):
position="Defensive position"
)
@discord.app_commands.choices(position=[
discord.app_commands.Choice(name="Pitcher (P)", value="P"),
discord.app_commands.Choice(name="Catcher (C)", value="C"),
discord.app_commands.Choice(name="First Base (1B)", value="1B"),
discord.app_commands.Choice(name="Second Base (2B)", value="2B"),
@ -212,6 +228,82 @@ class DiceRollCommands(commands.Cog):
embed = self._create_fielding_embed(parsed_position, roll_results, ctx.author)
await ctx.send(embed=embed)
@discord.app_commands.command(
name="jump",
description="Roll for baserunner's jump before stealing"
)
@logged_command("/jump")
async def jump_dice(self, interaction: discord.Interaction):
"""Roll to check for a baserunner's jump before attempting to steal a base."""
await interaction.response.defer()
embed_color = await self._get_channel_embed_color(interaction)
# Roll 1d20 for pickoff/balk check
check_roll = random.randint(1, 20)
# Roll 2d6 for jump rating
jump_result = self._parse_and_roll_single_dice("2d6")
# Roll another 1d20 for pickoff/balk resolution
resolution_roll = random.randint(1, 20)
# Create embed based on check roll
embed = self._create_jump_embed(
check_roll,
jump_result,
resolution_roll,
interaction.user,
embed_color,
show_author=False
)
await interaction.followup.send(embed=embed)
@commands.command(name="j", aliases=["jump"])
async def jump_dice_prefix(self, ctx: commands.Context):
"""Roll for baserunner's jump using prefix commands (!j, !jump)."""
self.logger.info(f"Jump command started by {ctx.author.display_name}")
team = await get_user_major_league_team(user_id=ctx.author.id)
embed_color = EmbedColors.PRIMARY
if team is not None and team.color is not None:
embed_color = int(team.color, 16)
# Roll 1d20 for pickoff/balk check
check_roll = random.randint(1, 20)
# Roll 2d6 for jump rating
jump_result = self._parse_and_roll_single_dice("2d6")
# Roll another 1d20 for pickoff/balk resolution
resolution_roll = random.randint(1, 20)
self.logger.info("Jump dice rolled successfully", check=check_roll, jump=jump_result.total if jump_result else None, resolution=resolution_roll)
# Create embed based on check roll
embed = self._create_jump_embed(
check_roll,
jump_result,
resolution_roll,
ctx.author,
embed_color
)
await ctx.send(embed=embed)
async def _get_channel_embed_color(self, interaction: discord.Interaction) -> int:
# Check if channel is a type that has a name attribute (DMChannel doesn't have one)
if isinstance(interaction.channel, (discord.TextChannel, discord.VoiceChannel, discord.Thread)):
channel_starter = interaction.channel.name[:6]
if '-' in channel_starter:
abbrev = channel_starter.split('-')[0]
channel_team = await team_service.get_team_by_abbrev(abbrev)
if channel_team is not None and channel_team.color is not None:
return int(channel_team.color,16)
team = await get_user_major_league_team(user_id=interaction.user.id)
if team is not None and team.color is not None:
return int(team.color,16)
return EmbedColors.PRIMARY
def _parse_position(self, position: str) -> str | None:
"""Parse and validate fielding position input for prefix commands."""
if not position:
@ -221,6 +313,7 @@ class DiceRollCommands(commands.Cog):
# Map common inputs to standard position names
position_map = {
'P': 'P', 'PITCHER': 'P',
'C': 'C', 'CATCHER': 'C',
'1': '1B', '1B': '1B', 'FIRST': '1B', 'FIRSTBASE': '1B',
'2': '2B', '2B': '2B', 'SECOND': '2B', 'SECONDBASE': '2B',
@ -266,8 +359,8 @@ class DiceRollCommands(commands.Cog):
# Add fielding check summary
range_result = self._get_range_result(position, d20_result)
embed.add_field(
name=f"{position} Fielding Check Summary",
value=f"```\nRange Result\n 1 | 2 | 3 | 4 | 5\n{range_result}```",
name=f"{position} Range Result",
value=f"```md\n 1 | 2 | 3 | 4 | 5\n{range_result}```",
inline=False
)
@ -280,26 +373,87 @@ class DiceRollCommands(commands.Cog):
inline=False
)
# Add help commands
embed.add_field(
name="Help Commands",
value="Run !<result> for full chart readout (e.g. !g1 or !do3)",
inline=False
# # Add help commands
# embed.add_field(
# name="Help Commands",
# value="Run !<result> for full chart readout (e.g. !g1 or !do3)",
# inline=False
# )
# # Add references
# embed.add_field(
# name="References",
# value="Range Chart / Error Chart / Result Reference",
# inline=False
# )
return embed
def _create_jump_embed(
self,
check_roll: int,
jump_result: DiceRoll | None,
resolution_roll: int,
user: discord.User | discord.Member,
embed_color: int = EmbedColors.PRIMARY,
show_author: bool = True
) -> discord.Embed:
"""Create an embed for jump roll results."""
# Create base embed
embed = EmbedTemplate.create_base_embed(
title=f"Jump roll for {user.name}",
color=embed_color
)
# Add references
embed.add_field(
name="References",
value="Range Chart / Error Chart / Result Reference",
inline=False
)
if show_author:
# Set user info
embed.set_author(
name=user.name,
icon_url=user.display_avatar.url
)
# Check for pickoff or balk
if check_roll == 1:
# Pickoff attempt
embed.add_field(
name="Special",
value="```md\nCheck pickoff```",
inline=False
)
embed.add_field(
name="Pickoff roll",
value=f"```md\n# {resolution_roll}\nDetails:[1d20 ({resolution_roll})]```",
inline=False
)
elif check_roll == 2:
# Balk
embed.add_field(
name="Special",
value="```md\nCheck balk```",
inline=False
)
embed.add_field(
name="Balk roll",
value=f"```md\n# {resolution_roll}\nDetails:[1d20 ({resolution_roll})]```",
inline=False
)
else:
# Normal jump - show 2d6 result
if jump_result:
rolls_str = ' '.join(str(r) for r in jump_result.rolls)
embed.add_field(
name="Result",
value=f"```md\n# {jump_result.total}\nDetails:[2d6 ({rolls_str})]```",
inline=False
)
return embed
def _get_range_result(self, position: str, d20_roll: int) -> str:
"""Get the range result display for a position and d20 roll."""
# Infield positions share the same range chart
if position in ['1B', '2B', '3B', 'SS']:
if position == 'P':
return self._get_pitcher_range(d20_roll)
elif position in ['1B', '2B', '3B', 'SS']:
return self._get_infield_range(d20_roll)
elif position in ['LF', 'CF', 'RF']:
return self._get_outfield_range(d20_roll)
@ -385,10 +539,38 @@ class DiceRollCommands(commands.Cog):
}
return catcher_ranges.get(d20_roll, 'Unknown')
def _get_pitcher_range(self, d20_roll: int) -> str:
"""Get pitcher range result based on d20 roll."""
pitcher_ranges = {
1: 'G3 ------SI1------',
2: 'G3 ------SI1------',
3: '--G3--- ----SI1----',
4: '----G3----- --SI1--',
5: '------G3------- SI1',
6: '------G3------- SI1',
7: '--------G3---------',
8: 'G2 ------G3-------',
9: 'G2 ------G3-------',
10: 'G1 G2 ----G3-----',
11: 'G1 G2 ----G3-----',
12: 'G1 G2 ----G3-----',
13: '--G1--- G2 --G3---',
14: '--G1--- --G2--- G3',
15: '--G1--- ----G2-----',
16: '--G1--- ----G2-----',
17: '----G1----- --G2---',
18: '----G1----- --G2---',
19: '------G1------- G2',
20: '--------G1---------'
}
return pitcher_ranges.get(d20_roll, 'Unknown')
def _get_error_result(self, position: str, d6_total: int) -> str:
"""Get the error result for a position and 3d6 total."""
# Get the appropriate error chart
if position == '1B':
if position == 'P':
return self._get_pitcher_error(d6_total)
elif position == '1B':
return self._get_1b_error(d6_total)
elif position == '2B':
return self._get_2b_error(d6_total)
@ -560,6 +742,28 @@ class DiceRollCommands(commands.Cog):
}
return errors.get(d6_total, 'No error')
def _get_pitcher_error(self, d6_total: int) -> str:
"""Get Pitcher error result based on 3d6 total."""
errors = {
18: '2-base error for e4 -> e12, e19 -> e28, e34 -> e43, e46 -> e48',
17: '2-base error for e13 -> e28, e44 -> e50',
16: '2-base error for e30 -> e48, e50, e51\n1-base error for e8, e11, e16, e23',
15: '2-base error for e50, e51\n1-base error for e10 -> e12, e19, e20, e24, e26, e30, e35, e38, e40, e46, e47',
14: '1-base error for e4, e14, e18, e21, e22, e26, e31, e35, e42, e43, e48 -> e51',
13: '1-base error for e6, e13, e14, e21, e22, e26, e27, e30 -> 34, e38 -> e51',
12: '1-base error for e7, e11, e12, e15 -> e19, e22 -> e51',
11: '1-base error for e10, e13, e15, e17, e18, e20, e21, e23, e24, e27 -> 38, e40, e42, e44 -> e51',
10: '1-base error for e20, e23, e24, e27 -> e51',
9: '1-base error for e16, e19, e26, e28, e34 -> e36, e39 -> e51',
8: '1-base error for e22, e33, e38, e39, e43 -> e51',
7: '1-base error for e14, e21, e36, e39, e42 -> e44, e47 -> e51',
6: '1-base error for e8, e22, e38, e39, e43 -> e51',
5: 'No error',
4: '1-base error for e15, e16, e40',
3: '2-base error for e8 -> e12, e26 -> e28, e39 -> e43\n1-base error for e2, e3, e7, e14, e15'
}
return errors.get(d6_total, 'No error')
def _parse_and_roll_multiple_dice(self, dice_notation: str) -> list[DiceRoll]:
"""Parse dice notation (supports multiple rolls) and return roll results."""
# Split by semicolon for multiple rolls
@ -637,11 +841,11 @@ class DiceRollCommands(commands.Cog):
return [first_d6_result, second_result, third_result]
def _create_multi_roll_embed(self, dice_notation: str, roll_results: list[DiceRoll], user: discord.User | discord.Member, set_author: bool = True) -> discord.Embed:
def _create_multi_roll_embed(self, dice_notation: str, roll_results: list[DiceRoll], user: discord.User | discord.Member, set_author: bool = True, embed_color: int = EmbedColors.PRIMARY) -> discord.Embed:
"""Create an embed for multiple dice roll results."""
embed = EmbedTemplate.create_base_embed(
title="🎲 Dice Roll",
color=EmbedColors.PRIMARY
color=embed_color
)
if set_author:

View File

@ -1,433 +0,0 @@
# Help Commands System
**Last Updated:** January 2025
**Status:** ✅ Fully Implemented
**Location:** `commands/help/`
## Overview
The Help Commands System provides a comprehensive, admin-managed help system for the Discord server. Administrators and designated "Help Editors" can create, edit, and manage custom help topics covering league documentation, resources, FAQs, links, and guides. This system replaces the originally planned `/links` command with a more flexible and powerful solution.
## Commands
### `/help [topic]`
**Description:** View a help topic or list all available topics
**Parameters:**
- `topic` (optional): Name of the help topic to view
**Behavior:**
- **With topic**: Displays the specified help topic with formatted content
- **Without topic**: Shows a paginated list of all available help topics organized by category
- Automatically increments view count when a topic is viewed
**Permissions:** Available to all server members
**Examples:**
```
/help trading-rules
/help
```
### `/help-create`
**Description:** Create a new help topic
**Permissions:** Administrators + "Help Editor" role
**Modal Fields:**
- **Topic Name**: URL-safe name (2-32 chars, letters/numbers/dashes only)
- **Display Title**: Human-readable title (1-200 chars)
- **Category**: Optional category (rules/guides/resources/info/faq)
- **Content**: Help content with markdown support (1-4000 chars)
**Features:**
- Real-time validation of all fields
- Preview before final creation
- Automatic duplicate detection
**Example:**
```
Topic Name: trading-rules
Display Title: Trading Rules & Guidelines
Category: rules
Content: [Detailed trading rules with markdown formatting]
```
### `/help-edit <topic>`
**Description:** Edit an existing help topic
**Parameters:**
- `topic` (required): Name of the help topic to edit
**Permissions:** Administrators + "Help Editor" role
**Features:**
- Pre-populated modal with current values
- Shows preview of changes before saving
- Tracks last editor and update timestamp
- Autocomplete for topic names
**Example:**
```
/help-edit trading-rules
```
### `/help-delete <topic>`
**Description:** Delete a help topic (soft delete)
**Parameters:**
- `topic` (required): Name of the help topic to delete
**Permissions:** Administrators + "Help Editor" role
**Features:**
- Confirmation dialog before deletion
- Shows topic statistics (view count)
- Soft delete (can be restored later)
- Autocomplete for topic names
**Example:**
```
/help-delete trading-rules
```
### `/help-list [category] [show_deleted]`
**Description:** Browse all help topics
**Parameters:**
- `category` (optional): Filter by category
- `show_deleted` (optional): Show soft-deleted topics (admin only)
**Permissions:** Available to all (show_deleted requires admin/help editor)
**Features:**
- Organized display by category
- Shows view counts
- Paginated interface for many topics
- Filtered views by category
**Examples:**
```
/help-list
/help-list category:rules
/help-list show_deleted:true
```
## Permission System
### Roles with Help Edit Permissions
1. **Server Administrators** - Full access to all help commands
2. **Help Editor Role** - Designated role with editing permissions
- Role name: "Help Editor" (configurable in `constants.py`)
- Can create, edit, and delete help topics
- Cannot view deleted topics unless also admin
### Permission Checks
```python
def has_help_edit_permission(interaction: discord.Interaction) -> bool:
"""Check if user can edit help commands."""
# Admin check
if interaction.user.guild_permissions.administrator:
return True
# Help Editor role check
role = discord.utils.get(interaction.guild.roles, name=HELP_EDITOR_ROLE_NAME)
if role and role in interaction.user.roles:
return True
return False
```
## Architecture
### Components
**Models** (`models/help_command.py`):
- `HelpCommand`: Main data model with validation
- `HelpCommandSearchFilters`: Search/filtering parameters
- `HelpCommandSearchResult`: Paginated search results
- `HelpCommandStats`: Statistics and analytics
**Service Layer** (`services/help_commands_service.py`):
- `HelpCommandsService`: CRUD operations and business logic
- `help_commands_service`: Global service instance
- Integrates with BaseService for API calls
**Views** (`views/help_commands.py`):
- `HelpCommandCreateModal`: Interactive creation modal
- `HelpCommandEditModal`: Interactive editing modal
- `HelpCommandDeleteConfirmView`: Deletion confirmation
- `HelpCommandListView`: Paginated topic browser
- `create_help_topic_embed()`: Formatted topic display
**Commands** (`commands/help/main.py`):
- `HelpCommands`: Cog with all command handlers
- Permission checking integration
- Autocomplete for topic names
- Error handling and user feedback
### Data Flow
1. **User Interaction** → Discord slash command
2. **Permission Check** → Validate user permissions
3. **Modal Display** → Interactive data input (for create/edit)
4. **Service Call** → Business logic and validation
5. **API Request** → Database operations via API
6. **Response** → Formatted embed with success/error message
### Database Integration
**API Endpoints** (via `../database/app/routers_v3/help_commands.py`):
- `GET /api/v3/help_commands` - List with filters
- `GET /api/v3/help_commands/{id}` - Get by ID
- `GET /api/v3/help_commands/by_name/{name}` - Get by name
- `POST /api/v3/help_commands` - Create
- `PUT /api/v3/help_commands/{id}` - Update
- `DELETE /api/v3/help_commands/{id}` - Soft delete
- `PATCH /api/v3/help_commands/{id}/restore` - Restore
- `PATCH /api/v3/help_commands/by_name/{name}/view` - Increment views
- `GET /api/v3/help_commands/autocomplete` - Autocomplete
- `GET /api/v3/help_commands/stats` - Statistics
**Database Table** (`help_commands`):
```sql
CREATE TABLE help_commands (
id SERIAL PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
category TEXT,
created_by_discord_id BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP,
last_modified_by BIGINT,
is_active BOOLEAN DEFAULT TRUE,
view_count INTEGER DEFAULT 0,
display_order INTEGER DEFAULT 0
);
```
## Features
### Soft Delete
- Topics are never permanently deleted from the database
- `is_active` flag controls visibility
- Admins can restore deleted topics (future enhancement)
- Full audit trail preserved
### View Tracking
- Automatic view count increment when topics are accessed
- Statistics available via API
- Most viewed topics tracked
### Category Organization
- Optional categorization of topics
- Suggested categories:
- `rules` - League rules and regulations
- `guides` - How-to guides and tutorials
- `resources` - Links to external resources
- `info` - General league information
- `faq` - Frequently asked questions
### Markdown Support
- Full markdown formatting in content
- Support for:
- Headers
- Bold/italic text
- Lists (ordered and unordered)
- Links
- Code blocks
- Blockquotes
### Autocomplete
- Fast topic name suggestions
- Searches across names and titles
- Limited to 25 suggestions for performance
## Use Cases
### Example Help Topics
**Trading Rules** (`/help trading-rules`):
```markdown
# Trading Rules & Guidelines
## Trade Deadline
All trades must be completed by Week 15 of the regular season.
## Restrictions
- Maximum 3 trades per team per season
- All trades must be approved by league commissioner
- No trading draft picks beyond 2 seasons ahead
## How to Propose a Trade
Use the `/trade` command to propose a trade. Both teams must accept before the trade is processed.
```
**Discord Links** (`/help links`):
```markdown
# Important League Links
## Website
https://sba-league.com
## Google Sheet
https://docs.google.com/spreadsheets/...
## Discord Invite
https://discord.gg/...
## Rules Document
https://docs.google.com/document/...
```
**How to Trade** (`/help how-to-trade`):
```markdown
# How to Use the Trade System
1. Type `/trade` to start a new trade proposal
2. Select the team you want to trade with
3. Add players/picks to the trade
4. Submit for review
5. Both teams must accept
6. Commissioner approves
7. Trade is processed!
For more information, see `/help trading-rules`
```
## Error Handling
### Common Errors
**Topic Not Found**:
```
❌ Topic Not Found
No help topic named 'xyz' exists.
Use /help to see available topics.
```
**Permission Denied**:
```
❌ Permission Denied
Only administrators and users with the Help Editor role can create help topics.
```
**Topic Already Exists**:
```
❌ Topic Already Exists
A help topic named 'trading-rules' already exists.
Try a different name.
```
**Validation Errors**:
- Topic name too short/long
- Invalid characters in topic name
- Content too long (>4000 chars)
- Title too long (>200 chars)
## Best Practices
### For Administrators
1. **Use Clear Topic Names**
- Use lowercase with hyphens: `trading-rules`, `how-to-draft`
- Keep names short but descriptive
- Avoid special characters
2. **Organize by Category**
- Consistent category naming
- Group related topics together
- Use standard categories (rules, guides, resources, info, faq)
3. **Write Clear Content**
- Use markdown formatting for readability
- Keep content concise and focused
- Link to related topics when appropriate
- Update regularly to keep information current
4. **Monitor Usage**
- Check view counts to see popular topics
- Update frequently accessed topics
- Archive outdated information
### For Users
1. **Browse Topics**
- Use `/help` to see all available topics
- Use `/help-list` to browse by category
- Use autocomplete to find topics quickly
2. **Request New Topics**
- Contact admins or help editors
- Suggest topics that would be useful
- Provide draft content if possible
## Testing
### Test Coverage
- ✅ Model validation tests
- ✅ Service layer CRUD operations
- ✅ Permission checking
- ✅ Autocomplete functionality
- ✅ Soft delete behavior
- ✅ View count incrementing
### Test Files
- `tests/test_models_help_command.py`
- `tests/test_services_help_commands.py`
- `tests/test_commands_help.py`
## Future Enhancements
### Planned Features (Post-Launch)
- Restore command for deleted topics (`/help-restore <topic>`)
- Statistics dashboard (`/help-stats`)
- Search functionality across all content
- Topic versioning and change history
- Attachments support (images, files)
- Related topics linking
- User feedback and ratings
- Full-text search in content
### Potential Improvements
- Rich embed support with custom colors
- Topic aliases (multiple names for same topic)
- Scheduled topic updates
- Topic templates for common formats
- Import/export functionality
- Bulk operations for admins
## Migration from Legacy System
If migrating from an older help/links system:
1. **Export existing content** from old system
2. **Create help topics** using `/help-create`
3. **Test all topics** for formatting and accuracy
4. **Update documentation** to reference new commands
5. **Train help editors** on new system
6. **Announce to users** with usage instructions
## Support
### For Users
- Use `/help` to browse available topics
- Contact server admins for topic requests
- Report broken links or outdated information
### For Administrators
- Review the implementation plan in `.claude/HELP_COMMANDS_PLAN.md`
- Check database migration docs in `.claude/DATABASE_MIGRATION_HELP_COMMANDS.md`
- See main project documentation in `CLAUDE.md`
---
**Implementation Details:**
- **Models:** `models/help_command.py`
- **Service:** `services/help_commands_service.py`
- **Views:** `views/help_commands.py`
- **Commands:** `commands/help/main.py`
- **Constants:** `constants.py` (HELP_EDITOR_ROLE_NAME)
- **Tests:** `tests/test_*_help*.py`
**Related Documentation:**
- Implementation Plan: `.claude/HELP_COMMANDS_PLAN.md`
- Database Migration: `.claude/DATABASE_MIGRATION_HELP_COMMANDS.md`
- Project Overview: `CLAUDE.md`
- Roadmap: `PRE_LAUNCH_ROADMAP.md`

View File

@ -1,494 +0,0 @@
# Injury Commands
**Command Group:** `/injury`
**Permission Required:** SBA Players role (for set-new and clear)
**Subcommands:** roll, set-new, clear
## Overview
The injury command family provides comprehensive player injury management for the SBA league. Team managers can roll for injuries using official Strat-o-Matic injury tables, record confirmed injuries, and clear injuries when players return.
## Commands
### `/injury roll`
Roll for injury based on a player's injury rating using 3d6 dice and official injury tables.
**Usage:**
```
/injury roll <player_name>
```
**Parameters:**
- `player_name` (required, autocomplete): Name of the player - uses smart autocomplete prioritizing your team's players
**Injury Rating Format:**
The player's `injury_rating` field contains both the games played and rating in format `#p##`:
- **Format**: `1p70`, `4p50`, `2p65`, etc.
- **First character**: Games played in current series (1-6)
- **Remaining characters**: Injury rating (p70, p65, p60, p50, p40, p30, p20)
**Examples:**
- `1p70` = 1 game played, p70 rating
- `4p50` = 4 games played, p50 rating
- `2p65` = 2 games played, p65 rating
**Dice Roll:**
- Rolls 3d6 (3-18 range)
- Automatically extracts games played and rating from player's injury_rating field
- Looks up result in official Strat-o-Matic injury tables
- Returns injury duration based on rating and games played
**Possible Results:**
- **OK**: No injury
- **REM**: Remainder of game (batters) or Fatigued (pitchers)
- **Number**: Games player will miss (1-24 games)
**Example:**
```
/injury roll Mike Trout
```
**Response Fields:**
- **Roll**: Total rolled and individual dice (e.g., "15 (3d6: 5 + 5 + 5)")
- **Player**: Player name and position
- **Injury Rating**: Full rating with parsed details (e.g., "4p50 (p50, 4 games)")
- **Result**: Injury outcome (OK, REM, or number of games)
- **Team**: Player's current team
**Response Colors:**
- **Green**: OK (no injury)
- **Gold**: REM (remainder of game/fatigued)
- **Orange**: Number of games (injury occurred)
**Error Handling:**
If a player's `injury_rating` is not in the correct format, an error message will be displayed:
```
Invalid Injury Rating Format
{Player} has an invalid injury rating: `{rating}`
Expected format: #p## (e.g., 1p70, 4p50)
```
---
### `/injury set-new`
Record a new injury for a player on your team.
**Usage:**
```
/injury set-new <player_name> <this_week> <this_game> <injury_games>
```
**Parameters:**
- `player_name` (required): Name of the player to injure
- `this_week` (required): Current week number
- `this_game` (required): Current game number (1-4)
- `injury_games` (required): Total number of games player will be out
**Validation:**
- Player must exist in current season
- Player cannot already have an active injury
- Game number must be between 1 and 4
- Injury duration must be at least 1 game
**Automatic Calculations:**
The command automatically calculates:
1. Injury start date (adjusts for game 4 edge case)
2. Return date based on injury duration
3. Week rollover when games exceed 4 per week
**Example:**
```
/injury set-new Mike Trout 5 2 4
```
This records an injury occurring in week 5, game 2, with player out for 4 games (returns week 6, game 2).
**Response:**
- Confirmation embed with injury details
- Player's name, position, and team
- Total games missed
- Calculated return date
---
### `/injury clear`
Clear a player's active injury and mark them as eligible to play.
**Usage:**
```
/injury clear <player_name>
```
**Parameters:**
- `player_name` (required): Name of the player whose injury to clear
**Validation:**
- Player must exist in current season
- Player must have an active injury
**Example:**
```
/injury clear Mike Trout
```
**Response:**
- Confirmation that injury was cleared
- Shows previous return date
- Shows total games that were missed
- Player's team information
---
## Date Format
All injury dates use the format `w##g#`:
- `w##` = Week number (zero-padded to 2 digits)
- `g#` = Game number (1-4)
**Examples:**
- `w05g2` = Week 5, Game 2
- `w12g4` = Week 12, Game 4
- `w01g1` = Week 1, Game 1
## Injury Calculation Logic
### Basic Calculation
For an injury of N games starting at week W, game G:
1. **Calculate weeks and remaining games:**
```
out_weeks = floor(N / 4)
out_games = N % 4
```
2. **Calculate return date:**
```
return_week = W + out_weeks
return_game = G + 1 + out_games
```
3. **Handle week rollover:**
```
if return_game > 4:
return_week += 1
return_game -= 4
```
### Special Cases
#### Game 4 Edge Case
If injury occurs during game 4, the start date is adjusted:
```
start_week = W + 1
start_game = 1
```
#### Examples
**Example 1: Simple injury (same week)**
- Current: Week 5, Game 1
- Injury: 2 games
- Return: Week 5, Game 4
**Example 2: Week rollover**
- Current: Week 5, Game 3
- Injury: 3 games
- Return: Week 6, Game 3
**Example 3: Multi-week injury**
- Current: Week 5, Game 2
- Injury: 8 games
- Return: Week 7, Game 3
**Example 4: Game 4 start**
- Current: Week 5, Game 4
- Injury: 2 games
- Start: Week 6, Game 1
- Return: Week 6, Game 3
## Database Schema
### Injury Model
```python
class Injury(SBABaseModel):
id: int # Injury ID
season: int # Season number
player_id: int # Player ID
total_games: int # Total games player will be out
start_week: int # Week injury started
start_game: int # Game number injury started (1-4)
end_week: int # Week player returns
end_game: int # Game number player returns (1-4)
is_active: bool # Whether injury is currently active
```
### API Integration
The commands interact with the following API endpoints:
- `GET /api/v3/injuries` - Query injuries with filters
- `POST /api/v3/injuries` - Create new injury record
- `PATCH /api/v3/injuries/{id}` - Update injury (clear active status)
- `PATCH /api/v3/players/{id}` - Update player's il_return field
## Service Layer
### InjuryService
**Location:** `services/injury_service.py`
**Key Methods:**
- `get_active_injury(player_id, season)` - Get active injury for player
- `get_injuries_by_player(player_id, season, active_only)` - Get all injuries for player
- `get_injuries_by_team(team_id, season, active_only)` - Get team injuries
- `create_injury(...)` - Create new injury record
- `clear_injury(injury_id)` - Deactivate injury
## Permissions
### Required Roles
**For `/injury check`:**
- No role required (available to all users)
**For `/injury set-new` and `/injury clear`:**
- **SBA Players** role required
- Configured via `SBA_PLAYERS_ROLE_NAME` environment variable
### Permission Checks
The commands use `has_player_role()` method to verify user has appropriate role:
```python
def has_player_role(self, interaction: discord.Interaction) -> bool:
"""Check if user has the SBA Players role."""
player_role = discord.utils.get(
interaction.guild.roles,
name=get_config().sba_players_role_name
)
return player_role in interaction.user.roles if player_role else False
```
## Error Handling
### Common Errors
**Player Not Found:**
```
❌ Player Not Found
I did not find anybody named **{player_name}**.
```
**Already Injured:**
```
❌ Already Injured
Hm. It looks like {player_name} is already hurt.
```
**Not Injured:**
```
❌ No Active Injury
{player_name} isn't injured.
```
**Invalid Input:**
```
❌ Invalid Input
Game number must be between 1 and 4.
```
**Permission Denied:**
```
❌ Permission Denied
This command requires the **SBA Players** role.
```
## Logging
All injury commands use the `@logged_command` decorator for automatic logging:
```python
@app_commands.command(name="check")
@logged_command("/injury check")
async def injury_check(self, interaction, player_name: str):
# Command implementation
```
**Log Context:**
- Command name
- User ID and username
- Player name
- Season
- Injury details (duration, dates)
- Success/failure status
**Example Log:**
```json
{
"level": "INFO",
"command": "/injury set-new",
"user_id": "123456789",
"player_name": "Mike Trout",
"season": 12,
"injury_games": 4,
"return_date": "w06g2",
"message": "Injury set for Mike Trout"
}
```
## Testing
### Test Coverage
**Location:** `tests/test_services_injury.py`
**Test Categories:**
1. **Model Tests** (5 tests) - Injury model creation and properties
2. **Service Tests** (8 tests) - InjuryService CRUD operations with API mocking
3. **Roll Logic Tests** (8 tests) - Injury rating parsing, table lookup, and dice roll logic
4. **Calculation Tests** (5 tests) - Date calculation logic for injury duration
**Total:** 26 comprehensive tests
**Running Tests:**
```bash
# Run all injury tests
python -m pytest tests/test_services_injury.py -v
# Run specific test class
python -m pytest tests/test_services_injury.py::TestInjuryService -v
python -m pytest tests/test_services_injury.py::TestInjuryRollLogic -v
# Run with coverage
python -m pytest tests/test_services_injury.py --cov=services.injury_service --cov=commands.injuries
```
## Injury Roll Tables
### Table Structure
The injury tables are based on official Strat-o-Matic rules with the following structure:
**Ratings:** p70, p65, p60, p50, p40, p30, p20 (higher is better)
**Games Played:** 1-6 games in current series
**Roll:** 3d6 (results from 3-18)
### Rating Availability by Games Played
Not all ratings are available for all games played combinations:
- **1 game**: All ratings (p70-p20)
- **2 games**: All ratings (p70-p20)
- **3 games**: p65-p20 (p70 exempt)
- **4 games**: p60-p20 (p70, p65 exempt)
- **5 games**: p60-p20 (p70, p65 exempt)
- **6 games**: p40-p20 (p70, p65, p60, p50 exempt)
When a rating/games combination has no table, the result is automatically "OK" (no injury).
### Example Table (p65, 1 game):
| Roll | Result |
|------|--------|
| 3 | 2 |
| 4 | 2 |
| 5 | OK |
| 6 | REM |
| 7 | 1 |
| ... | ... |
| 18 | 12 |
## UI/UX Design
### Embed Colors
- **Roll (OK):** Green - No injury
- **Roll (REM):** Gold - Remainder of game/Fatigued
- **Roll (Injury):** Orange - Number of games
- **Set New:** Success (green) - `EmbedTemplate.success()`
- **Clear:** Success (green) - `EmbedTemplate.success()`
- **Errors:** Error (red) - `EmbedTemplate.error()`
### Response Format
All successful responses use Discord embeds with:
- Clear title indicating action/status
- Well-organized field layout
- Team information when applicable
- Consistent formatting for dates
## Integration with Player Model
The Player model includes injury-related fields:
```python
class Player(SBABaseModel):
# ... other fields ...
pitcher_injury: Optional[int] # Pitcher injury rating
injury_rating: Optional[str] # General injury rating
il_return: Optional[str] # Injured list return date (w##g#)
```
When an injury is set or cleared, the player's `il_return` field is automatically updated via PlayerService.
## Future Enhancements
Possible improvements for future versions:
1. **Injury History** - View player's injury history for a season
2. **Team Injury Report** - List all injuries for a team
3. **Injury Notifications** - Automatic notifications when players return from injury
4. **Injury Statistics** - Track injury trends and statistics
5. **Injury Chart Image** - Display the official injury chart as an embed image
## Migration from Legacy
### Legacy Commands
The legacy injury commands were located in:
- `discord-app/cogs/players.py` - `set_injury_slash()` and `clear_injury_slash()`
- `discord-app/cogs/players.py` - `injury_roll_slash()` with manual rating/games input
### Key Improvements
1. **Cleaner Command Structure:** Using GroupCog for organized subcommands (`/injury roll`, `/injury set-new`, `/injury clear`)
2. **Simplified Interface:** Single parameter for injury roll - games played automatically extracted from player data
3. **Smart Injury Ratings:** Automatically reads and parses player's injury rating from database
4. **Player Autocomplete:** Modern autocomplete with team prioritization for better UX
5. **Better Error Handling:** User-friendly error messages via EmbedTemplate with format validation
6. **Improved Logging:** Automatic logging via @logged_command decorator
7. **Service Layer:** Separated business logic from command handlers
8. **Type Safety:** Full type hints and Pydantic models
9. **Testability:** Comprehensive unit tests (26 tests) with mocked API calls
10. **Modern UI:** Consistent embed-based responses with color coding
11. **Official Tables:** Complete Strat-o-Matic injury tables built into the command
### Migration Details
**Old:** `/injuryroll <rating> <games>` - Manual rating and games selection
**New:** `/injury roll <player>` - Single parameter, automatic rating and games extraction from player's `injury_rating` field
**Old:** `/setinjury <player> <week> <game> <duration>`
**New:** `/injury set-new <player> <week> <game> <duration>` - Same functionality, better naming
**Old:** `/clearinjury <player>`
**New:** `/injury clear <player>` - Same functionality, better naming
### Database Field Update
The `injury_rating` field format has changed to include games played:
- **Old Format**: `p65`, `p70`, etc. (rating only)
- **New Format**: `1p70`, `4p50`, `2p65`, etc. (games + rating)
Players must have their `injury_rating` field updated to the new format for the `/injury roll` command to work.
---
**Last Updated:** January 2025
**Version:** 2.0
**Status:** Active

View File

@ -17,14 +17,21 @@ from discord import app_commands
from discord.ext import commands
from config import get_config
from models.current import Current
from models.injury import Injury
from models.player import Player
from models.team import RosterType
from services.player_service import player_service
from services.injury_service import injury_service
from services.league_service import league_service
from services.giphy_service import GiphyService
from utils import team_utils
from utils.logging import get_contextual_logger
from utils.decorators import logged_command
from utils.autocomplete import player_autocomplete
from views.base import ConfirmationView
from views.embeds import EmbedTemplate
from views.modals import PitcherRestModal, BatterInjuryModal
from exceptions import BotException
@ -77,6 +84,16 @@ class InjuryGroup(app_commands.Group):
player = players[0]
# Check if player already has an active injury
existing_injury = await injury_service.get_active_injury(player.id, current.season)
if existing_injury:
embed = EmbedTemplate.error(
title="Already Injured",
description=f"Hm. It looks like {player.name} is already hurt."
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Check for injury_rating field
if not player.injury_rating:
embed = EmbedTemplate.error(
@ -145,16 +162,55 @@ class InjuryGroup(app_commands.Group):
inline=False
)
# Format result
view = None
# Format result and create callbacks for confirmation
if isinstance(injury_result, int):
result_text = f"**{injury_result} game{'s' if injury_result > 1 else ''}**"
embed.color = discord.Color.orange()
if injury_result > 6:
gif_search_text = ['well shit', 'well fuck', 'god dammit']
else:
gif_search_text = ['bummer', 'well damn']
if player.is_pitcher:
result_text += ' plus their current rest requirement'
# Pitcher callback shows modal to collect rest games
async def pitcher_confirm_callback(button_interaction: discord.Interaction):
"""Show modal to collect pitcher rest information."""
modal = PitcherRestModal(
player=player,
injury_games=injury_result,
season=current.season
)
await button_interaction.response.send_modal(modal)
injury_callback = pitcher_confirm_callback
else:
# Batter callback shows modal to collect current week/game
async def batter_confirm_callback(button_interaction: discord.Interaction):
"""Show modal to collect current week/game information for batter injury."""
modal = BatterInjuryModal(
player=player,
injury_games=injury_result,
season=current.season
)
await button_interaction.response.send_modal(modal)
injury_callback = batter_confirm_callback
# Create confirmation view with appropriate callback
view = ConfirmationView(
user_id=interaction.user.id,
timeout=180.0, # 3 minutes for confirmation
responders=[player.team.gmid, player.team.gmid2] if player.team else None,
confirm_callback=injury_callback,
confirm_label="Log Injury",
cancel_label="Ignore Injury"
)
elif injury_result == 'REM':
if player.is_pitcher:
result_text = '**FATIGUED**'
@ -167,24 +223,27 @@ class InjuryGroup(app_commands.Group):
embed.color = discord.Color.green()
gif_search_text = ['we are so back', 'all good', 'totally fine']
# embed.add_field(name='', value='', inline=False)
embed.add_field(
name="Injury Length",
value=result_text,
inline=True
)
try:
injury_gif = await GiphyService().get_gif(
phrase_options=gif_search_text
)
except Exception:
injury_gif = ''
embed.set_image(url=injury_gif)
await interaction.followup.send(embed=embed)
# Send confirmation (only include view if injury requires logging)
if view is not None:
await interaction.followup.send(embed=embed, view=view)
else:
await interaction.followup.send(embed=embed)
def _get_injury_result(self, rating: str, games_played: int, roll: int):
"""
@ -397,7 +456,7 @@ class InjuryGroup(app_commands.Group):
# Success response
embed = EmbedTemplate.success(
title="Injury Recorded",
description=f"{player.name} has been placed on the injured list."
description=f"{player.name}'s injury has been logged"
)
embed.add_field(
@ -434,9 +493,46 @@ class InjuryGroup(app_commands.Group):
season=current.season,
injury_id=injury.id
)
def _calc_injury_dates(self, start_week: int, start_game: int, injury_games: int) -> dict:
"""
Calculate injury dates from start week/game and injury duration.
Args:
start_week: Starting week number
start_game: Starting game number (1-4)
injury_games: Number of games player will be out
Returns:
Dictionary with calculated injury date fields
"""
# Calculate return date
out_weeks = math.floor(injury_games / 4)
out_games = injury_games % 4
return_week = start_week + out_weeks
return_game = start_game + 1 + out_games
if return_game > 4:
return_week += 1
return_game -= 4
# Adjust start date if injury starts after game 4
actual_start_week = start_week if start_game != 4 else start_week + 1
actual_start_game = start_game + 1 if start_game != 4 else 1
return {
'total_games': injury_games,
'start_week': actual_start_week,
'start_game': actual_start_game,
'end_week': return_week,
'end_game': return_game
}
@app_commands.command(name="clear", description="Clear a player's injury (requires SBA Players role)")
@app_commands.describe(player_name="Player name to clear injury")
@app_commands.autocomplete(player_name=player_autocomplete)
@logged_command("/injury clear")
async def injury_clear(self, interaction: discord.Interaction, player_name: str):
"""Clear a player's active injury."""
@ -480,35 +576,18 @@ class InjuryGroup(app_commands.Group):
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Clear the injury
success = await injury_service.clear_injury(injury.id)
if not success:
embed = EmbedTemplate.error(
title="Error",
description="Failed to clear the injury. Please try again."
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Clear player's il_return field
await player_service.update_player(player.id, {'il_return': None})
# Success response
embed = EmbedTemplate.success(
title="Injury Cleared",
description=f"{player.name} has been cleared and is eligible to play again."
# Create confirmation embed
embed = EmbedTemplate.info(
title=f"{player.name}",
description=f"Is **{player.name}** cleared to return?"
)
embed.add_field(
name="Previous Return Date",
value=injury.return_date,
inline=True
)
if player.team and player.team.thumbnail is not None:
embed.set_thumbnail(url=player.team.thumbnail)
embed.add_field(
name="Total Games Missed",
value=injury.duration_display,
name="Player",
value=f"{player.name} ({player.primary_position})",
inline=True
)
@ -516,19 +595,91 @@ class InjuryGroup(app_commands.Group):
embed.add_field(
name="Team",
value=f"{player.team.lname} ({player.team.abbrev})",
inline=False
inline=True
)
await interaction.followup.send(embed=embed)
# Log for debugging
self.logger.info(
f"Injury cleared for {player.name}",
player_id=player.id,
season=current.season,
injury_id=injury.id
embed.add_field(
name="Expected Return",
value=injury.return_date,
inline=True
)
embed.add_field(
name="Games Missed",
value=injury.duration_display,
inline=True
)
if player.team.roster_type() != RosterType.MAJOR_LEAGUE:
responder_team = await team_utils.get_user_major_league_team(interaction.user.id)
# Create callback for confirmation
async def clear_confirm_callback(button_interaction: discord.Interaction):
"""Handle confirmation to clear injury."""
# Clear the injury
success = await injury_service.clear_injury(injury.id)
if not success:
error_embed = EmbedTemplate.error(
title="Error",
description="Failed to clear the injury. Please try again."
)
await button_interaction.response.send_message(embed=error_embed, ephemeral=True)
return
# Clear player's il_return field
await player_service.update_player(player.id, {'il_return': None})
# Success response
success_embed = EmbedTemplate.success(
title="Injury Cleared",
description=f"{player.name} has been cleared and is eligible to play again."
)
success_embed.add_field(
name="Injury Return Date",
value=injury.return_date,
inline=True
)
success_embed.add_field(
name="Total Games Missed",
value=injury.duration_display,
inline=True
)
if player.team:
success_embed.add_field(
name="Team",
value=f"{player.team.lname}",
inline=False
)
if player.team.thumbnail is not None:
success_embed.set_thumbnail(url=player.team.thumbnail)
await button_interaction.response.send_message(embed=success_embed)
# Log for debugging
self.logger.info(
f"Injury cleared for {player.name}",
player_id=player.id,
season=current.season,
injury_id=injury.id
)
# Create confirmation view
view = ConfirmationView(
user_id=interaction.user.id,
timeout=180.0, # 3 minutes for confirmation
responders=[responder_team.gmid, responder_team.gmid2] if responder_team else None,
confirm_callback=clear_confirm_callback,
confirm_label="Clear Injury",
cancel_label="Cancel"
)
# Send confirmation embed with view
await interaction.followup.send(embed=embed, view=view)
async def setup(bot: commands.Bot):
"""Setup function for loading the injury commands."""

View File

@ -1,151 +0,0 @@
# League Commands
This directory contains Discord slash commands related to league-wide information and statistics.
## Files
### `info.py`
- **Command**: `/league`
- **Description**: Display current league status and information
- **Functionality**: Shows current season/week, phase (regular season/playoffs/offseason), transaction status, trade deadlines, and league configuration
- **Service Dependencies**: `league_service.get_current_state()`
- **Key Features**:
- Dynamic phase detection (offseason, playoffs, regular season)
- Transaction freeze status
- Trade deadline and playoff schedule information
- Draft pick trading status
### `standings.py`
- **Commands**:
- `/standings` - Display league standings by division
- `/playoff-picture` - Show current playoff picture and wild card race
- **Parameters**:
- `season`: Optional season number (defaults to current)
- `division`: Optional division filter for standings
- **Service Dependencies**: `standings_service`
- **Key Features**:
- Division-based standings display
- Games behind calculations
- Recent form statistics (home record, last 8 games, current streak)
- Playoff cutoff visualization
- Wild card race tracking
### `schedule.py`
- **Commands**:
- `/schedule` - Display game schedules
- `/results` - Show recent game results
- **Parameters**:
- `season`: Optional season number (defaults to current)
- `week`: Optional specific week filter
- `team`: Optional team abbreviation filter
- **Service Dependencies**: `schedule_service`
- **Key Features**:
- Weekly schedule views
- Team-specific schedule filtering
- Series grouping and summary
- Recent/upcoming game overview
- Game completion tracking
### `submit_scorecard.py`
- **Command**: `/submit-scorecard`
- **Description**: Submit Google Sheets scorecards with game results and play-by-play data
- **Parameters**:
- `sheet_url`: Full URL to the Google Sheets scorecard
- **Required Role**: `Season 12 Players`
- **Service Dependencies**:
- `SheetsService` - Google Sheets data extraction
- `game_service` - Game CRUD operations
- `play_service` - Play-by-play data management
- `decision_service` - Pitching decision management
- `standings_service` - Standings recalculation
- `league_service` - Current state retrieval
- `team_service` - Team lookup
- `player_service` - Player lookup for results display
- **Key Features**:
- **Scorecard Validation**: Checks sheet access and version compatibility
- **Permission Control**: Only GMs of playing teams can submit
- **Duplicate Detection**: Identifies already-played games with confirmation dialog
- **Transaction Rollback**: Full rollback support at 3 states:
- `PLAYS_POSTED`: Deletes plays on error
- `GAME_PATCHED`: Wipes game and deletes plays on error
- `COMPLETE`: All data committed successfully
- **Data Extraction**: Reads 68 fields from Playtable, 14 fields from Pitcherstats, box score, and game metadata
- **Results Display**: Rich embed with box score, pitching decisions, and top 3 key plays by WPA
- **Automated Standings**: Triggers standings recalculation after successful submission
- **News Channel Posting**: Automatically posts results to configured channel
**Workflow (14 Phases)**:
1. Validate scorecard access and version
2. Extract game metadata from Setup tab
3. Lookup teams and match managers
4. Check user permissions (must be GM of one team or bot owner)
5. Check for duplicate games (with confirmation if found)
6. Find scheduled game in database
7. Read play-by-play data (up to 297 plays)
8. Submit plays to database
9. Read box score
10. Update game with scores and managers
11. Read pitching decisions (up to 27 pitchers)
12. Submit decisions to database
13. Create and post results embed to news channel
14. Recalculate league standings
**Error Handling**:
- User-friendly error messages for common issues
- Graceful rollback on validation errors
- API error parsing for actionable feedback
- Non-critical errors (key plays, standings) don't fail submission
**Configuration**:
- `sheets_credentials_path` (in config.py): Path to Google service account credentials JSON (set via `SHEETS_CREDENTIALS_PATH` env var)
- `SBA_NETWORK_NEWS_CHANNEL`: Channel name for results posting
- `SBA_PLAYERS_ROLE_NAME`: Role required to submit scorecards
## Architecture Notes
### Decorator Usage
All commands use the `@logged_command` decorator pattern:
- Eliminates boilerplate logging code
- Provides consistent error handling
- Automatic request tracing and timing
### Error Handling
- Graceful fallbacks for missing data
- User-friendly error messages
- Ephemeral responses for errors
### Embed Structure
- Uses `EmbedTemplate` for consistent styling
- Color coding based on context (success/error/info)
- Rich formatting with team logos and thumbnails
## Troubleshooting
### Common Issues
1. **No league data available**: Check `league_service.get_current_state()` API endpoint
2. **Standings not loading**: Verify `standings_service.get_standings_by_division()` returns valid data
3. **Schedule commands failing**: Ensure `schedule_service` methods are properly handling season/week parameters
### Dependencies
- `services.league_service`
- `services.standings_service`
- `services.schedule_service`
- `services.sheets_service` (NEW) - Google Sheets integration
- `services.game_service` (NEW) - Game management
- `services.play_service` (NEW) - Play-by-play data
- `services.decision_service` (NEW) - Pitching decisions
- `services.team_service`
- `services.player_service`
- `utils.decorators.logged_command`
- `utils.discord_helpers` (NEW) - Channel and message utilities
- `utils.team_utils`
- `views.embeds.EmbedTemplate`
- `views.confirmations.ConfirmationView` (NEW) - Reusable confirmation dialog
- `constants.SBA_CURRENT_SEASON`
- `config.BotConfig.sheets_credentials_path` (NEW) - Google Sheets credentials path
- `constants.SBA_NETWORK_NEWS_CHANNEL` (NEW)
- `constants.SBA_PLAYERS_ROLE_NAME` (NEW)
### Testing
Run tests with: `python -m pytest tests/test_commands_league.py -v`

View File

@ -1,104 +0,0 @@
# Player Commands
This directory contains Discord slash commands for player information and statistics.
## Files
### `info.py`
- **Command**: `/player`
- **Description**: Display comprehensive player information and statistics
- **Parameters**:
- `name` (required): Player name to search for
- `season` (optional): Season for statistics (defaults to current season)
- **Service Dependencies**:
- `player_service.get_players_by_name()`
- `player_service.search_players_fuzzy()`
- `player_service.get_player()`
- `stats_service.get_player_stats()`
## Key Features
### Player Search
- **Exact Name Matching**: Primary search method using player name
- **Fuzzy Search Fallback**: If no exact match, suggests similar player names
- **Multiple Player Handling**: When multiple players match, attempts exact match or asks user to be more specific
- **Suggestion System**: Shows up to 10 suggested players with positions when no exact match found
### Player Information Display
- **Basic Info**: Name, position(s), team, season
- **Statistics Integration**:
- Batting stats (AVG/OBP/SLG, OPS, wOBA, HR, RBI, runs, etc.)
- Pitching stats (W-L record, ERA, WHIP, strikeouts, saves, etc.)
- Two-way player detection and display
- **Visual Elements**:
- Team logo as author icon
- Player card image as main image
- Thumbnail priority: fancy card → headshot → team logo
- Team color theming for embed
### Advanced Features
- **Concurrent Data Fetching**: Player data and statistics retrieved in parallel for performance
- **sWAR Display**: Shows Strat-o-Matic WAR value
- **Multi-Position Support**: Displays all eligible positions
- **Rich Error Handling**: Graceful fallbacks when data is unavailable
## Architecture Notes
### Search Logic Flow
1. Search by exact name in specified season
2. If no results, try fuzzy search across all players
3. If single result, display player card
4. If multiple results, attempt exact name match
5. If still multiple, show disambiguation list
### Performance Optimizations
- `asyncio.gather()` for concurrent API calls
- Efficient player data and statistics retrieval
- Lazy loading of optional player images
### Error Handling
- No players found: Suggests fuzzy matches
- Multiple matches: Provides clarification options
- Missing data: Shows partial information with clear indicators
- API failures: Graceful degradation with fallback data
## Troubleshooting
### Common Issues
1. **Player not found**:
- Check player name spelling
- Verify player exists in the specified season
- Use fuzzy search suggestions
2. **Statistics not loading**:
- Verify `stats_service.get_player_stats()` API endpoint
- Check if player has statistics for the requested season
- Ensure season parameter is valid
3. **Images not displaying**:
- Check player image URLs in database
- Verify team thumbnail URLs
- Ensure image hosting is accessible
4. **Performance issues**:
- Monitor concurrent API call efficiency
- Check database query performance
- Verify embed size limits
### Dependencies
- `services.player_service`
- `services.stats_service`
- `utils.decorators.logged_command`
- `views.embeds.EmbedTemplate`
- `constants.SBA_CURRENT_SEASON`
- `exceptions.BotException`
### Testing
Run tests with: `python -m pytest tests/test_commands_players.py -v`
## Database Requirements
- Player records with name, positions, team associations
- Statistics tables for batting and pitching
- Image URLs for player cards, headshots, and fancy cards
- Team logo and color information

View File

@ -14,8 +14,7 @@ from services.player_service import player_service
from services.stats_service import stats_service
from utils.logging import get_contextual_logger
from utils.decorators import logged_command
from views.embeds import EmbedColors, EmbedTemplate
from models.team import RosterType
from views.players import PlayerStatsView
async def player_name_autocomplete(
@ -155,176 +154,21 @@ class PlayerInfoCommands(commands.Cog):
batting_stats=bool(batting_stats),
pitching_stats=bool(pitching_stats))
# Create comprehensive player embed with statistics
self.logger.debug("Creating Discord embed with statistics")
embed = await self._create_player_embed_with_stats(
player_with_team,
search_season,
batting_stats,
pitching_stats
)
await interaction.followup.send(embed=embed)
async def _create_player_embed_with_stats(
self,
player,
season: int,
batting_stats=None,
pitching_stats=None
) -> discord.Embed:
"""Create a comprehensive player embed with statistics."""
# Determine embed color based on team
embed_color = EmbedColors.PRIMARY
if hasattr(player, 'team') and player.team and hasattr(player.team, 'color'):
try:
# Convert hex color string to int
embed_color = int(player.team.color, 16)
except (ValueError, TypeError):
embed_color = EmbedColors.PRIMARY
# Create base embed
embed = EmbedTemplate.create_base_embed(
title=f"🏟️ {player.name}",
color=embed_color
)
# Set team logo beside player name (as author icon)
if hasattr(player, 'team') and player.team and hasattr(player.team, 'thumbnail') and player.team.thumbnail:
embed.set_author(
name=player.name,
icon_url=player.team.thumbnail
)
# Remove the emoji from title since we're using author
embed.title = None
# Basic info section
embed.add_field(
name="Position",
value=player.primary_position,
inline=True
)
if hasattr(player, 'team') and player.team:
embed.add_field(
name="Team",
value=f"{player.team.abbrev} - {player.team.sname}",
inline=True
)
# Add Major League affiliate if this is a Minor League team
if player.team.roster_type() == RosterType.MINOR_LEAGUE:
major_affiliate = player.team.get_major_league_affiliate()
if major_affiliate:
embed.add_field(
name="Major Affiliate",
value=major_affiliate,
inline=True
)
embed.add_field(
name="sWAR",
value=f"{player.wara:.1f}",
inline=True
# Create interactive player view with toggleable statistics
self.logger.debug("Creating PlayerStatsView with toggleable statistics")
view = PlayerStatsView(
player=player_with_team,
season=search_season,
batting_stats=batting_stats,
pitching_stats=pitching_stats,
user_id=interaction.user.id
)
embed.add_field(
name="Player ID",
value=str(player.id),
inline=True
)
# All positions if multiple
if len(player.positions) > 1:
embed.add_field(
name="Positions",
value=", ".join(player.positions),
inline=True
)
embed.add_field(
name="Season",
value=str(season),
inline=True
)
# Get initial embed with stats hidden
embed = await view.get_initial_embed()
# Add injury rating if available
if player.injury_rating:
embed.add_field(
name="Injury Rating",
value=player.injury_rating,
inline=True
)
# Add batting stats if available
if batting_stats:
self.logger.debug("Adding batting statistics to embed")
batting_value = (
f"**AVG/OBP/SLG:** {batting_stats.avg:.3f}/{batting_stats.obp:.3f}/{batting_stats.slg:.3f}\n"
f"**OPS:** {batting_stats.ops:.3f} | **wOBA:** {batting_stats.woba:.3f}\n"
f"**HR:** {batting_stats.homerun} | **RBI:** {batting_stats.rbi} | **R:** {batting_stats.run}\n"
f"**AB:** {batting_stats.ab} | **H:** {batting_stats.hit} | **BB:** {batting_stats.bb} | **SO:** {batting_stats.so}"
)
embed.add_field(
name="⚾ Batting Stats",
value=batting_value,
inline=False
)
# Add pitching stats if available
if pitching_stats:
self.logger.debug("Adding pitching statistics to embed")
ip = pitching_stats.innings_pitched
pitching_value = (
f"**W-L:** {pitching_stats.win}-{pitching_stats.loss} | **ERA:** {pitching_stats.era:.2f}\n"
f"**WHIP:** {pitching_stats.whip:.2f} | **IP:** {ip:.1f}\n"
f"**SO:** {pitching_stats.so} | **BB:** {pitching_stats.bb} | **H:** {pitching_stats.hits}\n"
f"**GS:** {pitching_stats.gs} | **SV:** {pitching_stats.saves} | **HLD:** {pitching_stats.hold}"
)
embed.add_field(
name="🥎 Pitching Stats",
value=pitching_value,
inline=False
)
# Add a note if no stats are available
if not batting_stats and not pitching_stats:
embed.add_field(
name="📊 Statistics",
value="No statistics available for this season.",
inline=False
)
# Set player card as main image
if player.image:
embed.set_image(url=player.image)
self.logger.debug("Player card image added to embed", image_url=player.image)
# Set thumbnail with priority: fancycard → headshot → team logo
thumbnail_url = None
thumbnail_source = None
if hasattr(player, 'vanity_card') and player.vanity_card:
thumbnail_url = player.vanity_card
thumbnail_source = "fancycard"
elif hasattr(player, 'headshot') and player.headshot:
thumbnail_url = player.headshot
thumbnail_source = "headshot"
elif hasattr(player, 'team') and player.team and hasattr(player.team, 'thumbnail') and player.team.thumbnail:
thumbnail_url = player.team.thumbnail
thumbnail_source = "team logo"
if thumbnail_url:
embed.set_thumbnail(url=thumbnail_url)
self.logger.debug(f"Thumbnail set from {thumbnail_source}", thumbnail_url=thumbnail_url)
# Footer with player ID and additional info
footer_text = f"Player ID: {player.id}"
if batting_stats and pitching_stats:
footer_text += " • Two-way player"
embed.set_footer(text=footer_text)
return embed
# Send with interactive view
await interaction.followup.send(embed=embed, view=view)
async def setup(bot: commands.Bot):

View File

@ -1,424 +0,0 @@
# Player Image Management Commands
**Last Updated:** January 2025
**Status:** ✅ Fully Implemented
**Location:** `commands/profile/`
## Overview
The Player Image Management system allows users to update player fancy card and headshot images for players on teams they own. Administrators can update any player's images.
## Commands
### `/set-image <image_type> <player_name> <image_url>`
**Description:** Update a player's fancy card or headshot image
**Parameters:**
- `image_type` (choice): Choose "Fancy Card" or "Headshot"
- **Fancy Card**: Shows as thumbnail in player cards (takes priority)
- **Headshot**: Shows as thumbnail if no fancy card exists
- `player_name` (string with autocomplete): Player to update
- `image_url` (string): Direct URL to the image file
**Permissions:**
- **Regular Users**: Can update images for players on teams they own (ML/MiL/IL)
- **Administrators**: Can update any player's images (bypasses organization check)
**Usage Examples:**
```
/set-image fancy-card "Mike Trout" https://example.com/cards/trout.png
/set-image headshot "Shohei Ohtani" https://example.com/headshots/ohtani.jpg
```
## Permission System
### Regular Users
Users can update images for players in their organization:
- **Major League team players** - Direct team ownership
- **Minor League team players** - Owned via organizational affiliation
- **Injured List team players** - Owned via organizational affiliation
**Example:**
If you own the NYY team, you can update images for players on:
- NYY (Major League)
- NYYMIL (Minor League)
- NYYIL (Injured List)
### Administrators
Administrators have unrestricted access to update any player's images regardless of team ownership.
### Permission Check Logic
```python
# Check order:
1. Is user an administrator? → Grant access
2. Does user own any teams? → Continue check
3. Does player belong to user's organization? → Grant access
4. Otherwise → Deny access
```
## URL Requirements
### Format Validation
URLs must meet the following criteria:
- **Protocol**: Must start with `http://` or `https://`
- **Extension**: Must end with valid image extension:
- `.jpg`, `.jpeg` - JPEG format
- `.png` - PNG format
- `.gif` - GIF format (includes animated GIFs)
- `.webp` - WebP format
- **Length**: Maximum 500 characters
- **Query parameters**: Allowed (e.g., `?size=large`)
**Valid Examples:**
```
https://example.com/image.jpg
https://cdn.discord.com/attachments/123/456/player.png
https://i.imgur.com/abc123.webp
https://example.com/image.jpg?size=large&format=original
```
**Invalid Examples:**
```
example.com/image.jpg ❌ Missing protocol
ftp://example.com/image.jpg ❌ Wrong protocol
https://example.com/document.pdf ❌ Wrong extension
https://example.com/page ❌ No extension
```
### Accessibility Testing
After format validation, the bot tests URL accessibility:
- **HTTP HEAD Request**: Checks if URL is reachable
- **Status Code**: Must return 200 OK
- **Content-Type**: Must return `image/*` header
- **Timeout**: 5 seconds maximum
**Common Accessibility Errors:**
- `404 Not Found` - Image doesn't exist at URL
- `403 Forbidden` - Permission denied
- `Timeout` - Server too slow or unresponsive
- `Wrong content-type` - URL points to webpage, not image
## Workflow
### Step-by-Step Process
1. **User invokes command**
```
/set-image fancy-card "Mike Trout" https://example.com/card.png
```
2. **URL Format Validation**
- Checks protocol, extension, length
- If invalid: Shows error with requirements
3. **URL Accessibility Test**
- HTTP HEAD request to URL
- Checks status code and content-type
- If inaccessible: Shows error with troubleshooting tips
4. **Player Lookup**
- Searches for player by name
- Handles multiple matches (asks for exact name)
- If not found: Shows error
5. **Permission Check**
- Admin check → Grant access
- Organization ownership check → Grant/deny access
- If denied: Shows permission error
6. **Preview with Confirmation**
- Shows embed with new image as thumbnail
- Displays current vs new image info
- **Confirm Update** button → Proceed
- **Cancel** button → Abort
7. **Database Update**
- Updates `vanity_card` or `headshot` field
- If failure: Shows error
8. **Success Message**
- Confirms update
- Shows new image
- Displays updated player info
## Field Mapping
| Choice | Database Field | Display Priority | Notes |
|--------|----------------|------------------|-------|
| Fancy Card | `vanity_card` | 1st (highest) | Custom fancy player card |
| Headshot | `headshot` | 2nd | Player headshot photo |
| *(default)* | `team.thumbnail` | 3rd (fallback) | Team logo |
**Display Logic in Player Cards:**
```
IF player.vanity_card exists:
Show vanity_card as thumbnail
ELSE IF player.headshot exists:
Show headshot as thumbnail
ELSE IF player.team.thumbnail exists:
Show team logo as thumbnail
ELSE:
No thumbnail
```
## Best Practices
### For Users
#### Choosing Image URLs
✅ **DO:**
- Use reliable image hosting (Discord CDN, Imgur, established hosts)
- Use direct image links (right-click image → "Copy Image Address")
- Test URLs in browser before submitting
- Use permanent URLs, not temporary upload links
❌ **DON'T:**
- Use image hosting page URLs (must be direct image file)
- Use temporary or expiring URLs
- Use images from unreliable hosts
- Use extremely large images (impacts Discord performance)
#### Image Recommendations
**Fancy Cards:**
- Recommended size: 400x600px (or similar 2:3 aspect ratio)
- Format: PNG or JPEG
- File size: < 2MB for best performance
- Style: Custom designs, player stats, artistic renditions
**Headshots:**
- Recommended size: 256x256px (square aspect ratio)
- Format: PNG or JPEG with transparent background
- File size: < 500KB
- Style: Professional headshot, clean background
#### Finding Good Image URLs
1. **Discord CDN** (best option):
- Upload image to Discord
- Right-click → Copy Link
- Paste as image URL
2. **Imgur**:
- Upload to Imgur
- Right-click image → Copy Image Address
- Use direct link (ends with `.png` or `.jpg`)
3. **Other hosts**:
- Ensure stable, permanent hosting
- Verify URL accessibility before using
### For Administrators
#### Managing Player Images
- Set consistent style guidelines for your league
- Use standard image dimensions for uniformity
- Maintain backup copies of custom images
- Document image sources for attribution
#### Troubleshooting User Issues
Common problems and solutions:
| Issue | Cause | Solution |
|-------|-------|----------|
| "URL not accessible" | Host down, URL expired | Ask for new URL from stable host |
| "Not a valid image" | URL points to webpage | Get direct image link |
| "Permission denied" | User doesn't own team | Verify team ownership |
| "Player not found" | Typo in name | Use autocomplete feature |
## Error Messages
### Format Errors
```
❌ Invalid URL Format
URL must start with http:// or https://
Requirements:
• Must start with `http://` or `https://`
• Must end with `.jpg`, `.jpeg`, `.png`, `.gif`, or `.webp`
• Maximum 500 characters
```
### Accessibility Errors
```
❌ URL Not Accessible
URL returned status 404
Please check:
• URL is correct and not expired
• Image host is online
• URL points directly to an image file
• URL is publicly accessible
```
### Permission Errors
```
❌ Permission Denied
You don't own a team in the NYY organization
You can only update images for players on teams you own.
```
### Player Not Found
```
❌ Player Not Found
No player found matching 'Mike Trut' in the current season.
```
### Multiple Players Found
```
🔍 Multiple Players Found
Multiple players match 'Mike':
• Mike Trout (OF)
• Mike Zunino (C)
Please use the exact name from autocomplete.
```
## Technical Implementation
### Architecture
```
commands/profile/
├── __init__.py # Package setup
├── images.py # Main command implementation
│ ├── validate_url_format() # Format validation
│ ├── test_url_accessibility() # Accessibility testing
│ ├── can_edit_player_image() # Permission checking
│ ├── ImageUpdateConfirmView # Confirmation UI
│ ├── player_name_autocomplete() # Autocomplete function
│ └── ImageCommands # Command cog
└── README.md # This file
```
### Dependencies
- `aiohttp` - Async HTTP requests for URL testing
- `discord.py` - Discord bot framework
- `player_service` - Player CRUD operations
- `team_service` - Team queries and ownership
- Standard bot utilities (logging, decorators, embeds)
### Database Fields
**Player Model** (`models/player.py`):
```python
vanity_card: Optional[str] = Field(None, description="Custom vanity card URL")
headshot: Optional[str] = Field(None, description="Player headshot URL")
```
Both fields are optional and store direct image URLs.
### API Integration
**Update Operation:**
```python
# Update player image
update_data = {"vanity_card": "https://example.com/card.png"}
updated_player = await player_service.update_player(player_id, update_data)
```
**Endpoints Used:**
- `GET /api/v3/players?name={name}&season={season}` - Player search
- `PATCH /api/v3/players/{player_id}?vanity_card={url}` - Update player data
- `GET /api/v3/teams?owner_id={user_id}&season={season}` - User's teams
**Important Note:**
The player PATCH endpoint uses **query parameters** instead of JSON body for data updates. The `player_service.update_player()` method automatically handles this by setting `use_query_params=True` when calling the API client.
## Testing
### Test Coverage
**Test File:** `tests/test_commands_profile_images.py`
**Test Categories:**
1. **URL Format Validation** (10 tests)
- Valid formats (JPG, PNG, WebP, with query params)
- Invalid protocols (no protocol, FTP)
- Invalid extensions (PDF, no extension)
- URL length limits
2. **URL Accessibility** (5 tests)
- Successful access
- 404 errors
- Wrong content-type
- Timeouts
- Connection errors
3. **Permission Checking** (7 tests)
- Admin access to all players
- User access to owned teams
- User access to MiL/IL players
- Denial for other organizations
- Denial for users without teams
- Players without team assignment
4. **Integration Tests** (3 tests)
- Command structure validation
- Field mapping logic
### Running Tests
```bash
# Run all image management tests
python -m pytest tests/test_commands_profile_images.py -v
# Run specific test class
python -m pytest tests/test_commands_profile_images.py::TestURLValidation -v
# Run with coverage
python -m pytest tests/test_commands_profile_images.py --cov=commands.profile
```
## Future Enhancements
### Planned Features (Post-Launch)
- **Image size validation**: Check image dimensions
- **Image upload support**: Upload images directly instead of URLs
- **Bulk image updates**: Update multiple players at once
- **Image preview history**: See previous images
- **Image moderation**: Admin approval queue for user submissions
- **Default images**: Set default fancy cards per team
- **Image gallery**: View all player images for a team
### Potential Improvements
- **Automatic image optimization**: Resize/compress large images
- **CDN integration**: Auto-upload to Discord CDN for permanence
- **Image templates**: Pre-designed templates users can fill in
- **Batch operations**: Admin tool to set multiple images
- **Image analytics**: Track which images are most viewed
## Troubleshooting
### Common Issues
**Problem:** "URL not accessible" but URL works in browser
- **Cause:** Content-Delivery-Network (CDN) may require browser headers
- **Solution:** Use Discord CDN or Imgur instead
**Problem:** Permission denied even though I own the team
- **Cause:** Season mismatch or ownership data not synced
- **Solution:** Contact admin to verify team ownership data
**Problem:** Image appears broken in Discord
- **Cause:** Discord can't load the image (blocked, wrong format, too large)
- **Solution:** Try different host or smaller file size
**Problem:** Autocomplete doesn't show player
- **Cause:** Player doesn't exist in current season
- **Solution:** Verify player name and season
### Support
For issues or questions:
1. Check this README for solutions
2. Review error messages carefully (they include troubleshooting steps)
3. Contact server administrators
4. Check bot logs for detailed error information
---
**Implementation Details:**
- **Commands:** `commands/profile/images.py`
- **Tests:** `tests/test_commands_profile_images.py`
- **Models:** `models/player.py` (vanity_card, headshot fields)
- **Services:** `services/player_service.py`, `services/team_service.py`
**Related Documentation:**
- **Bot Architecture:** `/discord-app-v2/CLAUDE.md`
- **Command Patterns:** `/discord-app-v2/commands/README.md`
- **Testing Guide:** `/discord-app-v2/tests/README.md`

View File

@ -1,134 +0,0 @@
# Team Commands
This directory contains Discord slash commands for team information and roster management.
## Files
### `info.py`
- **Commands**:
- `/team` - Display comprehensive team information
- `/teams` - List all teams in a season
- **Parameters**:
- `abbrev` (required for `/team`): Team abbreviation (e.g., NYY, BOS, LAD)
- `season` (optional): Season to display (defaults to current season)
- **Service Dependencies**:
- `team_service.get_team_by_abbrev()`
- `team_service.get_teams_by_season()`
- `team_service.get_team_standings_position()`
### `roster.py`
- **Command**: `/roster`
- **Description**: Display detailed team roster with position breakdowns
- **Parameters**:
- `abbrev` (required): Team abbreviation
- `roster_type` (optional): "current" or "next" week roster (defaults to current)
- **Service Dependencies**:
- `team_service.get_team_by_abbrev()`
- `team_service.get_team_roster()`
## Key Features
### Team Information Display (`info.py`)
- **Comprehensive Team Data**:
- Team names (long name, short name, abbreviation)
- Stadium information
- Division assignment
- Team colors and logos
- **Standings Integration**:
- Win-loss record and winning percentage
- Games behind division leader
- Current standings position
- **Visual Elements**:
- Team color theming for embeds
- Team logo thumbnails
- Consistent branding across displays
### Team Listing (`/teams`)
- **Season Overview**: All teams organized by division
- **Division Grouping**: Automatically groups teams by division ID
- **Fallback Display**: Shows simple list if division data unavailable
- **Team Count**: Total team summary
### Roster Management (`roster.py`)
- **Multi-Week Support**: Current and next week roster views
- **Position Breakdown**:
- Batting positions (C, 1B, 2B, 3B, SS, LF, CF, RF, DH)
- Pitching positions (SP, RP, CP)
- Position player counts and totals
- **Advanced Features**:
- Total sWAR calculation and display
- Minor League (shortil) player tracking
- Injured List (longil) player management
- Detailed player lists with positions and WAR values
### Roster Display Structure
- **Summary Embed**: Position counts and totals
- **Detailed Player Lists**: Separate embeds for each roster type
- **Player Organization**: Batters and pitchers grouped separately
- **Chunked Display**: Long player lists split across multiple fields
## Architecture Notes
### Embed Design
- **Team Color Integration**: Uses team hex colors for embed theming
- **Fallback Colors**: Default colors when team colors unavailable
- **Thumbnail Priority**: Team logos displayed consistently
- **Multi-Embed Support**: Complex data split across multiple embeds
### Error Handling
- **Team Not Found**: Clear messaging with season context
- **Missing Roster Data**: Graceful handling of unavailable data
- **API Failures**: Fallback to partial information display
### Performance Considerations
- **Concurrent Data Fetching**: Standings and roster data retrieved in parallel
- **Efficient Roster Processing**: Position grouping and calculations optimized
- **Chunked Player Lists**: Prevents Discord embed size limits
## Troubleshooting
### Common Issues
1. **Team not found**:
- Verify team abbreviation spelling
- Check if team exists in the specified season
- Ensure abbreviation matches database format
2. **Roster data missing**:
- Verify `team_service.get_team_roster()` API endpoint
- Check if roster data exists for the requested week type
- Ensure team ID is correctly passed to roster service
3. **Position counts incorrect**:
- Verify roster data structure and position field names
- Check sWAR calculation logic
- Ensure player position arrays are properly parsed
4. **Standings not displaying**:
- Check `get_team_standings_position()` API response
- Verify standings data structure matches expected format
- Ensure error handling for malformed standings data
### Dependencies
- `services.team_service`
- `models.team.Team`
- `utils.decorators.logged_command`
- `views.embeds.EmbedTemplate`
- `constants.SBA_CURRENT_SEASON`
- `exceptions.BotException`
### Testing
Run tests with: `python -m pytest tests/test_commands_teams.py -v`
## Database Requirements
- Team records with abbreviations, names, colors, logos
- Division assignment and organization
- Roster data with position assignments and player details
- Standings calculations and team statistics
- Stadium and venue information
## Future Enhancements
- Team statistics and performance metrics
- Historical team data and comparisons
- Roster change tracking and transaction history
- Advanced roster analytics and projections

View File

@ -1,250 +0,0 @@
# Transaction Commands
This directory contains Discord slash commands for transaction management and roster legality checking.
## Files
### `management.py`
- **Commands**:
- `/mymoves` - View user's pending and scheduled transactions
- `/legal` - Check roster legality for current and next week
- **Service Dependencies**:
- `transaction_service` (multiple methods for transaction retrieval)
- `roster_service` (roster validation and retrieval)
- `team_service.get_teams_by_owner()` and `get_team_by_abbrev()`
### `dropadd.py`
- **Commands**:
- `/dropadd` - Interactive transaction builder for single-team roster moves
- `/cleartransaction` - Clear current transaction builder
- **Service Dependencies**:
- `transaction_builder` (transaction creation and validation)
- `player_service.search_players()` (player autocomplete)
- `team_service.get_teams_by_owner()`
### `trade.py` *(NEW)*
- **Commands**:
- `/trade initiate` - Start a new multi-team trade
- `/trade add-team` - Add additional teams to trade (3+ team trades)
- `/trade add-player` - Add player exchanges between teams
- `/trade supplementary` - Add internal organizational moves for roster legality
- `/trade view` - View current trade status
- `/trade clear` - Clear current trade
- **Service Dependencies**:
- `trade_builder` (multi-team trade management)
- `player_service.search_players()` (player autocomplete)
- `team_service.get_teams_by_owner()`, `get_team_by_abbrev()`, and `get_team()`
- **Channel Management**:
- Automatically creates private discussion channels for trades
- Uses `TradeChannelManager` and `TradeChannelTracker` for channel lifecycle
- Requires bot to have `Manage Channels` permission at server level
## Key Features
### Transaction Status Display (`/mymoves`)
- **User Team Detection**: Automatically finds user's team by Discord ID
- **Transaction Categories**:
- **Pending**: Transactions awaiting processing
- **Frozen**: Scheduled transactions ready for processing
- **Processed**: Recently completed transactions
- **Cancelled**: Optional display of cancelled transactions
- **Status Visualization**:
- Status emojis for each transaction type
- Week numbering and move descriptions
- Transaction count summaries
- **Smart Limiting**: Shows recent transactions (last 5 pending, 3 frozen/processed, 2 cancelled)
### Roster Legality Checking (`/legal`)
- **Dual Roster Validation**: Checks both current and next week rosters
- **Flexible Team Selection**:
- Auto-detects user's team
- Allows manual team specification via abbreviation
- **Comprehensive Validation**:
- Player count verification (active roster + IL)
- sWAR calculations and limits
- League rule compliance checking
- Error and warning categorization
- **Parallel Processing**: Roster retrieval and validation run concurrently
### Multi-Team Trade System (`/trade`) *(NEW)*
- **Trade Initiation**: Start trades between multiple teams using proper Discord command groups
- **Team Management**: Add/remove teams to create complex multi-team trades (2+ teams supported)
- **Player Exchanges**: Add cross-team player movements with source and destination validation
- **Supplementary Moves**: Add internal organizational moves for roster legality compliance
- **Interactive UI**: Rich Discord embeds with validation feedback and trade status
- **Real-time Validation**: Live roster checking across all participating teams
- **Authority Model**: Major League team owners control all players in their organization (ML/MiL/IL)
#### Trade Command Workflow:
1. **`/trade initiate other_team:LAA`** - Start trade between your team and LAA
- Creates a private discussion channel for the trade
- Only you see the ephemeral response
2. **`/trade add-team other_team:BOS`** - Add BOS for 3-team trade
- Updates are posted to the trade channel if executed elsewhere
- Other team members can see the progress
3. **`/trade add-player player_name:"Mike Trout" destination_team:BOS`** - Exchange players
- Trade embed updates posted to dedicated channel automatically
- Keeps all participants informed of changes
4. **`/trade supplementary player_name:"Player X" destination:ml`** - Internal roster moves
- Channel receives real-time updates
5. **`/trade view`** - Review complete trade with validation
- Posts current state to trade channel if viewed elsewhere
6. **Submit via interactive UI** - Trade submission through Discord buttons
**Channel Behavior**:
- Commands executed **in** the trade channel: Only ephemeral response to user
- Commands executed **outside** trade channel: Ephemeral response to user + public post to trade channel
- This ensures all participating teams stay informed of trade progress
#### Autocomplete System:
- **Team Initiation**: Only Major League teams (ML team owners initiate trades)
- **Player Destinations**: All roster types (ML/MiL/IL) available for player placement
- **Player Search**: Prioritizes user's team players, supports fuzzy name matching
- **Smart Filtering**: Context-aware suggestions based on user permissions
#### Trade Channel Management (`trade_channels.py`, `trade_channel_tracker.py`):
- **Automatic Channel Creation**: Private discussion channels created when trades are initiated
- **Channel Naming**: Format `trade-{team1}-{team2}-{short_id}` (e.g., `trade-wv-por-681f`)
- **Permission Management**:
- Channel hidden from @everyone
- Only participating team roles can view/message
- Bot has view and send message permissions
- Created in "Transactions" category (if it exists)
- **Channel Tracking**: JSON-based persistence for cleanup and management
- **Multi-Team Support**: Channels automatically update when teams are added to trades
- **Automatic Cleanup**: Channels deleted when trades are cleared
- **Smart Updates**: When trade commands are executed outside the dedicated trade channel, the trade embed is automatically posted to the trade channel (non-ephemeral) for visibility
**Bot Permission Requirements**:
- Server-level `Manage Channels` - Required to create/delete trade channels
- Server-level `Manage Permissions` - Optional, for enhanced permission management
- **Note**: Bot should NOT have these permissions in channel-specific overwrites (causes Discord API error 50013)
**Recent Fix (January 2025)**:
- Removed `manage_channels` and `manage_permissions` from bot's channel-specific overwrites
- Discord prohibits bots from granting themselves elevated permissions in channel overwrites
- Server-level permissions are sufficient for all channel management operations
### Advanced Transaction Features
- **Concurrent Data Fetching**: Multiple transaction types retrieved in parallel
- **Owner-Based Filtering**: Transactions filtered by team ownership
- **Status Tracking**: Real-time transaction status with emoji indicators
- **Team Integration**: Team logos and colors in transaction displays
## Architecture Notes
### Permission Model
- **Team Ownership**: Commands use Discord user ID to determine team ownership
- **Cross-Team Viewing**: `/legal` allows checking other teams' roster status
- **Access Control**: Users can only view their own transactions via `/mymoves`
### Data Processing
- **Async Operations**: Heavy use of `asyncio.gather()` for performance
- **Error Resilience**: Graceful handling of missing roster data
- **Validation Pipeline**: Multi-step roster validation with detailed feedback
### Embed Structure
- **Status-Based Coloring**: Success (green) vs Error (red) color coding
- **Information Hierarchy**: Important information prioritized in embed layout
- **Team Branding**: Consistent use of team thumbnails and colors
## Troubleshooting
### Common Issues
1. **User team not found**:
- Verify user has team ownership record in database
- Check Discord user ID mapping to team ownership
- Ensure current season team assignments are correct
2. **Transaction data missing**:
- Verify `transaction_service` API endpoints are functional
- Check transaction status filtering logic
- Ensure transaction records exist for the team/season
3. **Roster validation failing**:
- Check `roster_service.get_current_roster()` and `get_next_roster()` responses
- Verify roster validation rules and logic
- Ensure player data integrity in roster records
4. **Legal command errors**:
- Verify team abbreviation exists in database
- Check roster data availability for both current and next weeks
- Ensure validation service handles edge cases properly
5. **Trade channel creation fails** *(Fixed January 2025)*:
- Error: `Discord error: Missing Permissions. Code: 50013`
- **Root Cause**: Bot was trying to grant itself `manage_channels` and `manage_permissions` in channel-specific permission overwrites
- **Fix**: Removed elevated permissions from channel overwrites (line 74-77 in `trade_channels.py`)
- **Verification**: Bot only needs server-level `Manage Channels` permission
- Channels now create successfully with basic bot permissions (view, send messages, read history)
6. **AttributeError when adding players to trades** *(Fixed January 2025)*:
- Error: `'TeamService' object has no attribute 'get_team_by_id'`
- **Root Cause**: Code was calling non-existent method `team_service.get_team_by_id()`
- **Fix**: Changed to correct method name `team_service.get_team()` (line 201 in `trade_builder.py`)
- **Location**: `services/trade_builder.py` and test mocks in `tests/test_services_trade_builder.py`
- All 18 trade builder tests pass after fix
### Service Dependencies
- `services.transaction_service`:
- `get_pending_transactions()`
- `get_frozen_transactions()`
- `get_processed_transactions()`
- `get_team_transactions()`
- `services.roster_service`:
- `get_current_roster()`
- `get_next_roster()`
- `validate_roster()`
- `services.team_service`:
- `get_teams_by_owner()`
- `get_team_by_abbrev()`
- `get_teams_by_season()` *(trade autocomplete)*
- `services.trade_builder` *(NEW)*:
- `TradeBuilder` class for multi-team transaction management
- `get_trade_builder()` and `clear_trade_builder()` cache functions
- `TradeValidationResult` for comprehensive trade validation
- `services.player_service`:
- `search_players()` for autocomplete functionality
### Core Dependencies
- `utils.decorators.logged_command`
- `views.embeds.EmbedTemplate`
- `views.trade_embed` *(NEW)*: Trade-specific UI components
- `utils.autocomplete` *(ENHANCED)*: Player and team autocomplete functions
- `utils.team_utils` *(NEW)*: Shared team validation utilities
- `constants.SBA_CURRENT_SEASON`
### Testing
Run tests with:
- `python -m pytest tests/test_commands_transactions.py -v` (management commands)
- `python -m pytest tests/test_models_trade.py -v` *(NEW)* (trade models)
- `python -m pytest tests/test_services_trade_builder.py -v` *(NEW)* (trade builder service)
## Database Requirements
- Team ownership mapping (Discord user ID to team)
- Transaction records with status tracking
- Roster data for current and next weeks
- Player assignments and position information
- League rules and validation criteria
## Recent Enhancements *(NEW)*
- ✅ **Multi-Team Trade System**: Complete `/trade` command group for 2+ team trades
- ✅ **Enhanced Autocomplete**: Major League team filtering and smart player suggestions
- ✅ **Shared Utilities**: Reusable team validation and autocomplete functions
- ✅ **Comprehensive Testing**: Factory-based tests for trade models and services
- ✅ **Interactive Trade UI**: Rich Discord embeds with real-time validation
## Future Enhancements
- **Trade Submission Integration**: Connect trade system to transaction processing pipeline
- **Advanced transaction analytics and history
- **Trade Approval Workflow**: Multi-party trade approval system
- **Roster optimization suggestions
- **Automated roster validation alerts
- **Trade History Tracking**: Complete audit trail for multi-team trades
## Security Considerations
- User authentication via Discord IDs
- Team ownership verification for sensitive operations
- Transaction privacy (users can only see their own transactions)
- Input validation for team abbreviations and parameters

View File

@ -12,6 +12,7 @@ from discord.ext import commands
from .management import TransactionCommands
from .dropadd import DropAddCommands
from .trade import TradeCommands
from .ilmove import ILMoveCommands
logger = logging.getLogger(f'{__name__}.setup_transactions')
@ -27,6 +28,7 @@ async def setup_transactions(bot: commands.Bot) -> Tuple[int, int, List[str]]:
("TransactionCommands", TransactionCommands),
("DropAddCommands", DropAddCommands),
("TradeCommands", TradeCommands),
("ILMoveCommands", ILMoveCommands),
]
successful = 0

View File

@ -74,8 +74,8 @@ class DropAddCommands(commands.Cog):
if success:
# Move added successfully - show updated transaction builder
embed = await create_transaction_embed(builder)
view = TransactionEmbedView(builder, interaction.user.id)
embed = await create_transaction_embed(builder, command_name="/dropadd")
view = TransactionEmbedView(builder, interaction.user.id, submission_handler="scheduled", command_name="/dropadd")
success_msg = f"✅ **Added {player}{destination.upper()}**"
if builder.move_count > 1:
@ -91,8 +91,8 @@ class DropAddCommands(commands.Cog):
else:
# Failed to add move - still show current transaction state
embed = await create_transaction_embed(builder)
view = TransactionEmbedView(builder, interaction.user.id)
embed = await create_transaction_embed(builder, command_name="/dropadd")
view = TransactionEmbedView(builder, interaction.user.id, submission_handler="scheduled", command_name="/dropadd")
await interaction.followup.send(
content=f"❌ **{error_message}**\n"
@ -104,8 +104,8 @@ class DropAddCommands(commands.Cog):
self.logger.warning(f"Failed to add move: {player}{destination}: {error_message}")
else:
# No parameters or incomplete parameters - show current transaction state
embed = await create_transaction_embed(builder)
view = TransactionEmbedView(builder, interaction.user.id)
embed = await create_transaction_embed(builder, command_name="/dropadd")
view = TransactionEmbedView(builder, interaction.user.id, submission_handler="scheduled", command_name="/dropadd")
await interaction.followup.send(embed=embed, view=view, ephemeral=True)
async def _add_quick_move(

View File

@ -0,0 +1,242 @@
"""
/ilmove Command - Real-time IL/Roster Moves
Interactive transaction builder for immediate roster changes (current week).
Unlike /dropadd which schedules moves for next week, /ilmove:
- Creates transactions for THIS week
- Immediately posts transactions to database
- Immediately updates player team assignments
"""
from typing import Optional
import discord
from discord.ext import commands
from discord import app_commands
from config import get_config
from utils.logging import get_contextual_logger
from utils.decorators import logged_command
from utils.autocomplete import player_autocomplete
from utils.team_utils import validate_user_has_team
from services.transaction_builder import (
TransactionBuilder,
RosterType,
TransactionMove,
get_transaction_builder,
clear_transaction_builder
)
from services.player_service import player_service
from services.team_service import team_service
from views.transaction_embed import TransactionEmbedView, create_transaction_embed
class ILMoveCommands(commands.Cog):
"""Real-time roster move commands (IL, activations, etc)."""
def __init__(self, bot: commands.Bot):
self.bot = bot
self.logger = get_contextual_logger(f'{__name__}.ILMoveCommands')
@app_commands.command(
name="ilmove",
description="Build a real-time roster move (executed immediately for this week)"
)
@app_commands.describe(
player="Player name; begin typing for autocomplete",
destination="Where to move the player: Major League, Minor League, or Injured List"
)
@app_commands.autocomplete(player=player_autocomplete)
@app_commands.choices(destination=[
app_commands.Choice(name="Major League", value="ml"),
app_commands.Choice(name="Minor League", value="mil"),
app_commands.Choice(name="Injured List", value="il")
])
@logged_command("/ilmove")
async def ilmove(
self,
interaction: discord.Interaction,
player: Optional[str] = None,
destination: Optional[str] = None
):
"""Interactive transaction builder for immediate roster moves."""
await interaction.response.defer(ephemeral=True)
# Get user's major league team
team = await validate_user_has_team(interaction)
if not team:
return
# Get or create transaction builder
builder = get_transaction_builder(interaction.user.id, team)
# Handle different scenarios based on builder state and parameters
if player and destination:
# User provided both parameters - try to add the move
success, error_message = await self._add_quick_move(builder, player, destination)
if success:
# Move added successfully - show updated transaction builder
embed = await create_transaction_embed(builder, command_name="/ilmove")
view = TransactionEmbedView(builder, interaction.user.id, submission_handler="immediate", command_name="/ilmove")
success_msg = f"✅ **Added {player}{destination.upper()}**"
if builder.move_count > 1:
success_msg += f"\n📊 Transaction now has {builder.move_count} moves"
await interaction.followup.send(
content=success_msg,
embed=embed,
view=view,
ephemeral=True
)
self.logger.info(f"Move added for {team.abbrev}: {player}{destination}")
else:
# Failed to add move - still show current transaction state
embed = await create_transaction_embed(builder, command_name="/ilmove")
view = TransactionEmbedView(builder, interaction.user.id, submission_handler="immediate", command_name="/ilmove")
await interaction.followup.send(
content=f"❌ **{error_message}**\n"
f"💡 Try using autocomplete for player names",
embed=embed,
view=view,
ephemeral=True
)
self.logger.warning(f"Failed to add move: {player}{destination}: {error_message}")
else:
# No parameters or incomplete parameters - show current transaction state
embed = await create_transaction_embed(builder, command_name="/ilmove")
view = TransactionEmbedView(builder, interaction.user.id, submission_handler="immediate", command_name="/ilmove")
await interaction.followup.send(embed=embed, view=view, ephemeral=True)
async def _add_quick_move(
self,
builder: TransactionBuilder,
player_name: str,
destination_str: str
) -> tuple[bool, str]:
"""
Add a move quickly from command parameters by auto-determining the action.
Args:
builder: TransactionBuilder instance
player_name: Name of player to move
destination_str: Destination string (ml, mil, il)
Returns:
Tuple of (success: bool, error_message: str)
"""
try:
# Find player using the new search endpoint
players = await player_service.search_players(player_name, limit=10, season=get_config().sba_current_season)
if not players:
self.logger.error(f"Player not found: {player_name}")
return False, f"Player '{player_name}' not found"
# Use exact match if available, otherwise first result
player = None
for p in players:
if p.name.lower() == player_name.lower():
player = p
break
if not player:
player = players[0] # Use first match
# Check if player belongs to another team (not user's team and not Free Agency)
if player.team and hasattr(player.team, 'abbrev'):
# Player belongs to another team if:
# 1. They have a team assigned AND
# 2. That team is not Free Agency (abbrev != 'FA') AND
# 3. That team is not in the same organization as the user's team
if (player.team.abbrev != 'FA' and
not builder.team.is_same_organization(player.team)):
self.logger.warning(f"Player {player.name} belongs to {player.team.abbrev}, cannot add to {builder.team.abbrev} transaction")
return False, f"{player.name} belongs to {player.team.abbrev} and cannot be added to your transaction"
# Parse destination
destination_map = {
"ml": RosterType.MAJOR_LEAGUE,
"mil": RosterType.MINOR_LEAGUE,
"il": RosterType.INJURED_LIST,
}
to_roster = destination_map.get(destination_str.lower())
if not to_roster:
self.logger.error(f"Invalid destination: {destination_str}")
return False, f"Invalid destination: {destination_str}"
# Determine player's current roster status by checking actual roster data
# Note: Minor League players have different team_id than Major League team
self.logger.debug(f"Player {player.name} team_id: {player.team_id}, Builder team_id: {builder.team.id}")
await builder.load_roster_data()
if builder._current_roster:
# Check which roster section the player is on (regardless of team_id)
player_on_active = any(p.id == player.id for p in builder._current_roster.active_players)
player_on_minor = any(p.id == player.id for p in builder._current_roster.minor_league_players)
player_on_il = any(p.id == player.id for p in builder._current_roster.il_players)
if player_on_active:
from_roster = RosterType.MAJOR_LEAGUE
self.logger.debug(f"Player {player.name} found on active roster (Major League)")
elif player_on_minor:
from_roster = RosterType.MINOR_LEAGUE
self.logger.debug(f"Player {player.name} found on minor league roster")
elif player_on_il:
from_roster = RosterType.INJURED_LIST
self.logger.debug(f"Player {player.name} found on injured list")
else:
# Player not found on user's roster - cannot move with /ilmove
from_roster = None
self.logger.warning(f"Player {player.name} not found on {builder.team.abbrev} roster")
return False, f"{player.name} is not on your roster (use /dropadd for FA signings)"
else:
# Couldn't load roster data
self.logger.error(f"Could not load roster data for {builder.team.abbrev}")
return False, "Could not load roster data. Please try again."
if from_roster is None:
return False, f"{player.name} is not on your roster"
# Create move
move = TransactionMove(
player=player,
from_roster=from_roster,
to_roster=to_roster,
from_team=builder.team,
to_team=builder.team
)
success, error_message = builder.add_move(move)
if not success:
self.logger.warning(f"Failed to add quick move: {error_message}")
return False, error_message
return True, ""
except Exception as e:
self.logger.error(f"Error adding quick move: {e}")
return False, f"Error adding move: {str(e)}"
@app_commands.command(
name="clearilmove",
description="Clear your current IL move transaction builder"
)
@logged_command("/clearilmove")
async def clear_ilmove(self, interaction: discord.Interaction):
"""Clear the user's current IL move transaction builder."""
clear_transaction_builder(interaction.user.id)
await interaction.response.send_message(
"✅ Your IL move transaction builder has been cleared.",
ephemeral=True
)
async def setup(bot):
"""Setup function for the cog."""
await bot.add_cog(ILMoveCommands(bot))

View File

@ -1,235 +0,0 @@
# Utility Commands
This directory contains general utility commands that enhance the user experience for the SBA Discord bot.
## Commands
### `/weather [team_abbrev]`
**Description**: Roll ballpark weather for gameplay.
**Usage**:
- `/weather` - Roll weather for your team or current channel's team
- `/weather NYY` - Roll weather for a specific team
**Features**:
- **Smart Team Resolution** (3-tier priority):
1. Explicit team abbreviation parameter
2. Channel name parsing (e.g., `NYY-Yankee Stadium``NYY`)
3. User's owned team (fallback)
- **Season Display**:
- Weeks 1-5: 🌼 Spring
- Weeks 6-14: 🏖️ Summer
- Weeks 15+: 🍂 Fall
- **Time of Day Logic**:
- Based on games played this week
- Division weeks: [1, 3, 6, 14, 16, 18]
- 0/2 games OR (1 game in division week): 🌙 Night
- 1/3 games: 🌞 Day
- 4+ games: 🕸️ Spidey Time (special case)
- **Weather Roll**: Random d20 (1-20) displayed in markdown format
**Embed Layout**:
```
┌─────────────────────────────────┐
│ 🌤️ Weather Check │
│ [Team Colors] │
├─────────────────────────────────┤
│ Season: 🌼 Spring │
│ Time of Day: 🌙 Night │
│ Week: 5 | Games Played: 2/4 │
├─────────────────────────────────┤
│ Weather Roll │
│ ```md │
│ # 14 │
│ Details: [1d20 (14)] │
│ ``` │
├─────────────────────────────────┤
│ [Stadium Image] │
└─────────────────────────────────┘
```
**Implementation Details**:
- **File**: `commands/utilities/weather.py`
- **Service Dependencies**:
- `LeagueService` - Current league state
- `ScheduleService` - Week schedule and games
- `TeamService` - Team resolution
- **Logging**: Uses `@logged_command` decorator for automatic logging
- **Error Handling**: Graceful fallback with user-friendly error messages
**Examples**:
1. In a team channel (`#NYY-Yankee-Stadium`):
```
/weather
→ Automatically uses NYY from channel name
```
2. Explicit team:
```
/weather BOS
→ Shows weather for Boston team
```
3. As team owner:
```
/weather
→ Defaults to your owned team if not in a team channel
```
## Architecture
### Command Pattern
All utility commands follow the standard bot architecture:
```python
@discord.app_commands.command(name="command")
@discord.app_commands.describe(param="Description")
@logged_command("/command")
async def command_handler(self, interaction, param: str):
await interaction.response.defer()
# Command logic using services
await interaction.followup.send(embed=embed)
```
### Service Layer
Utility commands leverage the service layer for all data access:
- **No direct database calls** - all data through services
- **Async operations** - proper async/await patterns
- **Error handling** - graceful degradation with user feedback
### Embed Templates
Use `EmbedTemplate` from `views.embeds` for consistent styling:
- Team colors via `team.color`
- Standard error/success/info templates
- Image support (thumbnails and full images)
## Testing
All utility commands have comprehensive test coverage:
**Weather Command** (`tests/test_commands_weather.py` - 20 tests):
- Team resolution (3-tier priority)
- Season calculation
- Time of day logic (including division weeks)
- Weather roll randomization
- Embed formatting and layout
- Error handling scenarios
**Charts Command** (`tests/test_commands_charts.py` - 26 tests):
- Chart service operations (loading, adding, updating, removing)
- Chart display (single and multi-image)
- Autocomplete functionality
- Admin command operations
- Error handling (invalid charts, categories)
- JSON persistence
### `/charts <chart-name>`
**Description**: Display gameplay charts and infographics from the league library.
**Usage**:
- `/charts rest` - Display pitcher rest chart
- `/charts defense` - Display defense chart
- `/charts hit-and-run` - Display hit and run strategy chart
**Features**:
- **Autocomplete**: Smart chart name suggestions with category display
- **Multi-image Support**: Automatically sends multiple images for complex charts
- **Categorized Library**: Charts organized by gameplay, defense, reference, and stats
- **Proper Embeds**: Charts displayed in formatted Discord embeds with descriptions
**Available Charts** (12 total):
- **Gameplay**: rest, sac-bunt, squeeze-bunt, hit-and-run, g1, g2, g3, groundball, fly-b
- **Defense**: rob-hr, defense, block-plate
**Admin Commands**:
Administrators can manage the chart library using these commands:
- `/chart-add <key> <name> <category> <url> [description]` - Add a new chart
- `/chart-remove <key>` - Remove a chart from the library
- `/chart-list [category]` - List all charts (optionally filtered by category)
- `/chart-update <key> [name] [category] [url] [description]` - Update chart properties
**Implementation Details**:
- **Files**:
- `commands/utilities/charts.py` - Command handlers
- `services/chart_service.py` - Chart management service
- `data/charts.json` - Chart definitions storage
- **Service**: `ChartService` - Manages chart loading, saving, and retrieval
- **Categories**: gameplay, defense, reference, stats
- **Logging**: Uses `@logged_command` decorator for automatic logging
**Examples**:
1. Display a single-image chart:
```
/charts defense
→ Shows defense chart embed with image
```
2. Display multi-image chart:
```
/charts hit-and-run
→ Shows first image in response, additional images in followups
```
3. Admin: Add new chart:
```
/chart-add steal-chart "Steal Chart" gameplay https://example.com/steal.png
→ Adds new chart to the library
```
4. Admin: List charts by category:
```
/chart-list gameplay
→ Shows all gameplay charts
```
**Data Structure** (`data/charts.json`):
```json
{
"charts": {
"chart-key": {
"name": "Display Name",
"category": "gameplay",
"description": "Chart description",
"urls": ["https://example.com/image.png"]
}
},
"categories": {
"gameplay": "Gameplay Mechanics",
"defense": "Defensive Play"
}
}
```
## Future Commands
Planned utility commands (see PRE_LAUNCH_ROADMAP.md):
- `/links <resource-name>` - Quick access to league resources
## Development Guidelines
When adding new utility commands:
1. **Follow existing patterns** - Use weather.py as a reference
2. **Use @logged_command** - Automatic logging and error handling
3. **Service layer only** - No direct database access
4. **Comprehensive tests** - Cover all edge cases
5. **User-friendly errors** - Clear, actionable error messages
6. **Document in README** - Update this file with new commands
---
**Last Updated**: January 2025
**Maintainer**: Major Domo Bot Development Team

View File

@ -1,251 +0,0 @@
# Voice Channel Commands
This directory contains Discord slash commands for creating and managing voice channels for gameplay.
## Files
### `channels.py`
- **Commands**:
- `/voice-channel public` - Create a public voice channel for gameplay
- `/voice-channel private` - Create a private team vs team voice channel
- **Description**: Main command implementation with VoiceChannelCommands cog
- **Service Dependencies**:
- `team_service.get_teams_by_owner()` - Verify user has a team
- `league_service.get_current_state()` - Get current season/week info
- `schedule_service.get_team_schedule()` - Find opponent for private channels
- **Deprecated Commands**:
- `!vc`, `!voice`, `!gameplay` → Shows migration message to `/voice-channel public`
- `!private` → Shows migration message to `/voice-channel private`
### `cleanup_service.py`
- **Class**: `VoiceChannelCleanupService`
- **Description**: Manages automatic cleanup of bot-created voice channels
- **Features**:
- Restart-resilient channel tracking using JSON persistence
- Configurable cleanup intervals and empty thresholds
- Background monitoring loop with error recovery
- Startup verification to clean stale tracking entries
### `tracker.py`
- **Class**: `VoiceChannelTracker`
- **Description**: JSON-based persistent tracking of voice channels
- **Features**:
- Channel creation and status tracking
- Empty duration monitoring with datetime handling
- Cleanup candidate identification
- Automatic stale entry removal
### `__init__.py`
- **Function**: `setup_voice(bot)`
- **Description**: Package initialization with resilient cog loading
- **Integration**: Follows established bot architecture patterns
## Key Features
### Public Voice Channels (`/voice-channel public`)
- **Permissions**: Everyone can connect and speak
- **Naming**: Random codename generation (e.g., "Gameplay Phoenix", "Gameplay Thunder")
- **Requirements**: User must own a Major League team (3-character abbreviations like NYY, BOS)
- **Auto-cleanup**: Configurable threshold (default: empty for configured minutes)
### Private Voice Channels (`/voice-channel private`)
- **Permissions**:
- Team members can connect and speak (using `team.lname` Discord roles)
- Everyone else can connect but only listen
- **Naming**: Automatic "{Away} vs {Home}" format based on current week's schedule
- **Opponent Detection**: Uses current league week to find scheduled opponent
- **Requirements**:
- User must own a Major League team (3-character abbreviations like NYY, BOS)
- Team must have upcoming games in current week
- **Role Integration**: Finds Discord roles matching team full names (`team.lname`)
### Automatic Cleanup System
- **Monitoring Interval**: Configurable (default: 60 seconds)
- **Empty Threshold**: Configurable (default: 5 minutes empty before deletion)
- **Restart Resilience**: JSON file persistence survives bot restarts
- **Startup Verification**: Validates tracked channels still exist on bot startup
- **Graceful Error Handling**: Continues operation even if individual operations fail
## Architecture
### Command Flow
1. **Major League Team Verification**: Check user owns a Major League team using `team_service`
2. **Channel Creation**: Create voice channel with appropriate permissions
3. **Tracking Registration**: Add channel to cleanup service tracking
4. **User Feedback**: Send success embed with channel details
### Team Validation Logic
The voice channel system validates that users own **Major League teams** specifically:
```python
async def _get_user_major_league_team(self, user_id: int, season: Optional[int] = None):
"""Get the user's Major League team for schedule/game purposes."""
teams = await team_service.get_teams_by_owner(user_id, season)
# Filter to only Major League teams (3-character abbreviations)
major_league_teams = [team for team in teams if team.roster_type() == RosterType.MAJOR_LEAGUE]
return major_league_teams[0] if major_league_teams else None
```
**Team Types:**
- **Major League**: 3-character abbreviations (e.g., NYY, BOS, LAD) - **Required for voice channels**
- **Minor League**: 4+ characters ending in "MIL" (e.g., NYYMIL, BOSMIL) - **Not eligible**
- **Injured List**: Ending in "IL" (e.g., NYYIL, BOSIL) - **Not eligible**
**Rationale:** Only Major League teams participate in weekly scheduled games, so voice channel creation is restricted to active Major League team owners.
### Permission System
```python
# Public channels - everyone can speak
overwrites = {
guild.default_role: discord.PermissionOverwrite(speak=True, connect=True)
}
# Private channels - team roles only can speak
overwrites = {
guild.default_role: discord.PermissionOverwrite(speak=False, connect=True),
user_team_role: discord.PermissionOverwrite(speak=True, connect=True),
opponent_team_role: discord.PermissionOverwrite(speak=True, connect=True)
}
```
### Cleanup Service Integration
```python
# Bot initialization (bot.py)
from commands.voice.cleanup_service import VoiceChannelCleanupService
self.voice_cleanup_service = VoiceChannelCleanupService()
asyncio.create_task(self.voice_cleanup_service.start_monitoring(self))
# Channel tracking
if hasattr(self.bot, 'voice_cleanup_service'):
cleanup_service = self.bot.voice_cleanup_service
cleanup_service.tracker.add_channel(channel, channel_type, interaction.user.id)
```
### JSON Data Structure
```json
{
"voice_channels": {
"123456789": {
"channel_id": "123456789",
"guild_id": "987654321",
"name": "Gameplay Phoenix",
"type": "public",
"created_at": "2025-01-15T10:30:00",
"last_checked": "2025-01-15T10:35:00",
"empty_since": "2025-01-15T10:32:00",
"creator_id": "111222333"
}
}
}
```
## Configuration
### Cleanup Service Settings
- **`cleanup_interval`**: How often to check channels (default: 60 seconds)
- **`empty_threshold`**: Minutes empty before deletion (default: 5 minutes)
- **`data_file`**: JSON persistence file path (default: "data/voice_channels.json")
### Channel Categories
- Channels are created in the "Voice Channels" category if it exists
- Falls back to no category if "Voice Channels" category not found
### Random Codenames
```python
CODENAMES = [
"Phoenix", "Thunder", "Lightning", "Storm", "Blaze", "Frost", "Shadow", "Nova",
"Viper", "Falcon", "Wolf", "Eagle", "Tiger", "Shark", "Bear", "Dragon",
"Alpha", "Beta", "Gamma", "Delta", "Echo", "Foxtrot", "Golf", "Hotel",
"Crimson", "Azure", "Emerald", "Golden", "Silver", "Bronze", "Platinum", "Diamond"
]
```
## Error Handling
### Common Scenarios
- **No Team Found**: User-friendly message directing to contact league administrator
- **No Upcoming Games**: Informative message about being between series
- **Missing Discord Roles**: Warning in embed about teams without speaking permissions
- **Permission Errors**: Clear message to contact server administrator
- **League Info Unavailable**: Graceful fallback with retry suggestion
### Service Dependencies
- **Graceful Degradation**: Voice channels work without cleanup service
- **API Failures**: Comprehensive error handling for external service calls
- **Discord Errors**: Specific handling for Forbidden, NotFound, etc.
## Testing Coverage
### Test Files
- **`tests/test_commands_voice.py`**: Comprehensive test suite covering:
- VoiceChannelTracker JSON persistence and datetime handling
- VoiceChannelCleanupService restart resilience and monitoring
- VoiceChannelCommands slash command functionality
- Error scenarios and edge cases
- Deprecated command migration messages
### Mock Objects
- Discord guild, channels, roles, and interactions
- Team service responses and player data
- Schedule service responses and game data
- League service current state information
## Integration Points
### Bot Integration
- **Package Loading**: Integrated into `bot.py` command package loading sequence
- **Background Tasks**: Cleanup service started in `_setup_background_tasks()`
- **Shutdown Handling**: Cleanup service stopped in `bot.close()`
### Service Layer
- **Team Service**: User team verification and ownership lookup
- **League Service**: Current season/week information retrieval
- **Schedule Service**: Team schedule and opponent detection
### Discord Integration
- **Application Commands**: Modern slash command interface with command groups
- **Permission Overwrites**: Fine-grained voice channel permission control
- **Embed Templates**: Consistent styling using established embed patterns
- **Error Handling**: Integration with global application command error handler
## Usage Examples
### Creating Public Channel
```
/voice-channel public
```
**Result**: Creates "Gameplay [Codename]" with public speaking permissions
### Creating Private Channel
```
/voice-channel private
```
**Result**: Creates "[Away] vs [Home]" with team-only speaking permissions
### Migration from Old Commands
```
!vc
```
**Result**: Shows deprecation message suggesting `/voice-channel public`
## Future Enhancements
### Potential Features
- **Channel Limits**: Per-user or per-team channel creation limits
- **Custom Names**: Allow users to specify custom channel names
- **Extended Permissions**: More granular permission control options
- **Channel Templates**: Predefined setups for different game types
- **Integration Webhooks**: Notifications when channels are created/deleted
### Configuration Options
- **Environment Variables**: Make cleanup intervals configurable via env vars
- **Per-Guild Settings**: Different settings for different Discord servers
- **Role Mapping**: Custom role name patterns for team permissions
---
**Last Updated**: January 2025
**Architecture**: Modern async Discord.py with JSON persistence
**Dependencies**: discord.py, team_service, league_service, schedule_service

View File

@ -224,12 +224,17 @@ class VoiceChannelCommands(commands.Cog):
# Find opponent from current week's schedule
try:
team_games = await self.schedule_service.get_team_schedule(
current_season, user_team.abbrev, weeks=1
)
# Get all games for the current week
week_games = await self.schedule_service.get_week_schedule(current_season, current_week)
current_week_games = [g for g in team_games
if g.week == current_week and not g.is_completed]
# Filter for games involving this team that haven't been completed
team_abbrev_upper = user_team.abbrev.upper()
current_week_games = [
g for g in week_games
if (g.away_team.abbrev.upper() == team_abbrev_upper or
g.home_team.abbrev.upper() == team_abbrev_upper)
and not g.is_completed
]
if not current_week_games:
embed = EmbedTemplate.warning(

View File

@ -33,6 +33,7 @@ class BotConfig(BaseSettings):
weeks_per_season: int = 18
games_per_week: int = 4
modern_stats_start_season: int = 8
offseason_flag: bool = False # When True, relaxes roster limits and disables weekly freeze/thaw
# Current Season Constants
sba_current_season: int = 12
@ -45,7 +46,7 @@ class BotConfig(BaseSettings):
# Draft Constants
default_pick_minutes: int = 10
draft_rounds: int = 25
draft_rounds: int = 32
# Special Team IDs
free_agent_team_id: int = 498
@ -63,7 +64,7 @@ class BotConfig(BaseSettings):
# Application settings
log_level: str = "INFO"
environment: str = "development"
testing: bool = False
testing: bool = True
# Google Sheets settings
sheets_credentials_path: str = "/app/data/major-domo-service-creds.json"

View File

@ -1,657 +0,0 @@
# Models Directory
The models directory contains Pydantic data models for Discord Bot v2.0, providing type-safe representations of all SBA (Strat-o-Matic Baseball Association) entities. All models inherit from `SBABaseModel` and follow consistent validation patterns.
## Architecture
### Pydantic Foundation
All models use Pydantic v2 with:
- **Automatic validation** of field types and constraints
- **Serialization/deserialization** for API interactions
- **Type safety** with full IDE support
- **JSON schema generation** for documentation
- **Field validation** with custom validators
### Base Model (`base.py`)
The foundation for all SBA models:
```python
class SBABaseModel(BaseModel):
model_config = {
"validate_assignment": True,
"use_enum_values": True,
"arbitrary_types_allowed": True,
"json_encoders": {datetime: lambda v: v.isoformat() if v else None}
}
id: Optional[int] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
```
### Breaking Changes (August 2025)
**Database entities now require `id` fields** since they're always fetched from the database:
- `Player` model: `id: int = Field(..., description="Player ID from database")`
- `Team` model: `id: int = Field(..., description="Team ID from database")`
### Game Submission Models (January 2025)
New models for comprehensive game data submission from Google Sheets scorecards:
#### Play Model (`play.py`)
Represents a single play in a baseball game with complete statistics and game state.
**Key Features:**
- **92 total fields** supporting comprehensive play-by-play tracking
- **68 fields from scorecard**: All data read from Google Sheets Playtable
- **Required fields**: game_id, play_num, pitcher_id, on_base_code, inning details, outs, scores
- **Base running**: Tracks up to 3 runners with starting and ending positions
- **Statistics**: PA, AB, H, HR, RBI, BB, SO, SB, CS, errors, and 20+ more
- **Advanced metrics**: WPA, RE24, ballpark effects
- **Descriptive text generation**: Automatic play descriptions for key plays display
**Field Validators:**
```python
@field_validator('on_first_final')
@classmethod
def no_final_if_no_runner_one(cls, v, info):
"""Ensure on_first_final is None if no runner on first."""
if info.data.get('on_first_id') is None:
return None
return v
```
**Usage Example:**
```python
play = Play(
id=1234,
game_id=567,
play_num=1,
pitcher_id=100,
batter_id=101,
on_base_code="000",
inning_half="top",
inning_num=1,
batting_order=1,
starting_outs=0,
away_score=0,
home_score=0,
homerun=1,
rbi=1,
wpa=0.15
)
# Generate human-readable description
description = play.descriptive_text(away_team, home_team)
# Output: "Top 1: (NYY) homers"
```
**Field Categories:**
- **Game Context**: game_id, play_num, inning_half, inning_num, starting_outs
- **Players**: batter_id, pitcher_id, catcher_id, defender_id, runner_id
- **Base Runners**: on_first_id, on_second_id, on_third_id (with _final positions)
- **Offensive Stats**: pa, ab, hit, rbi, double, triple, homerun, bb, so, hbp, sac
- **Defensive Stats**: outs, error, wild_pitch, passed_ball, pick_off, balk
- **Advanced**: wpa, re24_primary, re24_running, ballpark effects (bphr, bpfo, bp1b, bplo)
- **Pitching**: pitcher_rest_outs, inherited_runners, inherited_scored, on_hook_for_loss
**API-Populated Nested Objects:**
The Play model includes optional nested object fields for all ID references. These are populated by the API endpoint to provide complete context without additional lookups:
```python
class Play(SBABaseModel):
# ID field with corresponding optional object
game_id: int = Field(..., description="Game ID this play belongs to")
game: Optional[Game] = Field(None, description="Game object (API-populated)")
pitcher_id: int = Field(..., description="Pitcher ID")
pitcher: Optional[Player] = Field(None, description="Pitcher object (API-populated)")
batter_id: Optional[int] = Field(None, description="Batter ID")
batter: Optional[Player] = Field(None, description="Batter object (API-populated)")
# ... and so on for all player/team IDs
```
**Pattern Details:**
- **Placement**: Optional object field immediately follows its corresponding ID field
- **Naming**: Object field uses singular form of ID field name (e.g., `batter_id``batter`)
- **API Population**: Database endpoint includes nested objects in response
- **Future Enhancement**: Validators could ensure consistency between ID and object fields
**ID Fields with Nested Objects:**
- `game_id``game: Optional[Game]`
- `pitcher_id``pitcher: Optional[Player]`
- `batter_id``batter: Optional[Player]`
- `batter_team_id``batter_team: Optional[Team]`
- `pitcher_team_id``pitcher_team: Optional[Team]`
- `on_first_id``on_first: Optional[Player]`
- `on_second_id``on_second: Optional[Player]`
- `on_third_id``on_third: Optional[Player]`
- `catcher_id``catcher: Optional[Player]`
- `catcher_team_id``catcher_team: Optional[Team]`
- `defender_id``defender: Optional[Player]`
- `defender_team_id``defender_team: Optional[Team]`
- `runner_id``runner: Optional[Player]`
- `runner_team_id``runner_team: Optional[Team]`
**Usage Example:**
```python
# API returns play with nested objects populated
play = await play_service.get_play(play_id=123)
# Access nested objects directly without additional lookups
if play.batter:
print(f"Batter: {play.batter.name}")
if play.pitcher:
print(f"Pitcher: {play.pitcher.name}")
if play.game:
print(f"Game: {play.game.matchup_display}")
```
#### Decision Model (`decision.py`)
Tracks pitching decisions (wins, losses, saves, holds) for game results.
**Key Features:**
- **Pitching decisions**: Win, Loss, Save, Hold, Blown Save flags
- **Game metadata**: game_id, season, week, game_num
- **Pitcher workload**: rest_ip, rest_required, inherited runners
- **Human-readable repr**: Shows decision type (W/L/SV/HLD/BS)
**Usage Example:**
```python
decision = Decision(
id=456,
game_id=567,
season=12,
week=5,
game_num=2,
pitcher_id=200,
team_id=10,
win=1, # Winning pitcher
is_start=True,
rest_ip=7.0,
rest_required=4
)
print(decision)
# Output: Decision(pitcher_id=200, game_id=567, type=W)
```
**Field Categories:**
- **Game Context**: game_id, season, week, game_num
- **Pitcher**: pitcher_id, team_id
- **Decisions**: win, loss, hold, is_save, b_save (all 0 or 1)
- **Workload**: is_start, irunners, irunners_scored, rest_ip, rest_required
**Data Pipeline:**
```
Google Sheets Scorecard
SheetsService.read_playtable_data() → 68 fields per play
PlayService.create_plays_batch() → Validate with Play model
Database API /plays endpoint
PlayService.get_top_plays_by_wpa() → Return Play objects
Play.descriptive_text() → Human-readable descriptions
```
## Model Categories
### Core Entities
#### League Structure
- **`team.py`** - Team information, abbreviations, divisions, and organizational affiliates
- **`division.py`** - Division structure and organization
- **`manager.py`** - Team managers and ownership
- **`standings.py`** - Team standings and rankings
#### Player Data
- **`player.py`** - Core player information and identifiers
- **`sbaplayer.py`** - Extended SBA-specific player data
- **`batting_stats.py`** - Batting statistics and performance metrics
- **`pitching_stats.py`** - Pitching statistics and performance metrics
- **`roster.py`** - Team roster assignments and positions
#### Game Operations
- **`game.py`** - Individual game results and scheduling
- **`play.py`** (NEW - January 2025) - Play-by-play data for game submissions
- **`decision.py`** (NEW - January 2025) - Pitching decisions and game results
- **`transaction.py`** - Player transactions (trades, waivers, etc.)
#### Draft System
- **`draft_pick.py`** - Individual draft pick information
- **`draft_data.py`** - Draft round and selection data
- **`draft_list.py`** - Complete draft lists and results
#### Custom Features
- **`custom_command.py`** - User-created Discord commands
#### Trade System
- **`trade.py`** - Multi-team trade structures and validation
### Legacy Models
- **`current.py`** - Legacy model definitions for backward compatibility
## Model Validation Patterns
### Required Fields
Models distinguish between required and optional fields:
```python
class Player(SBABaseModel):
id: int = Field(..., description="Player ID from database") # Required
name: str = Field(..., description="Player full name") # Required
team_id: Optional[int] = None # Optional
position: Optional[str] = None # Optional
```
### Field Constraints
Models use Pydantic validators for data integrity:
```python
class BattingStats(SBABaseModel):
at_bats: int = Field(ge=0, description="At bats (non-negative)")
hits: int = Field(ge=0, le=Field('at_bats'), description="Hits (cannot exceed at_bats)")
@field_validator('batting_average')
@classmethod
def validate_batting_average(cls, v):
if v is not None and not 0.0 <= v <= 1.0:
raise ValueError('Batting average must be between 0.0 and 1.0')
return v
```
### Custom Validators
Models implement business logic validation:
```python
class Transaction(SBABaseModel):
transaction_type: str
player_id: int
from_team_id: Optional[int] = None
to_team_id: Optional[int] = None
@model_validator(mode='after')
def validate_team_requirements(self):
if self.transaction_type == 'trade':
if not self.from_team_id or not self.to_team_id:
raise ValueError('Trade transactions require both from_team_id and to_team_id')
return self
```
## API Integration
### Data Transformation
Models provide methods for API interaction:
```python
class Player(SBABaseModel):
@classmethod
def from_api_data(cls, data: Dict[str, Any]):
"""Create model instance from API response data."""
if not data:
raise ValueError(f"Cannot create {cls.__name__} from empty data")
return cls(**data)
def to_dict(self, exclude_none: bool = True) -> Dict[str, Any]:
"""Convert model to dictionary for API requests."""
return self.model_dump(exclude_none=exclude_none)
```
### Serialization Examples
Models handle various data formats:
```python
# From API JSON
player_data = {"id": 123, "name": "Player Name", "team_id": 5}
player = Player.from_api_data(player_data)
# To API JSON
api_payload = player.to_dict(exclude_none=True)
# JSON string serialization
json_string = player.model_dump_json()
# From JSON string
player_copy = Player.model_validate_json(json_string)
```
## Testing Requirements
### Model Validation Testing
All model tests must provide complete data:
```python
def test_player_creation():
# ✅ Correct - provides required ID field
player = Player(
id=123,
name="Test Player",
team_id=5,
position="1B"
)
assert player.id == 123
def test_incomplete_data():
# ❌ This will fail - missing required ID
with pytest.raises(ValidationError):
Player(name="Test Player") # Missing required id field
```
### Test Data Patterns
Use helper functions for consistent test data:
```python
def create_test_player(**overrides) -> Player:
"""Create a test player with default values."""
defaults = {
"id": 123,
"name": "Test Player",
"team_id": 1,
"position": "1B"
}
defaults.update(overrides)
return Player(**defaults)
def test_player_with_stats():
player = create_test_player(name="Star Player")
assert player.name == "Star Player"
assert player.id == 123 # Default from helper
```
## Field Types and Constraints
### Common Field Patterns
#### Identifiers
```python
id: int = Field(..., description="Database primary key")
player_id: int = Field(..., description="Foreign key to player")
team_id: Optional[int] = Field(None, description="Foreign key to team")
```
#### Names and Text
```python
name: str = Field(..., min_length=1, max_length=100)
abbreviation: str = Field(..., min_length=2, max_length=5)
description: Optional[str] = Field(None, max_length=500)
```
#### Statistics
```python
games_played: int = Field(ge=0, description="Games played (non-negative)")
batting_average: Optional[float] = Field(None, ge=0.0, le=1.0)
era: Optional[float] = Field(None, ge=0.0, description="Earned run average")
```
#### Dates and Times
```python
game_date: Optional[datetime] = None
created_at: Optional[datetime] = None
season_year: int = Field(..., ge=1900, le=2100)
```
## Model Relationships
### Foreign Key Patterns
Models reference related entities via ID fields:
```python
class Player(SBABaseModel):
id: int
team_id: Optional[int] = None # References Team.id
class BattingStats(SBABaseModel):
player_id: int # References Player.id
season: int
team_id: int # References Team.id
```
### Nested Objects
Some models contain nested structures:
```python
class CustomCommand(SBABaseModel):
name: str
creator: Manager # Nested Manager object
response: str
class DraftPick(SBABaseModel):
pick_number: int
player: Optional[Player] = None # Optional nested Player
team: Team # Required nested Team
```
## Validation Error Handling
### Common Validation Errors
- **Missing required fields** - Provide all required model fields
- **Type mismatches** - Ensure field types match model definitions
- **Constraint violations** - Check field validators and constraints
- **Invalid nested objects** - Validate all nested model data
### Error Examples
```python
try:
player = Player(name="Test") # Missing required id
except ValidationError as e:
print(e.errors())
# [{'type': 'missing', 'loc': ('id',), 'msg': 'Field required'}]
try:
stats = BattingStats(hits=5, at_bats=3) # hits > at_bats
except ValidationError as e:
print(e.errors())
# Constraint violation error
```
## Performance Considerations
### Model Instantiation
- Use `model_validate()` for external data
- Use `model_construct()` for trusted internal data (faster)
- Cache model instances when possible
- Avoid repeated validation of the same data
### Memory Usage
- Models are relatively lightweight
- Nested objects can increase memory footprint
- Consider using `__slots__` for high-volume models
- Use `exclude_none=True` to reduce serialization size
## Development Guidelines
### Adding New Models
1. **Inherit from SBABaseModel** for consistency
2. **Define required fields explicitly** with proper types
3. **Add field descriptions** for documentation
4. **Include validation rules** for data integrity
5. **Provide `from_api_data()` class method** if needed
6. **Write comprehensive tests** covering edge cases
## Team Model Enhancements (January 2025)
### Organizational Affiliate Methods
The Team model now includes methods to work with organizational affiliates (Major League, Minor League, and Injured List teams):
```python
class Team(SBABaseModel):
async def major_league_affiliate(self) -> 'Team':
"""Get the major league team for this organization via API call."""
async def minor_league_affiliate(self) -> 'Team':
"""Get the minor league team for this organization via API call."""
async def injured_list_affiliate(self) -> 'Team':
"""Get the injured list team for this organization via API call."""
def is_same_organization(self, other_team: 'Team') -> bool:
"""Check if this team and another team are from the same organization."""
```
### Usage Examples
#### Organizational Relationships
```python
# Get affiliate teams
por_team = await team_service.get_team_by_abbrev("POR", 12)
por_mil = await por_team.minor_league_affiliate() # Returns "PORMIL" team
por_il = await por_team.injured_list_affiliate() # Returns "PORIL" team
# Check organizational relationships
assert por_team.is_same_organization(por_mil) # True
assert por_team.is_same_organization(por_il) # True
# Different organizations
nyy_team = await team_service.get_team_by_abbrev("NYY", 12)
assert not por_team.is_same_organization(nyy_team) # False
```
#### Roster Type Detection
```python
# Determine roster type from team abbreviation
assert por_team.roster_type() == RosterType.MAJOR_LEAGUE # "POR"
assert por_mil.roster_type() == RosterType.MINOR_LEAGUE # "PORMIL"
assert por_il.roster_type() == RosterType.INJURED_LIST # "PORIL"
# Handle edge cases
bhm_il = Team(abbrev="BHMIL") # BHM + IL, not BH + MIL
assert bhm_il.roster_type() == RosterType.INJURED_LIST
```
### Implementation Notes
- **API Integration**: Affiliate methods make actual API calls to fetch team data
- **Error Handling**: Methods raise `ValueError` if affiliate teams cannot be found
- **Edge Cases**: Correctly handles teams like "BHMIL" (Birmingham IL)
- **Performance**: Base abbreviation extraction is cached internally
### Model Evolution
- **Backward compatibility** - Add optional fields for new features
- **Migration patterns** - Handle schema changes gracefully
- **Version management** - Document breaking changes
- **API alignment** - Keep models synchronized with API
### Testing Strategy
- **Unit tests** for individual model validation
- **Integration tests** with service layer
- **Edge case testing** for validation rules
- **Performance tests** for large data sets
## Trade Model Enhancements (January 2025)
### Multi-Team Trade Support
The Trade model now supports complex multi-team player exchanges with proper organizational authority handling:
```python
class Trade(SBABaseModel):
def get_participant_by_organization(self, team: Team) -> Optional[TradeParticipant]:
"""Find participant by organization affiliation.
Major League team owners control their entire organization (ML/MiL/IL),
so if a ML team is participating, their MiL and IL teams are also valid.
"""
@property
def cross_team_moves(self) -> List[TradeMove]:
"""Get all moves that cross team boundaries (deduplicated)."""
```
### Key Features
#### Organizational Authority Model
```python
# ML team owners can trade from/to any affiliate
wv_team = Team(abbrev="WV") # Major League
wv_mil = Team(abbrev="WVMIL") # Minor League
wv_il = Team(abbrev="WVIL") # Injured List
# If WV is participating in trade, WVMIL and WVIL moves are valid
trade.add_participant(wv_team) # Add ML team
# Now can move players to/from WVMIL and WVIL
```
#### Deduplication Fix
```python
# Before: Each move appeared twice (giving + receiving perspective)
cross_moves = trade.cross_team_moves # Would show duplicates
# After: Clean single view of each player exchange
cross_moves = trade.cross_team_moves # Shows each move once
```
### Trade Move Descriptions
Enhanced move descriptions with clear team-to-team visualization:
```python
# Team-to-team trade
"🔄 Mike Trout: WV (ML) → NY (ML)"
# Free agency signing
" Mike Trout: FA → WV (ML)"
# Release to free agency
" Mike Trout: WV (ML) → FA"
```
### Usage Examples
#### Basic Trade Setup
```python
# Create trade
trade = Trade(trade_id="abc123", participants=[], status=TradeStatus.DRAFT)
# Add participating teams
wv_participant = trade.add_participant(wv_team)
ny_participant = trade.add_participant(ny_team)
# Create player moves
move = TradeMove(
player=player,
from_team=wv_team,
to_team=ny_team,
source_team=wv_team,
destination_team=ny_team
)
```
#### Organizational Flexibility
```python
# Trade builder allows MiL/IL destinations when ML team participates
builder = TradeBuilder(user_id, wv_team) # WV is participating
builder.add_team(ny_team)
# This now works - can send player to NYMIL
success, error = await builder.add_player_move(
player=player,
from_team=wv_team,
to_team=ny_mil_team, # Minor league affiliate
from_roster=RosterType.MAJOR_LEAGUE,
to_roster=RosterType.MINOR_LEAGUE
)
assert success # ✅ Works due to organizational authority
```
### Implementation Notes
- **Deduplication**: `cross_team_moves` now uses only `moves_giving` to avoid showing same move twice
- **Organizational Lookup**: Trade participants can be found by any team in the organization
- **Validation**: Trade balance validation ensures moves are properly matched
- **UI Integration**: Embeds show clean, deduplicated player exchange lists
### Breaking Changes Fixed
- **Team Roster Type Detection**: Updated logic to handle edge cases like "BHMIL" correctly
- **Autocomplete Functions**: Fixed invalid parameter passing in team filtering
- **Trade Participant Validation**: Now properly handles organizational affiliates
---
**Next Steps for AI Agents:**
1. Review existing model implementations for patterns
2. Understand the validation rules and field constraints
3. Check the service layer integration in `/services`
4. Follow the testing patterns with complete model data
5. Consider the API data format when creating new models

View File

@ -27,7 +27,7 @@ class CustomCommand(SBABaseModel):
id: int = Field(..., description="Database ID") # type: ignore
name: str = Field(..., description="Command name (unique)")
content: str = Field(..., description="Command response content")
creator_id: int = Field(..., description="ID of the creator")
creator_id: Optional[int] = Field(None, description="ID of the creator (may be missing from execute endpoint)")
creator: Optional[CustomCommandCreator] = Field(None, description="Creator details")
# Timestamps

View File

@ -3,8 +3,8 @@ Injury model for tracking player injuries
Represents an injury record with game timeline and status information.
"""
from typing import Optional
from pydantic import Field
from typing import Optional, Any, Dict
from pydantic import Field, model_validator
from models.base import SBABaseModel
@ -19,6 +19,26 @@ class Injury(SBABaseModel):
player_id: int = Field(..., description="Player ID who is injured")
total_games: int = Field(..., description="Total games player will be out")
@model_validator(mode='before')
@classmethod
def extract_player_id(cls, data: Any) -> Any:
"""
Extract player_id from nested player object if present.
The API returns injuries with a nested 'player' object:
{'id': 123, 'player': {'id': 456, ...}, ...}
This validator extracts the player ID before validation:
{'id': 123, 'player_id': 456, ...}
"""
if isinstance(data, dict):
# If player_id is missing but player object exists, extract it
if 'player_id' not in data and 'player' in data:
if isinstance(data['player'], dict) and 'id' in data['player']:
data['player_id'] = data['player']['id']
return data
# Injury timeline
start_week: int = Field(..., description="Week injury started")
start_game: int = Field(..., description="Game number injury started (1-4)")

View File

@ -1,394 +0,0 @@
# Services Directory
The services directory contains the service layer for Discord Bot v2.0, providing clean abstractions for API interactions and business logic. All services inherit from `BaseService` and follow consistent patterns for data operations.
## Architecture
### Service Layer Pattern
Services act as the interface between Discord commands and the external API, providing:
- **Data validation** using Pydantic models
- **Error handling** with consistent exception patterns
- **Caching support** via Redis decorators
- **Type safety** with generic TypeVar support
- **Logging integration** with structured logging
### Base Service (`base_service.py`)
The foundation for all services, providing:
- **Generic CRUD operations** (Create, Read, Update, Delete)
- **API client management** with connection pooling
- **Response format handling** for API responses
- **Cache key generation** and management
- **Error handling** with APIException wrapping
```python
class BaseService(Generic[T]):
def __init__(self, model_class: Type[T], endpoint: str)
async def get_by_id(self, object_id: int) -> Optional[T]
async def get_all(self, params: Optional[List[tuple]] = None) -> Tuple[List[T], int]
async def create(self, model_data: Dict[str, Any]) -> Optional[T]
async def update(self, object_id: int, model_data: Dict[str, Any]) -> Optional[T]
async def patch(self, object_id: int, model_data: Dict[str, Any], use_query_params: bool = False) -> Optional[T]
async def delete(self, object_id: int) -> bool
```
**PATCH vs PUT Operations:**
- `update()` uses HTTP PUT for full resource replacement
- `patch()` uses HTTP PATCH for partial updates
- `use_query_params=True` sends data as URL query parameters instead of JSON body
**When to use `use_query_params=True`:**
Some API endpoints (notably the player PATCH endpoint) expect data as query parameters instead of JSON body. Example:
```python
# Standard PATCH with JSON body
await base_service.patch(object_id, {"field": "value"})
# → PATCH /api/v3/endpoint/{id} with JSON: {"field": "value"}
# PATCH with query parameters
await base_service.patch(object_id, {"field": "value"}, use_query_params=True)
# → PATCH /api/v3/endpoint/{id}?field=value
```
## Service Files
### Core Entity Services
- **`player_service.py`** - Player data operations and search functionality
- **`team_service.py`** - Team information and roster management
- **`league_service.py`** - League-wide data and current season info
- **`standings_service.py`** - Team standings and division rankings
- **`schedule_service.py`** - Game scheduling and results
- **`stats_service.py`** - Player statistics (batting, pitching, fielding)
- **`roster_service.py`** - Team roster composition and position assignments
#### TeamService Key Methods
The `TeamService` provides team data operations with specific method names:
```python
class TeamService(BaseService[Team]):
async def get_team(team_id: int) -> Optional[Team] # ✅ Correct method name
async def get_teams_by_owner(owner_id: int, season: Optional[int], roster_type: Optional[str]) -> List[Team]
async def get_team_by_abbrev(abbrev: str, season: Optional[int]) -> Optional[Team]
async def get_teams_by_season(season: int) -> List[Team]
async def get_team_roster(team_id: int, roster_type: str = 'current') -> Optional[Dict[str, Any]]
```
**⚠️ Common Mistake (Fixed January 2025)**:
- **Incorrect**: `team_service.get_team_by_id(team_id)` ❌ (method does not exist)
- **Correct**: `team_service.get_team(team_id)`
This naming inconsistency was fixed in `services/trade_builder.py` line 201 and corresponding test mocks.
### Transaction Services
- **`transaction_service.py`** - Player transaction operations (trades, waivers, etc.)
- **`transaction_builder.py`** - Complex transaction building and validation
### Game Submission Services (NEW - January 2025)
- **`game_service.py`** - Game CRUD operations and scorecard submission support
- **`play_service.py`** - Play-by-play data management for game submissions
- **`decision_service.py`** - Pitching decision operations for game results
- **`sheets_service.py`** - Google Sheets integration for scorecard reading
#### GameService Key Methods
```python
class GameService(BaseService[Game]):
async def find_duplicate_game(season: int, week: int, game_num: int,
away_team_id: int, home_team_id: int) -> Optional[Game]
async def find_scheduled_game(season: int, week: int,
away_team_id: int, home_team_id: int) -> Optional[Game]
async def wipe_game_data(game_id: int) -> bool # Transaction rollback support
async def update_game_result(game_id: int, away_score: int, home_score: int,
away_manager_id: int, home_manager_id: int,
game_num: int, scorecard_url: str) -> Game
```
#### PlayService Key Methods
```python
class PlayService:
async def create_plays_batch(plays: List[Dict[str, Any]]) -> bool
async def delete_plays_for_game(game_id: int) -> bool # Transaction rollback
async def get_top_plays_by_wpa(game_id: int, limit: int = 3) -> List[Play]
```
#### DecisionService Key Methods
```python
class DecisionService:
async def create_decisions_batch(decisions: List[Dict[str, Any]]) -> bool
async def delete_decisions_for_game(game_id: int) -> bool # Transaction rollback
def find_winning_losing_pitchers(decisions_data: List[Dict[str, Any]])
-> Tuple[Optional[int], Optional[int], Optional[int], List[int], List[int]]
```
#### SheetsService Key Methods
```python
class SheetsService:
async def open_scorecard(sheet_url: str) -> pygsheets.Spreadsheet
async def read_setup_data(scorecard: pygsheets.Spreadsheet) -> Dict[str, Any]
async def read_playtable_data(scorecard: pygsheets.Spreadsheet) -> List[Dict[str, Any]]
async def read_pitching_decisions(scorecard: pygsheets.Spreadsheet) -> List[Dict[str, Any]]
async def read_box_score(scorecard: pygsheets.Spreadsheet) -> Dict[str, List[int]]
```
**Transaction Rollback Pattern:**
The game submission services implement a 3-state transaction rollback pattern:
1. **PLAYS_POSTED**: Plays submitted → Rollback: Delete plays
2. **GAME_PATCHED**: Game updated → Rollback: Wipe game + Delete plays
3. **COMPLETE**: All data committed → No rollback needed
**Usage Example:**
```python
# Create plays (state: PLAYS_POSTED)
await play_service.create_plays_batch(plays_data)
rollback_state = "PLAYS_POSTED"
try:
# Update game (state: GAME_PATCHED)
await game_service.update_game_result(game_id, ...)
rollback_state = "GAME_PATCHED"
# Create decisions (state: COMPLETE)
await decision_service.create_decisions_batch(decisions_data)
rollback_state = "COMPLETE"
except APIException as e:
# Rollback based on current state
if rollback_state == "GAME_PATCHED":
await game_service.wipe_game_data(game_id)
await play_service.delete_plays_for_game(game_id)
elif rollback_state == "PLAYS_POSTED":
await play_service.delete_plays_for_game(game_id)
```
### Custom Features
- **`custom_commands_service.py`** - User-created custom Discord commands
- **`help_commands_service.py`** - Admin-managed help system and documentation
## Caching Integration
Services support optional Redis caching via decorators:
```python
from utils.decorators import cached_api_call, cached_single_item
class PlayerService(BaseService[Player]):
@cached_api_call(ttl=600) # Cache for 10 minutes
async def get_players_by_team(self, team_id: int, season: int) -> List[Player]:
return await self.get_all_items(params=[('team_id', team_id), ('season', season)])
@cached_single_item(ttl=300) # Cache for 5 minutes
async def get_player_by_name(self, name: str) -> Optional[Player]:
players = await self.get_by_field('name', name)
return players[0] if players else None
```
### Caching Features
- **Graceful degradation** - Works without Redis
- **Automatic key generation** based on method parameters
- **TTL support** with configurable expiration
- **Cache invalidation** patterns for data updates
## Error Handling
All services use consistent error handling:
```python
try:
result = await some_service.get_data()
return result
except APIException as e:
logger.error("API error occurred", error=e)
raise # Re-raise for command handlers
except Exception as e:
logger.error("Unexpected error", error=e)
raise APIException(f"Service operation failed: {e}")
```
### Exception Types
- **`APIException`** - API communication errors
- **`ValueError`** - Data validation errors
- **`ConnectionError`** - Network connectivity issues
## Usage Patterns
### Service Initialization
Services are typically initialized once and reused:
```python
# In services/__init__.py
from .player_service import PlayerService
from models.player import Player
player_service = PlayerService(Player, 'players')
```
### Command Integration
Services integrate with Discord commands via the `@logged_command` decorator:
```python
@discord.app_commands.command(name="player")
@logged_command("/player")
async def player_info(self, interaction: discord.Interaction, name: str):
player = await player_service.get_player_by_name(name)
if not player:
await interaction.followup.send("Player not found")
return
embed = create_player_embed(player)
await interaction.followup.send(embed=embed)
```
## API Response Format
Services handle the standard API response format:
```json
{
"count": 150,
"players": [
{"id": 1, "name": "Player Name", ...},
{"id": 2, "name": "Another Player", ...}
]
}
```
The `BaseService._extract_items_and_count_from_response()` method automatically parses this format and returns typed model instances.
## Development Guidelines
### Adding New Services
1. **Inherit from BaseService** with appropriate model type
2. **Define specific business methods** beyond CRUD operations
3. **Add caching decorators** for expensive operations
4. **Include comprehensive logging** with structured context
5. **Handle edge cases** and provide meaningful error messages
### Service Method Patterns
- **Query methods** should return `List[T]` or `Optional[T]`
- **Mutation methods** should return the updated model or `None`
- **Search methods** should accept flexible parameters
- **Bulk operations** should handle batching efficiently
### Testing Services
- Use `aioresponses` for HTTP client mocking
- Test both success and error scenarios
- Validate model parsing and transformation
- Verify caching behavior when Redis is available
## Environment Integration
Services respect environment configuration:
- **`DB_URL`** - Database API endpoint
- **`API_TOKEN`** - Authentication token
- **`REDIS_URL`** - Optional caching backend
- **`LOG_LEVEL`** - Logging verbosity
## Performance Considerations
### Optimization Strategies
- **Connection pooling** via global API client
- **Response caching** for frequently accessed data
- **Batch operations** for bulk data processing
- **Lazy loading** for expensive computations
### Monitoring
- All operations are logged with timing information
- Cache hit/miss ratios are tracked
- API error rates are monitored
- Service response times are measured
## Transaction Builder Enhancements (January 2025)
### Enhanced sWAR Calculations
The `TransactionBuilder` now includes comprehensive sWAR (sum of WARA) tracking for both current moves and pre-existing transactions:
```python
class TransactionBuilder:
async def validate_transaction(self, next_week: Optional[int] = None) -> RosterValidationResult:
"""
Validate transaction with optional pre-existing transaction analysis.
Args:
next_week: Week to check for existing transactions (includes pre-existing analysis)
Returns:
RosterValidationResult with projected roster counts and sWAR values
"""
```
### Pre-existing Transaction Support
When `next_week` is provided, the transaction builder:
- **Fetches existing transactions** for the specified week via API
- **Calculates roster impact** of scheduled moves using organizational team matching
- **Tracks sWAR changes** separately for Major League and Minor League rosters
- **Provides contextual display** for user transparency
#### Usage Examples
```python
# Basic validation (current functionality)
validation = await builder.validate_transaction()
# Enhanced validation with pre-existing transactions
current_week = await league_service.get_current_week()
validation = await builder.validate_transaction(next_week=current_week + 1)
# Access enhanced data
print(f"Projected ML sWAR: {validation.major_league_swar}")
print(f"Pre-existing impact: {validation.pre_existing_transactions_note}")
```
### Enhanced RosterValidationResult
New fields provide complete transaction context:
```python
@dataclass
class RosterValidationResult:
# Existing fields...
major_league_swar: float = 0.0
minor_league_swar: float = 0.0
pre_existing_ml_swar_change: float = 0.0
pre_existing_mil_swar_change: float = 0.0
pre_existing_transaction_count: int = 0
@property
def major_league_swar_status(self) -> str:
"""Formatted sWAR display with emoji."""
@property
def pre_existing_transactions_note(self) -> str:
"""User-friendly note about pre-existing moves impact."""
```
### Organizational Team Matching
Transaction processing now uses sophisticated team matching:
```python
# Enhanced logic using Team.is_same_organization()
if transaction.oldteam.is_same_organization(self.team):
# Accurately determine which roster the player is leaving
from_roster_type = transaction.oldteam.roster_type()
if from_roster_type == RosterType.MAJOR_LEAGUE:
# Update ML roster and sWAR
elif from_roster_type == RosterType.MINOR_LEAGUE:
# Update MiL roster and sWAR
```
### Key Improvements
- **Accurate Roster Detection**: Uses `Team.roster_type()` instead of assumptions
- **Organization Awareness**: Properly handles PORMIL, PORIL transactions for POR team
- **Separate sWAR Tracking**: ML and MiL sWAR changes tracked independently
- **Performance Optimization**: Pre-existing transactions loaded once and cached
- **User Transparency**: Clear display of how pre-existing moves affect calculations
### Implementation Details
- **Backwards Compatible**: All existing functionality preserved
- **Optional Enhancement**: `next_week` parameter is optional
- **Error Handling**: Graceful fallback if pre-existing transactions cannot be loaded
- **Caching**: Transaction and roster data cached to avoid repeated API calls
---
**Next Steps for AI Agents:**
1. Review existing service implementations for patterns
2. Check the corresponding model definitions in `/models`
3. Understand the caching decorators in `/utils/decorators.py`
4. Follow the error handling patterns established in `BaseService`
5. Use structured logging with contextual information
6. Consider pre-existing transaction impact when building new transaction features

View File

@ -155,13 +155,22 @@ class InjuryService(BaseService[Injury]):
'is_active': True
}
injury = await self.create(injury_data)
if injury:
logger.info(f"Created injury for player {player_id}: {total_games} games")
return injury
# Call the API to create the injury
client = await self.get_client()
response = await client.post(self.endpoint, injury_data)
logger.error(f"Failed to create injury for player {player_id}")
return None
if not response:
logger.error(f"Failed to create injury for player {player_id}: No response from API")
return None
# Merge the request data with the response to ensure all required fields are present
# (API may not return all fields that were sent)
merged_data = {**injury_data, **response}
# Create Injury model from merged data
injury = Injury.from_api_data(merged_data)
logger.info(f"Created injury for player {player_id}: {total_games} games")
return injury
except Exception as e:
logger.error(f"Error creating injury for player {player_id}: {e}")

View File

@ -32,26 +32,76 @@ class LeagueService(BaseService[Current]):
async def get_current_state(self) -> Optional[Current]:
"""
Get the current league state including week, season, and settings.
Returns:
Current league state or None if not available
"""
try:
client = await self.get_client()
data = await client.get('current')
if data:
current = Current.from_api_data(data)
logger.debug(f"Retrieved current state: Week {current.week}, Season {current.season}")
return current
logger.debug("No current state data found")
return None
except Exception as e:
logger.error(f"Failed to get current league state: {e}")
return None
async def update_current_state(
self,
week: Optional[int] = None,
freeze: Optional[bool] = None
) -> Optional[Current]:
"""
Update current league state (week and/or freeze status).
This is typically used by automated tasks to increment the week
and toggle freeze status during weekly operations.
Args:
week: New week number (None to leave unchanged)
freeze: New freeze status (None to leave unchanged)
Returns:
Updated Current object or None if update failed
Raises:
APIException: If the update operation fails
"""
try:
# Build update data
update_data = {}
if week is not None:
update_data['week'] = week
if freeze is not None:
update_data['freeze'] = freeze
if not update_data:
logger.warning("update_current_state called with no updates")
return await self.get_current_state()
# Current state always has ID of 1 (single record)
current_id = 1
# Use BaseService patch method
updated_current = await self.patch(current_id, update_data)
if updated_current:
logger.info(f"Updated current state: {update_data}")
return updated_current
else:
logger.error("Failed to update current state - patch returned None")
return None
except Exception as e:
logger.error(f"Error updating current state: {e}")
raise APIException(f"Failed to update current state: {e}")
async def get_standings(self, season: Optional[int] = None) -> Optional[List[Dict[str, Any]]]:
"""
Get league standings for a season.

View File

@ -298,6 +298,38 @@ class PlayerService(BaseService[Player]):
logger.error(f"Failed to update player {player_id}: {e}")
return None
async def update_player_team(self, player_id: int, new_team_id: int) -> Optional[Player]:
"""
Update a player's team assignment (for real-time IL moves).
This is used for immediate roster changes where the player needs to show
up on their new team right away, rather than waiting for transaction processing.
Args:
player_id: Player ID to update
new_team_id: New team ID to assign
Returns:
Updated player instance or None
Raises:
APIException: If player update fails
"""
try:
logger.info(f"Updating player {player_id} team to {new_team_id}")
updated_player = await self.update_player(player_id, {'team_id': new_team_id})
if updated_player:
logger.info(f"Successfully updated player {player_id} to team {new_team_id}")
return updated_player
else:
logger.error(f"Failed to update player {player_id} team - no response from API")
raise APIException(f"Failed to update player {player_id} team assignment")
except Exception as e:
logger.error(f"Error updating player {player_id} team: {e}")
raise APIException(f"Failed to update player team: {e}")
# Global service instance - will be properly initialized in __init__.py
player_service = PlayerService()

View File

@ -380,7 +380,7 @@ class TransactionBuilder:
ml_limit = 26
mil_limit = 6
else:
ml_limit = 25
ml_limit = 26
mil_limit = 14
# Validate roster limits
@ -452,16 +452,35 @@ class TransactionBuilder:
for move in self.moves:
# Determine old and new teams based on roster locations
# We need to map RosterType to the actual team (ML, MiL, or IL affiliate)
if move.from_roster == RosterType.FREE_AGENCY:
old_team = fa_team
else:
old_team = move.from_team or self.team
base_team = move.from_team or self.team
# Get the appropriate affiliate based on roster type
if move.from_roster == RosterType.MAJOR_LEAGUE:
old_team = base_team # Already ML team
elif move.from_roster == RosterType.MINOR_LEAGUE:
old_team = await base_team.minor_league_affiliate()
elif move.from_roster == RosterType.INJURED_LIST:
old_team = await base_team.injured_list_affiliate()
else:
old_team = base_team
if move.to_roster == RosterType.FREE_AGENCY:
new_team = fa_team
else:
new_team = move.to_team or self.team
base_team = move.to_team or self.team
# Get the appropriate affiliate based on roster type
if move.to_roster == RosterType.MAJOR_LEAGUE:
new_team = base_team # Already ML team
elif move.to_roster == RosterType.MINOR_LEAGUE:
new_team = await base_team.minor_league_affiliate()
elif move.to_roster == RosterType.INJURED_LIST:
new_team = await base_team.injured_list_affiliate()
else:
new_team = base_team
# For cases where we don't have specific teams, fall back to defaults
if not old_team:
continue

View File

@ -187,44 +187,203 @@ class TransactionService(BaseService[Transaction]):
is_legal=False,
errors=[f"Validation error: {str(e)}"]
)
async def create_transaction_batch(self, transactions: List[Transaction]) -> List[Transaction]:
"""
Create multiple transactions via API POST (for immediate execution).
This is used for real-time transactions (like IL moves) that need to be
posted to the database immediately rather than scheduled for later processing.
The API expects a TransactionList format:
{
"count": 2,
"moves": [
{
"week": 17,
"player_id": 123,
"oldteam_id": 10,
"newteam_id": 11,
"season": 12,
"moveid": "Season-012-Week-17-123456",
"cancelled": false,
"frozen": false
},
...
]
}
Args:
transactions: List of Transaction objects to create
Returns:
List of created Transaction objects with API-assigned IDs
Raises:
APIException: If transaction creation fails
"""
try:
# Convert Transaction objects to API format (simple ID references only)
moves = []
for transaction in transactions:
move = {
"week": transaction.week,
"player_id": transaction.player.id,
"oldteam_id": transaction.oldteam.id,
"newteam_id": transaction.newteam.id,
"season": transaction.season,
"moveid": transaction.moveid,
"cancelled": transaction.cancelled or False,
"frozen": transaction.frozen or False
}
moves.append(move)
# Create batch request payload
batch_data = {
"count": len(moves),
"moves": moves
}
# POST batch to API
client = await self.get_client()
response = await client.post(self.endpoint, data=batch_data)
# API returns a string like "2 transactions have been added"
# We need to return the original Transaction objects (they won't have IDs assigned by API)
if response and isinstance(response, str) and "transactions have been added" in response:
logger.info(f"Successfully created batch: {response}")
return transactions
else:
logger.error(f"Unexpected API response: {response}")
raise APIException(f"Unexpected API response: {response}")
except Exception as e:
logger.error(f"Error creating transaction batch: {e}")
raise APIException(f"Failed to create transactions: {e}")
async def cancel_transaction(self, transaction_id: str) -> bool:
"""
Cancel a pending transaction.
Note: When using moveid, this updates ALL transactions with that moveid (bulk update).
The API returns a message string like "Updated 4 transactions" instead of the transaction object.
Args:
transaction_id: ID of transaction to cancel
transaction_id: Move ID of transaction to cancel (e.g., "Season-012-Week-17-08-18:57:21")
Returns:
True if cancelled successfully
"""
try:
transaction = await self.get_by_id(transaction_id)
if not transaction:
return False
if not transaction.is_pending:
logger.warning(f"Cannot cancel transaction {transaction_id}: not pending (cancelled={transaction.cancelled}, frozen={transaction.frozen})")
return False
# Update transaction status
# Update transaction status using direct API call to handle bulk updates
update_data = {
'cancelled': True,
'cancelled_at': datetime.now(UTC).isoformat()
}
updated_transaction = await self.update(transaction_id, update_data)
if updated_transaction:
# Call API directly since bulk update returns a message string, not a Transaction object
client = await self.get_client()
response = await client.patch(
self.endpoint,
update_data,
object_id=transaction_id,
use_query_params=True
)
# Check if response indicates success
# Response will be a string like "Updated 4 transactions" for bulk updates
if response and (isinstance(response, str) and 'Updated' in response):
logger.info(f"Cancelled transaction(s) {transaction_id}: {response}")
return True
elif response:
# If we got a dict response, it's a single transaction update
logger.info(f"Cancelled transaction {transaction_id}")
return True
else:
logger.warning(f"Failed to cancel transaction {transaction_id}")
return False
except Exception as e:
logger.error(f"Error cancelling transaction {transaction_id}: {e}")
return False
async def unfreeze_transaction(self, transaction_id: str) -> bool:
"""
Unfreeze a frozen transaction, allowing it to be processed.
Note: When using moveid, this updates ALL transactions with that moveid (bulk update).
The API returns a message string like "Updated 4 transactions" instead of the transaction object.
Args:
transaction_id: Move ID of transaction to unfreeze (e.g., "Season-012-Week-17-08-18:57:21")
Returns:
True if unfrozen successfully
"""
try:
# Call API directly since bulk update returns a message string, not a Transaction object
client = await self.get_client()
response = await client.patch(
self.endpoint,
{'frozen': False},
object_id=transaction_id,
use_query_params=True
)
# Check if response indicates success
# Response will be a string like "Updated 4 transactions" for bulk updates
if response and (isinstance(response, str) and 'Updated' in response):
logger.info(f"Unfroze transaction(s) {transaction_id}: {response}")
return True
elif response:
# If we got a dict response, it's a single transaction update
logger.info(f"Unfroze transaction {transaction_id}")
return True
else:
logger.warning(f"Failed to unfreeze transaction {transaction_id}")
return False
except Exception as e:
logger.error(f"Error unfreezing transaction {transaction_id}: {e}")
return False
async def get_frozen_transactions_by_week(
self,
season: int,
week_start: int,
week_end: int
) -> List[Transaction]:
"""
Get all frozen transactions for a week range (all teams).
This is used during freeze processing to get all contested transactions
across the entire league.
Args:
season: Season number
week_start: Starting week number
week_end: Ending week number
Returns:
List of frozen transactions for the week range
"""
try:
params = [
('season', str(season)),
('week_start', str(week_start)),
('week_end', str(week_end)),
('frozen', 'true')
]
transactions = await self.get_all_items(params=params)
logger.debug(f"Retrieved {len(transactions)} frozen transactions for weeks {week_start}-{week_end}")
return transactions
except Exception as e:
logger.error(f"Error getting frozen transactions for weeks {week_start}-{week_end}: {e}")
return []
async def get_contested_transactions(self, season: int, week: int) -> List[Transaction]:
"""
Get transactions that may be contested (multiple teams want same player).

View File

@ -1,364 +0,0 @@
# Tasks Directory
The tasks directory contains automated background tasks for Discord Bot v2.0. These tasks handle periodic maintenance, data cleanup, and scheduled operations that run independently of user interactions.
## Architecture
### Task System Design
Tasks in Discord Bot v2.0 follow these patterns:
- **Discord.py tasks** using the `@tasks.loop` decorator
- **Structured logging** with contextual information
- **Error handling** with graceful degradation
- **Guild-specific operations** respecting bot permissions
- **Configurable intervals** via task decorators
### Base Task Pattern
All tasks follow a consistent structure:
```python
from discord.ext import tasks
from utils.logging import get_contextual_logger
class ExampleTask:
def __init__(self, bot: commands.Bot):
self.bot = bot
self.logger = get_contextual_logger(f'{__name__}.ExampleTask')
self.task_loop.start()
def cog_unload(self):
"""Stop the task when cog is unloaded."""
self.task_loop.cancel()
@tasks.loop(hours=24) # Run daily
async def task_loop(self):
"""Main task implementation."""
try:
# Task logic here
pass
except Exception as e:
self.logger.error("Task failed", error=e)
@task_loop.before_loop
async def before_task(self):
"""Wait for bot to be ready before starting."""
await self.bot.wait_until_ready()
```
## Current Tasks
### Custom Command Cleanup (`custom_command_cleanup.py`)
**Purpose:** Automated cleanup system for user-created custom commands
**Schedule:** Daily (24 hours)
**Operations:**
- **Warning Phase:** Notifies users about commands at risk (unused for 60+ days)
- **Deletion Phase:** Removes commands unused for 90+ days
- **Admin Reporting:** Sends cleanup summaries to admin channels
#### Key Features
- **User Notifications:** Direct messages to command creators
- **Grace Period:** 30-day warning before deletion
- **Admin Transparency:** Optional summary reports
- **Bulk Operations:** Efficient batch processing
- **Error Resilience:** Continues operation despite individual failures
#### Configuration
The cleanup task respects guild settings and permissions:
```python
# Configuration via get_config()
guild_id = config.guild_id # Target guild
admin_channels = ['admin', 'bot-logs'] # Admin notification channels
```
#### Notification System
**Warning Embed (30 days before deletion):**
- Lists commands at risk
- Shows days since last use
- Provides usage instructions
- Links to command management
**Deletion Embed (after deletion):**
- Lists deleted commands
- Shows final usage statistics
- Provides recreation instructions
- Explains cleanup policy
#### Admin Summary
Optional admin channel reporting includes:
- Number of warnings sent
- Number of commands deleted
- Current system statistics
- Next cleanup schedule
## Task Lifecycle
### Initialization
Tasks are initialized when the bot starts:
```python
# In bot startup
def setup_cleanup_task(bot: commands.Bot) -> CustomCommandCleanupTask:
return CustomCommandCleanupTask(bot)
# Usage
cleanup_task = setup_cleanup_task(bot)
```
### Execution Flow
1. **Bot Ready Check:** Wait for `bot.wait_until_ready()`
2. **Guild Validation:** Verify bot has access to configured guild
3. **Permission Checks:** Ensure bot can send messages/DMs
4. **Main Operation:** Execute task logic with error handling
5. **Logging:** Record operation results and performance metrics
6. **Cleanup:** Reset state for next iteration
### Error Handling
Tasks implement comprehensive error handling:
```python
async def task_operation(self):
try:
# Main task logic
result = await self.perform_operation()
self.logger.info("Task completed", result=result)
except SpecificException as e:
self.logger.warning("Recoverable error", error=e)
# Continue with degraded functionality
except Exception as e:
self.logger.error("Task failed", error=e)
# Task will retry on next interval
```
## Development Patterns
### Creating New Tasks
1. **Inherit from Base Pattern**
```python
class NewTask:
def __init__(self, bot: commands.Bot):
self.bot = bot
self.logger = get_contextual_logger(f'{__name__}.NewTask')
self.main_loop.start()
```
2. **Configure Task Schedule**
```python
@tasks.loop(minutes=30) # Every 30 minutes
# or
@tasks.loop(hours=6) # Every 6 hours
# or
@tasks.loop(time=datetime.time(hour=3)) # Daily at 3 AM UTC
```
3. **Implement Before Loop**
```python
@main_loop.before_loop
async def before_loop(self):
await self.bot.wait_until_ready()
self.logger.info("Task initialized and ready")
```
4. **Add Cleanup Handling**
```python
def cog_unload(self):
self.main_loop.cancel()
self.logger.info("Task stopped")
```
### Task Categories
#### Maintenance Tasks
- **Data cleanup** (expired records, unused resources)
- **Cache management** (clear stale entries, optimize storage)
- **Log rotation** (archive old logs, manage disk space)
#### User Management
- **Inactive user cleanup** (remove old user data)
- **Permission auditing** (validate role assignments)
- **Usage analytics** (collect usage statistics)
#### System Monitoring
- **Health checks** (verify system components)
- **Performance monitoring** (track response times)
- **Error rate tracking** (monitor failure rates)
### Task Configuration
#### Environment Variables
Tasks respect standard bot configuration:
```python
GUILD_ID=12345... # Target Discord guild
LOG_LEVEL=INFO # Logging verbosity
REDIS_URL=redis://... # Optional caching backend
```
#### Runtime Configuration
Tasks use the central config system:
```python
from config import get_config
config = get_config()
guild = self.bot.get_guild(config.guild_id)
```
## Logging and Monitoring
### Structured Logging
Tasks use contextual logging for observability:
```python
self.logger.info(
"Cleanup task starting",
guild_id=guild.id,
commands_at_risk=len(at_risk_commands)
)
self.logger.warning(
"User DM failed",
user_id=user.id,
reason="DMs disabled"
)
self.logger.error(
"Task operation failed",
operation="delete_commands",
error=str(e)
)
```
### Performance Tracking
Tasks log timing and performance metrics:
```python
start_time = datetime.utcnow()
# ... task operations ...
duration = (datetime.utcnow() - start_time).total_seconds()
self.logger.info(
"Task completed",
duration_seconds=duration,
operations_completed=operation_count
)
```
### Error Recovery
Tasks implement retry logic and graceful degradation:
```python
async def process_with_retry(self, operation, max_retries=3):
for attempt in range(max_retries):
try:
return await operation()
except RecoverableError as e:
if attempt == max_retries - 1:
raise
await asyncio.sleep(2 ** attempt) # Exponential backoff
```
## Testing Strategies
### Unit Testing Tasks
```python
@pytest.mark.asyncio
async def test_custom_command_cleanup():
# Mock bot and services
bot = AsyncMock()
task = CustomCommandCleanupTask(bot)
# Mock service responses
with patch('services.custom_commands_service') as mock_service:
mock_service.get_commands_needing_warning.return_value = []
# Test task execution
await task.cleanup_task()
# Verify service calls
mock_service.get_commands_needing_warning.assert_called_once()
```
### Integration Testing
```python
@pytest.mark.integration
async def test_cleanup_task_with_real_data():
# Test with actual Discord bot instance
# Use test guild and test data
# Verify real Discord API interactions
```
### Performance Testing
```python
@pytest.mark.performance
async def test_cleanup_task_performance():
# Test with large datasets
# Measure execution time
# Verify memory usage
```
## Security Considerations
### Permission Validation
Tasks verify bot permissions before operations:
```python
async def check_permissions(self, guild: discord.Guild) -> bool:
"""Verify bot has required permissions."""
bot_member = guild.me
# Check for required permissions
if not bot_member.guild_permissions.send_messages:
self.logger.warning("Missing send_messages permission")
return False
return True
```
### Data Privacy
Tasks handle user data responsibly:
- **Minimal data access** - Only access required data
- **Secure logging** - Avoid logging sensitive information
- **GDPR compliance** - Respect user data rights
- **Permission respect** - Honor user privacy settings
### Rate Limiting
Tasks implement Discord API rate limiting:
```python
async def send_notifications_with_rate_limiting(self, notifications):
"""Send notifications with rate limiting."""
for notification in notifications:
try:
await self.send_notification(notification)
await asyncio.sleep(1) # Avoid rate limits
except discord.HTTPException as e:
if e.status == 429: # Rate limited
retry_after = e.response.headers.get('Retry-After', 60)
await asyncio.sleep(int(retry_after))
```
## Future Task Ideas
### Potential Additions
- **Database maintenance** - Optimize database performance
- **Backup automation** - Create data backups
- **Usage analytics** - Generate usage reports
- **Health monitoring** - System health checks
- **Cache warming** - Pre-populate frequently accessed data
### Scalability Patterns
- **Task queues** - Distribute work across multiple workers
- **Sharding support** - Handle multiple Discord guilds
- **Load balancing** - Distribute task execution
- **Monitoring integration** - External monitoring systems
---
**Next Steps for AI Agents:**
1. Review the existing cleanup task implementation
2. Understand the Discord.py tasks framework
3. Follow the structured logging patterns
4. Implement proper error handling and recovery
5. Consider guild permissions and user privacy
6. Test tasks thoroughly before deployment

685
tasks/transaction_freeze.py Normal file
View File

@ -0,0 +1,685 @@
"""
Transaction Freeze/Thaw Task for Discord Bot v2.0
Automated weekly system for freezing and processing transactions.
Runs on a schedule to increment weeks and process contested transactions.
"""
import random
from datetime import datetime, UTC
from typing import Dict, List, Tuple, Set
from dataclasses import dataclass
import discord
from discord.ext import commands, tasks
from services.league_service import league_service
from services.transaction_service import transaction_service
from services.standings_service import standings_service
from models.current import Current
from models.transaction import Transaction
from utils.logging import get_contextual_logger
from views.embeds import EmbedTemplate, EmbedColors
from config import get_config
@dataclass
class TransactionPriority:
"""
Data class for transaction priority calculation.
Used to resolve contested transactions (multiple teams wanting same player).
"""
transaction: Transaction
team_win_percentage: float
tiebreaker: float # win% + small random number for randomized tiebreak
def __lt__(self, other):
"""Allow sorting by tiebreaker value."""
return self.tiebreaker < other.tiebreaker
async def resolve_contested_transactions(
transactions: List[Transaction],
season: int
) -> Tuple[List[str], List[str]]:
"""
Resolve contested transactions where multiple teams want the same player.
This is extracted as a pure function for testability.
Args:
transactions: List of all frozen transactions for the week
season: Current season number
Returns:
Tuple of (winning_move_ids, losing_move_ids)
"""
logger = get_contextual_logger(f'{__name__}.resolve_contested_transactions')
# Group transactions by player name
player_transactions: Dict[str, List[Transaction]] = {}
for transaction in transactions:
player_name = transaction.player.name.lower()
# Only consider transactions where a team is acquiring a player (not FA drops)
if transaction.newteam.abbrev.upper() != 'FA':
if player_name not in player_transactions:
player_transactions[player_name] = []
player_transactions[player_name].append(transaction)
# Identify contested players (multiple teams want same player)
contested_players: Dict[str, List[Transaction]] = {}
non_contested_moves: Set[str] = set()
for player_name, player_transactions_list in player_transactions.items():
if len(player_transactions_list) > 1:
contested_players[player_name] = player_transactions_list
logger.info(f"Contested player: {player_name} ({len(player_transactions_list)} teams)")
else:
# Non-contested, automatically wins
non_contested_moves.add(player_transactions_list[0].moveid)
# Resolve contests using team priority (win% + random tiebreaker)
winning_move_ids: Set[str] = set()
losing_move_ids: Set[str] = set()
for player_name, contested_transactions in contested_players.items():
priorities: List[TransactionPriority] = []
for transaction in contested_transactions:
# Get team for priority calculation
# If adding to MiL team, use the parent ML team for standings
if transaction.newteam.abbrev.endswith('MiL'):
team_abbrev = transaction.newteam.abbrev[:-3] # Remove 'MiL' suffix
else:
team_abbrev = transaction.newteam.abbrev
try:
# Get team standings to calculate win percentage
standings = await standings_service.get_team_standings(team_abbrev, season)
if standings and standings.wins is not None and standings.losses is not None:
total_games = standings.wins + standings.losses
win_pct = standings.wins / total_games if total_games > 0 else 0.0
else:
win_pct = 0.0
logger.warning(f"Could not get standings for {team_abbrev}, using 0.0 win%")
# Add small random component for tiebreaking (5 decimal precision)
random_component = random.randint(10000, 99999) * 0.00000001
tiebreaker = win_pct + random_component
priorities.append(TransactionPriority(
transaction=transaction,
team_win_percentage=win_pct,
tiebreaker=tiebreaker
))
except Exception as e:
logger.error(f"Error calculating priority for {team_abbrev}: {e}")
# Give them 0.0 priority on error
priorities.append(TransactionPriority(
transaction=transaction,
team_win_percentage=0.0,
tiebreaker=random.randint(10000, 99999) * 0.00000001
))
# Sort by tiebreaker (lowest win% wins - worst teams get priority)
priorities.sort()
# First team wins, rest lose
if priorities:
winner = priorities[0]
winning_move_ids.add(winner.transaction.moveid)
logger.info(
f"Contest resolved for {player_name}: {winner.transaction.newteam.abbrev} wins "
f"(win%: {winner.team_win_percentage:.3f}, tiebreaker: {winner.tiebreaker:.8f})"
)
for loser in priorities[1:]:
losing_move_ids.add(loser.transaction.moveid)
logger.info(
f"Contest lost for {player_name}: {loser.transaction.newteam.abbrev} "
f"(win%: {loser.team_win_percentage:.3f}, tiebreaker: {loser.tiebreaker:.8f})"
)
# Add non-contested moves to winners
winning_move_ids.update(non_contested_moves)
return list(winning_move_ids), list(losing_move_ids)
class TransactionFreezeTask:
"""Automated weekly freeze/thaw system for transactions."""
def __init__(self, bot: commands.Bot):
self.bot = bot
self.logger = get_contextual_logger(f'{__name__}.TransactionFreezeTask')
self.weekly_warning_sent = False # Prevent duplicate error notifications
self.logger.info("Transaction freeze/thaw task initialized")
# Start the weekly loop
self.weekly_loop.start()
def cog_unload(self):
"""Stop the task when cog is unloaded."""
self.weekly_loop.cancel()
@tasks.loop(minutes=1)
async def weekly_loop(self):
"""
Main loop that checks time and triggers freeze/thaw operations.
Runs every minute and checks:
- Monday 00:00 -> Begin freeze (increment week, set freeze flag)
- Saturday 00:00 -> End freeze (process frozen transactions)
"""
try:
self.logger.info("Weekly loop check starting")
config = get_config()
# Skip if offseason mode is enabled
if config.offseason_flag:
self.logger.info("Skipping freeze/thaw operations - offseason mode enabled")
return
# Get current league state
current = await league_service.get_current_state()
if not current:
self.logger.warning("Could not get current league state")
return
now = datetime.now()
self.logger.info(
f"Weekly loop check",
datetime=now.isoformat(),
weekday=now.weekday(),
hour=now.hour,
current_week=current.week,
freeze_status=current.freeze
)
# BEGIN FREEZE: Monday at 00:00, not already frozen
if now.weekday() == 0 and now.hour == 0 and not current.freeze:
self.logger.info("Triggering freeze begin")
await self._begin_freeze(current)
self.weekly_warning_sent = False # Reset error flag
# END FREEZE: Saturday at 00:00, currently frozen
elif now.weekday() == 5 and now.hour == 0 and current.freeze:
self.logger.info("Triggering freeze end")
await self._end_freeze(current)
self.weekly_warning_sent = False # Reset error flag
else:
self.logger.debug("No freeze/thaw action needed at this time")
except Exception as e:
self.logger.error(f"Unhandled exception in weekly_loop: {e}", exc_info=True)
error_message = (
f"⚠️ **Weekly Freeze Task Failed**\n"
f"```\n"
f"Error: {str(e)}\n"
f"Time: {datetime.now(UTC).isoformat()}\n"
f"Task: weekly_loop in transaction_freeze.py\n"
f"```"
)
try:
if not self.weekly_warning_sent:
await self._send_owner_notification(error_message)
self.weekly_warning_sent = True
except Exception as notify_error:
self.logger.error(f"Failed to send error notification: {notify_error}")
@weekly_loop.before_loop
async def before_weekly_loop(self):
"""Wait for bot to be ready before starting."""
await self.bot.wait_until_ready()
self.logger.info("Bot is ready, transaction freeze/thaw task starting")
async def _begin_freeze(self, current: Current):
"""
Begin weekly freeze period.
Actions:
1. Increment current week
2. Set freeze flag to True
3. Run regular transactions for current week
4. Send freeze announcement
5. Post weekly info (weeks 1-18 only)
"""
try:
self.logger.info(f"Beginning freeze for week {current.week}")
# Increment week and set freeze via service
new_week = current.week + 1
updated_current = await league_service.update_current_state(
week=new_week,
freeze=True
)
if not updated_current:
raise Exception("Failed to update current state during freeze begin")
self.logger.info(f"Week incremented to {new_week}, freeze set to True")
# Update local current object with returned data
current.week = updated_current.week
current.freeze = updated_current.freeze
# Run regular transactions for the new week
await self._run_transactions(current)
# Send freeze announcement
await self._send_freeze_announcement(current.week, is_beginning=True)
# Post weekly info for weeks 1-18
if 1 <= current.week <= 18:
await self._post_weekly_info(current)
self.logger.info(f"Freeze begin completed for week {current.week}")
except Exception as e:
self.logger.error(f"Error in _begin_freeze: {e}", exc_info=True)
raise
async def _end_freeze(self, current: Current):
"""
End weekly freeze period.
Actions:
1. Process frozen transactions with priority resolution
2. Set freeze flag to False
3. Send thaw announcement
"""
try:
self.logger.info(f"Ending freeze for week {current.week}")
# Process frozen transactions BEFORE unfreezing
await self._process_frozen_transactions(current)
# Set freeze to False via service
updated_current = await league_service.update_current_state(freeze=False)
if not updated_current:
raise Exception("Failed to update current state during freeze end")
self.logger.info(f"Freeze set to False for week {current.week}")
# Update local current object
current.freeze = updated_current.freeze
# Send thaw announcement
await self._send_freeze_announcement(current.week, is_beginning=False)
self.logger.info(f"Freeze end completed for week {current.week}")
except Exception as e:
self.logger.error(f"Error in _end_freeze: {e}", exc_info=True)
raise
async def _run_transactions(self, current: Current):
"""
Process regular (non-frozen) transactions for the current week.
These are transactions that take effect immediately.
"""
try:
# Get all non-frozen transactions for current week
client = await transaction_service.get_client()
params = [
('season', str(current.season)),
('week_start', str(current.week)),
('week_end', str(current.week))
]
response = await client.get('transactions', params=params)
if not response or response.get('count', 0) == 0:
self.logger.info(f"No regular transactions to process for week {current.week}")
return
transactions = response.get('transactions', [])
self.logger.info(f"Processing {len(transactions)} regular transactions for week {current.week}")
# Note: The actual player updates would happen via the API here
# For now, we just log them - the API handles the actual roster updates
except Exception as e:
self.logger.error(f"Error running transactions: {e}", exc_info=True)
async def _process_frozen_transactions(self, current: Current):
"""
Process frozen transactions with priority resolution.
Uses the NEW transaction logic (no backup implementation).
Steps:
1. Get all frozen transactions for current week
2. Resolve contested transactions (multiple teams want same player)
3. Cancel losing transactions
4. Unfreeze and post winning transactions
"""
try:
# Get all frozen transactions for current week via service
transactions = await transaction_service.get_frozen_transactions_by_week(
season=current.season,
week_start=current.week,
week_end=current.week + 1
)
if not transactions:
self.logger.warning(f"No frozen transactions to process for week {current.week}")
return
self.logger.info(f"Processing {len(transactions)} frozen transactions for week {current.week}")
# Resolve contested transactions
winning_move_ids, losing_move_ids = await resolve_contested_transactions(
transactions,
current.season
)
# Cancel losing transactions via service
for losing_move_id in losing_move_ids:
try:
# Get all moves with this moveid (could be multiple players in one transaction)
losing_moves = [t for t in transactions if t.moveid == losing_move_id]
if losing_moves:
# Cancel the entire transaction (all moves with same moveid)
for move in losing_moves:
success = await transaction_service.cancel_transaction(move.moveid)
if not success:
self.logger.warning(f"Failed to cancel transaction {move.moveid}")
# Notify the GM(s) about cancellation
first_move = losing_moves[0]
# Determine which team to notify (the team that was trying to acquire)
team_for_notification = (first_move.newteam
if first_move.newteam.abbrev.upper() != 'FA'
else first_move.oldteam)
await self._notify_gm_of_cancellation(first_move, team_for_notification)
contested_players = [move.player.name for move in losing_moves]
self.logger.info(
f"Cancelled transaction {losing_move_id} due to contested players: "
f"{contested_players}"
)
except Exception as e:
self.logger.error(f"Error cancelling transaction {losing_move_id}: {e}")
# Unfreeze winning transactions and post to log via service
for winning_move_id in winning_move_ids:
try:
# Get all moves with this moveid
winning_moves = [t for t in transactions if t.moveid == winning_move_id]
for move in winning_moves:
# Unfreeze the transaction via service
success = await transaction_service.unfreeze_transaction(move.moveid)
if not success:
self.logger.warning(f"Failed to unfreeze transaction {move.moveid}")
# Post to transaction log
await self._post_transaction_to_log(winning_move_id, transactions)
self.logger.info(f"Processed successful transaction {winning_move_id}")
except Exception as e:
self.logger.error(f"Error processing winning transaction {winning_move_id}: {e}")
self.logger.info(
f"Freeze processing complete: {len(winning_move_ids)} successful transactions, "
f"{len(losing_move_ids)} cancelled transactions"
)
except Exception as e:
self.logger.error(f"Error during freeze processing: {e}", exc_info=True)
raise
async def _send_freeze_announcement(self, week: int, is_beginning: bool):
"""
Send freeze/thaw announcement to transaction log channel.
Args:
week: Current week number
is_beginning: True for freeze begin, False for freeze end
"""
try:
config = get_config()
guild = self.bot.get_guild(config.guild_id)
if not guild:
self.logger.warning("Could not find guild for freeze announcement")
return
channel = discord.utils.get(guild.text_channels, name='transaction-log')
if not channel:
self.logger.warning("Could not find transaction-log channel")
return
# Create announcement message (formatted like legacy bot)
week_num = f'Week {week}'
stars = '*' * 32
if is_beginning:
message = (
f'```\n'
f'{stars}\n'
f'{week_num:>9} Freeze Period Begins\n'
f'{stars}\n'
f'```'
)
else:
message = (
f'```\n'
f'{"*" * 30}\n'
f'{week_num:>9} Freeze Period Ends\n'
f'{"*" * 30}\n'
f'```'
)
await channel.send(message)
self.logger.info(f"Freeze announcement sent for week {week} ({'begin' if is_beginning else 'end'})")
except Exception as e:
self.logger.error(f"Error sending freeze announcement: {e}")
async def _post_weekly_info(self, current: Current):
"""
Post weekly schedule information to #weekly-info channel.
Args:
current: Current league state
"""
try:
config = get_config()
guild = self.bot.get_guild(config.guild_id)
if not guild:
return
info_channel = discord.utils.get(guild.text_channels, name='weekly-info')
if not info_channel:
self.logger.warning("Could not find weekly-info channel")
return
# Clear recent messages (last 25)
async for message in info_channel.history(limit=25):
try:
await message.delete()
except:
pass # Ignore deletion errors
# Determine season emoji
if current.week <= 5:
season_str = "🌼 **Spring**"
elif current.week > 14:
season_str = "🍂 **Fall**"
else:
season_str = "🏖️ **Summer**"
# Determine day/night schedule
night_str = "🌙 Night"
day_str = "🌞 Day"
is_div_week = current.week in [1, 3, 6, 14, 16, 18]
weekly_str = (
f'**Season**: {season_str}\n'
f'**Time of Day**: {night_str} / {night_str if is_div_week else day_str} / '
f'{night_str} / {day_str}'
)
# Send info messages
await info_channel.send(
content=(
f'Each team has manage permissions in their home ballpark. '
f'They may pin messages and rename the channel.\n\n'
f'**Make sure your ballpark starts with your team abbreviation.**'
)
)
await info_channel.send(weekly_str)
self.logger.info(f"Weekly info posted for week {current.week}")
except Exception as e:
self.logger.error(f"Error posting weekly info: {e}")
async def _post_transaction_to_log(
self,
move_id: str,
all_transactions: List[Transaction]
):
"""
Post a transaction to the transaction log channel.
Args:
move_id: Transaction move ID
all_transactions: List of all transactions to find moves with this ID
"""
try:
config = get_config()
guild = self.bot.get_guild(config.guild_id)
if not guild:
return
channel = discord.utils.get(guild.text_channels, name='transaction-log')
if not channel:
return
# Get all moves with this moveid
moves = [t for t in all_transactions if t.moveid == move_id]
if not moves:
return
# Determine the team for the embed (team making the moves)
first_move = moves[0]
if first_move.newteam.abbrev.upper() != 'FA' and 'IL' not in first_move.newteam.abbrev:
this_team = first_move.newteam
elif first_move.oldteam.abbrev.upper() != 'FA' and 'IL' not in first_move.oldteam.abbrev:
this_team = first_move.oldteam
else:
# Default to newteam if both are FA/IL
this_team = first_move.newteam
# Build move string
move_string = ""
week_num = first_move.week
for move in moves:
move_string += (
f'**{move.player.name}** ({move.player.wara:.1f}) '
f'from {move.oldteam.abbrev} to {move.newteam.abbrev}\n'
)
# Create embed
embed = EmbedTemplate.create_base_embed(
title=f'Week {week_num} Transaction',
description=this_team.sname if hasattr(this_team, 'sname') else this_team.lname,
color=EmbedColors.INFO
)
# Set team color if available
if hasattr(this_team, 'color') and this_team.color:
try:
embed.color = discord.Color(int(this_team.color.replace('#', ''), 16))
except:
pass # Use default color on error
embed.add_field(name='Player Moves', value=move_string, inline=False)
await channel.send(embed=embed)
self.logger.info(f"Transaction posted to log: {move_id}")
except Exception as e:
self.logger.error(f"Error posting transaction to log: {e}")
async def _notify_gm_of_cancellation(
self,
transaction: Transaction,
team
):
"""
Send DM to GM(s) about cancelled transaction.
Args:
transaction: The cancelled transaction
team: Team whose GMs should be notified
"""
try:
config = get_config()
guild = self.bot.get_guild(config.guild_id)
if not guild:
return
cancel_text = (
f'Your transaction for **{transaction.player.name}** has been cancelled '
f'because another team successfully claimed them during the freeze period.'
)
# Notify GM1
if hasattr(team, 'gmid') and team.gmid:
try:
gm_one = guild.get_member(team.gmid)
if gm_one:
await gm_one.send(cancel_text)
self.logger.info(f"Cancellation notification sent to GM1 of {team.abbrev}")
except Exception as e:
self.logger.error(f"Could not notify GM1 of {team.abbrev}: {e}")
# Notify GM2 if exists
if hasattr(team, 'gmid2') and team.gmid2:
try:
gm_two = guild.get_member(team.gmid2)
if gm_two:
await gm_two.send(cancel_text)
self.logger.info(f"Cancellation notification sent to GM2 of {team.abbrev}")
except Exception as e:
self.logger.error(f"Could not notify GM2 of {team.abbrev}: {e}")
except Exception as e:
self.logger.error(f"Error notifying GM of cancellation: {e}")
async def _send_owner_notification(self, message: str):
"""
Send error notification to bot owner.
Args:
message: Error message to send
"""
try:
app_info = await self.bot.application_info()
if app_info.owner:
await app_info.owner.send(message)
self.logger.info("Owner notification sent")
except Exception as e:
self.logger.error(f"Could not send owner notification: {e}")
def setup_freeze_task(bot: commands.Bot) -> TransactionFreezeTask:
"""Set up the transaction freeze/thaw task."""
return TransactionFreezeTask(bot)

View File

@ -1,293 +0,0 @@
# Testing Guide for Discord Bot v2.0
This document provides guidance on testing strategies, patterns, and lessons learned during the development of the Discord Bot v2.0 test suite.
## Test Structure Overview
```
tests/
├── README.md # This guide
├── __init__.py # Test package
├── fixtures/ # Test data fixtures
├── test_config.py # Configuration tests
├── test_constants.py # Constants tests
├── test_exceptions.py # Exception handling tests
├── test_models.py # Pydantic model tests
├── test_services.py # Service layer tests (25 tests)
└── test_api_client_with_aioresponses.py # API client HTTP tests (19 tests)
```
**Total Coverage**: 44 comprehensive tests covering all core functionality.
## Key Testing Patterns
### 1. HTTP Testing with aioresponsesf
**✅ Recommended Approach:**
```python
from aioresponses import aioresponses
@pytest.mark.asyncio
async def test_api_request(api_client):
with aioresponses() as m:
m.get(
"https://api.example.com/v3/players/1",
payload={"id": 1, "name": "Test Player"},
status=200
)
result = await api_client.get("players", object_id=1)
assert result["name"] == "Test Player"
```
**❌ Avoid Complex AsyncMock:**
We initially tried mocking aiohttp's async context managers manually with AsyncMock, which led to complex, brittle tests that failed due to coroutine protocol issues.
### 2. Service Layer Testing
**✅ Complete Model Data:**
Always provide complete model data that satisfies Pydantic validation:
```python
def create_player_data(self, player_id: int, name: str, **kwargs):
"""Create complete player data for testing."""
base_data = {
'id': player_id,
'name': name,
'wara': 2.5, # Required field
'season': 12, # Required field
'team_id': team_id, # Required field
'image': f'https://example.com/player{player_id}.jpg', # Required field
'pos_1': position, # Required field
}
base_data.update(kwargs)
return base_data
```
**❌ Partial Model Data:**
Providing incomplete data leads to Pydantic validation errors that are hard to debug.
### 3. API Response Format Testing
Our API returns responses in this format:
```json
{
"count": 25,
"players": [...]
}
```
**✅ Test Both Formats:**
```python
# Test the count + list format
mock_data = {
"count": 2,
"players": [player1_data, player2_data]
}
# Test single object format (for get_by_id)
mock_data = player1_data
```
## Lessons Learned
### 1. aiohttp Testing Complexity
**Problem**: Manually mocking aiohttp's async context managers is extremely complex and error-prone.
**Solution**: Use `aioresponses` library specifically designed for this purpose.
**Code Example**:
```bash
pip install aioresponses>=0.7.4
```
```python
# Clean, readable, reliable
with aioresponses() as m:
m.get("https://api.example.com/endpoint", payload=expected_data)
result = await client.get("endpoint")
```
### 2. Pydantic Model Validation in Tests
**Problem**: Our models have many required fields. Partial test data causes validation errors.
**Solution**: Create helper functions that generate complete, valid model data.
**Pattern**:
```python
def create_model_data(self, id: int, name: str, **overrides):
"""Create complete model data with all required fields."""
base_data = {
# All required fields with sensible defaults
'id': id,
'name': name,
'required_field1': 'default_value',
'required_field2': 42,
}
base_data.update(overrides)
return base_data
```
### 3. Async Context Manager Mocking
**Problem**: This doesn't work reliably:
```python
# ❌ Brittle and complex
mock_session.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
mock_session.get.return_value.__aexit__ = AsyncMock(return_value=None)
```
**Solution**: Use specialized libraries or patch at higher levels:
```python
# ✅ Clean with aioresponses
with aioresponses() as m:
m.get("url", payload=data)
# Test the actual HTTP call
```
### 4. Service Layer Mocking Strategy
**✅ Mock at the Client Level:**
```python
@pytest.fixture
def player_service_instance(self, mock_client):
service = PlayerService()
service._client = mock_client # Inject mock client
return service
```
This allows testing service logic while controlling API responses.
### 5. Global Instance Testing
**Pattern for Singleton Services:**
```python
def test_global_service_independence():
service1 = PlayerService()
service2 = PlayerService()
# Should be different instances
assert service1 is not service2
# But same configuration
assert service1.endpoint == service2.endpoint
```
## Testing Anti-Patterns to Avoid
### 1. ❌ Incomplete Test Data
```python
# This will fail Pydantic validation
mock_data = {'id': 1, 'name': 'Test'} # Missing required fields
```
### 2. ❌ Complex Manual Mocking
```python
# Avoid complex AsyncMock setups for HTTP clients
mock_response = AsyncMock()
mock_response.__aenter__ = AsyncMock(...) # Too complex
```
### 3. ❌ Testing Implementation Details
```python
# Don't test internal method calls
assert mock_client.get.call_count == 2 # Brittle
# Instead test behavior
assert len(result) == 2 # What matters to users
```
### 4. ❌ Mixing Test Concerns
```python
# Don't test multiple unrelated things in one test
def test_everything(): # Too broad
# Test HTTP client
# Test service logic
# Test model validation
# All in one test - hard to debug
```
## Best Practices Summary
### ✅ Do:
1. **Use aioresponses** for HTTP client testing
2. **Create complete model data** with helper functions
3. **Test behavior, not implementation** details
4. **Mock at appropriate levels** (client level for services)
5. **Use realistic data** that matches actual API responses
6. **Test error scenarios** as thoroughly as happy paths
7. **Keep tests focused** on single responsibilities
### ❌ Don't:
1. **Manually mock async context managers** - use specialized tools
2. **Use partial model data** - always provide complete valid data
3. **Test implementation details** - focus on behavior
4. **Mix multiple concerns** in single tests
5. **Ignore error paths** - test failure scenarios
6. **Skip integration scenarios** - test realistic workflows
## Running Tests
```bash
# Run all tests
pytest
# Run specific test files
pytest tests/test_services.py
pytest tests/test_api_client_with_aioresponses.py
# Run with coverage
pytest --cov=api --cov=services
# Run with verbose output
pytest -v
# Run specific test patterns
pytest -k "test_player" -v
```
## Adding New Tests
### For New API Endpoints:
1. Add aioresponses-based tests in `test_api_client_with_aioresponses.py`
2. Follow existing patterns for success/error scenarios
### For New Services:
1. Add service tests in `test_services.py`
2. Create helper functions for complete model data
3. Mock at the client level, not HTTP level
### For New Models:
1. Add model tests in `test_models.py`
2. Test validation, serialization, and edge cases
3. Use `from_api_data()` pattern for realistic data
## Dependencies
Core testing dependencies in `requirements.txt`:
```
pytest>=7.0.0
pytest-asyncio>=0.21.0
pytest-mock>=3.10.0
aioresponses>=0.7.4 # Essential for HTTP testing
```
## Troubleshooting Common Issues
### "coroutine object does not support async context manager"
- **Cause**: Manually mocking aiohttp async context managers
- **Solution**: Use aioresponses instead of manual mocking
### "ValidationError: Field required"
- **Cause**: Incomplete test data for Pydantic models
- **Solution**: Use helper functions that provide all required fields
### "AssertionError: Regex pattern did not match"
- **Cause**: Exception message doesn't match expected pattern
- **Solution**: Check actual error message and adjust test expectations
### Tests hanging or timing out
- **Cause**: Unclosed aiohttp sessions or improper async handling
- **Solution**: Ensure proper session cleanup and use async context managers
This guide should help maintain high-quality, reliable tests as the project grows!

View File

@ -458,12 +458,13 @@ class TestVoiceChannelCommands:
with patch('commands.voice.channels.team_service') as mock_team_service:
with patch('commands.voice.channels.league_service') as mock_league_service:
with patch.object(voice_cog.schedule_service, 'get_team_schedule') as mock_schedule:
with patch.object(voice_cog.schedule_service, 'get_week_schedule') as mock_schedule:
with patch.object(mock_interaction.guild, 'create_voice_channel', return_value=mock_channel) as mock_create:
with patch('discord.utils.get') as mock_utils_get:
mock_team_service.get_teams_by_owner = AsyncMock(return_value=[mock_user_team])
mock_league_service.get_current_state = AsyncMock(return_value=mock_current)
# get_week_schedule returns all games for the week (not just team games)
mock_schedule.return_value = [mock_game]
# Mock discord.utils.get calls

File diff suppressed because it is too large Load Diff

View File

@ -1,941 +0,0 @@
# Utils Package Documentation
**Discord Bot v2.0 - Utility Functions and Helpers**
This package contains utility functions, helpers, and shared components used throughout the Discord bot application.
## 📋 Table of Contents
1. [**Structured Logging**](#-structured-logging) - Contextual logging with Discord integration
2. [**Redis Caching**](#-redis-caching) - Optional performance caching system
3. [**Command Decorators**](#-command-decorators) - Boilerplate reduction decorators
4. [**Future Utilities**](#-future-utilities) - Planned utility modules
---
## 🔍 Structured Logging
**Location:** `utils/logging.py`
**Purpose:** Provides hybrid logging system with contextual information for Discord bot debugging and monitoring.
### **Quick Start**
```python
from utils.logging import get_contextual_logger, set_discord_context
class YourCommandCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.logger = get_contextual_logger(f'{__name__}.YourCommandCog')
async def your_command(self, interaction: discord.Interaction, param: str):
# Set Discord context for all subsequent log entries
set_discord_context(
interaction=interaction,
command="/your-command",
param_value=param
)
# Start operation timing and get trace ID
trace_id = self.logger.start_operation("your_command_operation")
try:
self.logger.info("Command started")
# Your command logic here
result = await some_api_call(param)
self.logger.debug("API call completed", result_count=len(result))
self.logger.info("Command completed successfully")
except Exception as e:
self.logger.error("Command failed", error=e)
self.logger.end_operation(trace_id, "failed")
raise
else:
self.logger.end_operation(trace_id, "completed")
```
### **Key Features**
#### **🎯 Contextual Information**
Every log entry automatically includes:
- **Discord Context**: User ID, guild ID, guild name, channel ID
- **Command Context**: Command name, parameters
- **Operation Context**: Trace ID, operation name, execution duration
- **Custom Fields**: Additional context via keyword arguments
#### **⏱️ Automatic Timing & Tracing**
```python
trace_id = self.logger.start_operation("complex_operation")
# ... do work ...
self.logger.info("Operation in progress") # Includes duration_ms in extras
# ... more work ...
self.logger.end_operation(trace_id, "completed") # Final timing log
```
**Key Behavior:**
- **`trace_id`**: Promoted to **standard JSON key** (root level) for easy filtering
- **`duration_ms`**: Available in **extras** when timing is active (optional field)
- **Context**: All operation context preserved throughout the async operation
#### **🔗 Request Tracing**
Track a single request through all log entries using trace IDs:
```bash
# Find all logs for a specific request (trace_id is now a standard key)
jq 'select(.trace_id == "abc12345")' logs/discord_bot_v2.json
```
#### **📤 Hybrid Output**
- **Console**: Human-readable for development
- **Traditional File** (`discord_bot_v2.log`): Human-readable with debug info
- **JSON File** (`discord_bot_v2.json`): Structured for analysis
### **API Reference**
#### **Core Functions**
**`get_contextual_logger(logger_name: str) -> ContextualLogger`**
```python
# Get a logger instance for your module
logger = get_contextual_logger(f'{__name__}.MyClass')
```
**`set_discord_context(interaction=None, user_id=None, guild_id=None, **kwargs)`**
```python
# Set context from Discord interaction (recommended)
set_discord_context(interaction=interaction, command="/player", player_name="Mike Trout")
# Or set context manually
set_discord_context(user_id="123456", guild_id="987654", custom_field="value")
```
**`clear_context()`**
```python
# Clear the current logging context (usually not needed)
clear_context()
```
#### **ContextualLogger Methods**
**`start_operation(operation_name: str = None) -> str`**
```python
# Start timing and get trace ID
trace_id = logger.start_operation("player_search")
```
**`end_operation(trace_id: str, operation_result: str = "completed")`**
```python
# End operation and log final duration
logger.end_operation(trace_id, "completed")
# or
logger.end_operation(trace_id, "failed")
```
**`info(message: str, **kwargs)`**
```python
logger.info("Player found", player_id=123, team_name="Yankees")
```
**`debug(message: str, **kwargs)`**
```python
logger.debug("API call started", endpoint="players/search", timeout=30)
```
**`warning(message: str, **kwargs)`**
```python
logger.warning("Multiple players found", candidates=["Player A", "Player B"])
```
**`error(message: str, error: Exception = None, **kwargs)`**
```python
# With exception
logger.error("API call failed", error=e, retry_count=3)
# Without exception
logger.error("Validation failed", field="player_name", value="invalid")
```
**`exception(message: str, **kwargs)`**
```python
# Automatically captures current exception
try:
risky_operation()
except:
logger.exception("Unexpected error in operation", operation_id=123)
```
### **Output Examples**
#### **Console Output (Development)**
```
2025-08-14 14:32:15,123 - commands.players.info.PlayerInfoCommands - INFO - Player info command started
2025-08-14 14:32:16,456 - commands.players.info.PlayerInfoCommands - DEBUG - Starting player search
2025-08-14 14:32:18,789 - commands.players.info.PlayerInfoCommands - INFO - Command completed successfully
```
#### **JSON Output (Monitoring & Analysis)**
```json
{
"timestamp": "2025-08-15T14:32:15.123Z",
"level": "INFO",
"logger": "commands.players.info.PlayerInfoCommands",
"message": "Player info command started",
"function": "player_info",
"line": 50,
"trace_id": "abc12345",
"context": {
"user_id": "123456789",
"guild_id": "987654321",
"guild_name": "SBA League",
"channel_id": "555666777",
"command": "/player",
"player_name": "Mike Trout",
"season": 12,
"trace_id": "abc12345",
"operation": "player_info_command"
},
"extra": {
"duration_ms": 0
}
}
```
#### **Error Output with Exception**
```json
{
"timestamp": "2025-08-15T14:32:18.789Z",
"level": "ERROR",
"logger": "commands.players.info.PlayerInfoCommands",
"message": "API call failed",
"function": "player_info",
"line": 125,
"trace_id": "abc12345",
"exception": {
"type": "APITimeout",
"message": "Request timed out after 30s",
"traceback": "Traceback (most recent call last):\n File ..."
},
"context": {
"user_id": "123456789",
"guild_id": "987654321",
"command": "/player",
"player_name": "Mike Trout",
"trace_id": "abc12345",
"operation": "player_info_command"
},
"extra": {
"duration_ms": 30000,
"retry_count": 3,
"endpoint": "players/search"
}
}
```
### **Advanced Usage Patterns**
#### **API Call Logging**
```python
async def fetch_player_data(self, player_name: str):
self.logger.debug("API call started",
api_endpoint="players/search",
search_term=player_name,
timeout_ms=30000)
try:
result = await api_client.get("players", params=[("name", player_name)])
self.logger.info("API call successful",
results_found=len(result) if result else 0,
response_size_kb=len(str(result)) // 1024)
return result
except TimeoutError as e:
self.logger.error("API timeout",
error=e,
endpoint="players/search",
search_term=player_name)
raise
```
#### **Performance Monitoring**
```python
async def complex_operation(self, data):
trace_id = self.logger.start_operation("complex_operation")
# Step 1
self.logger.debug("Processing step 1", step="validation")
validate_data(data)
# Step 2
self.logger.debug("Processing step 2", step="transformation")
processed = transform_data(data)
# Step 3
self.logger.debug("Processing step 3", step="persistence")
result = await save_data(processed)
self.logger.info("Complex operation completed",
input_size=len(data),
output_size=len(result),
steps_completed=3)
# Final log automatically includes total duration_ms
```
#### **Error Context Enrichment**
```python
async def handle_player_command(self, interaction, player_name):
set_discord_context(
interaction=interaction,
command="/player",
player_name=player_name,
# Add additional context that helps debugging
user_permissions=interaction.user.guild_permissions.administrator,
guild_member_count=len(interaction.guild.members),
request_timestamp=discord.utils.utcnow().isoformat()
)
try:
# Command logic
pass
except Exception as e:
# Error logs will include all the above context automatically
self.logger.error("Player command failed",
error=e,
# Additional error-specific context
error_code="PLAYER_NOT_FOUND",
suggestion="Try using the full player name")
raise
```
### **Querying JSON Logs**
#### **Using jq for Analysis**
**Find all errors:**
```bash
jq 'select(.level == "ERROR")' logs/discord_bot_v2.json
```
**Find slow operations (>5 seconds):**
```bash
jq 'select(.extra.duration_ms > 5000)' logs/discord_bot_v2.json
```
**Track a specific user's activity:**
```bash
jq 'select(.context.user_id == "123456789")' logs/discord_bot_v2.json
```
**Find API timeout errors:**
```bash
jq 'select(.exception.type == "APITimeout")' logs/discord_bot_v2.json
```
**Get error summary by type:**
```bash
jq -r 'select(.level == "ERROR") | .exception.type' logs/discord_bot_v2.json | sort | uniq -c
```
**Trace a complete request:**
```bash
jq 'select(.trace_id == "abc12345")' logs/discord_bot_v2.json | jq -s 'sort_by(.timestamp)'
```
#### **Performance Analysis**
**Average command execution time:**
```bash
jq -r 'select(.message == "Command completed successfully") | .extra.duration_ms' logs/discord_bot_v2.json | awk '{sum+=$1; n++} END {print sum/n}'
```
**Most active users:**
```bash
jq -r '.context.user_id' logs/discord_bot_v2.json | sort | uniq -c | sort -nr | head -10
```
**Command usage statistics:**
```bash
jq -r '.context.command' logs/discord_bot_v2.json | sort | uniq -c | sort -nr
```
### **Best Practices**
#### **✅ Do:**
1. **Always set Discord context** at the start of command handlers
2. **Use start_operation()** for timing critical operations
3. **Call end_operation()** to complete operation timing
4. **Include relevant context** in log messages via keyword arguments
5. **Log at appropriate levels** (debug for detailed flow, info for milestones, warning for recoverable issues, error for failures)
6. **Include error context** when logging exceptions
7. **Use trace_id for correlation** - it's automatically available as a standard key
#### **❌ Don't:**
1. **Don't log sensitive information** (passwords, tokens, personal data)
2. **Don't over-log in tight loops** (use sampling or conditional logging)
3. **Don't use string formatting in log messages** (use keyword arguments instead)
4. **Don't forget to handle exceptions** in logging code itself
5. **Don't manually add trace_id to log messages** - it's handled automatically
#### **🎯 Trace ID & Duration Guidelines:**
- **`trace_id`**: Automatically promoted to standard key when operation is active
- **`duration_ms`**: Appears in extras for logs during timed operations
- **Operation flow**: Always call `start_operation()` → log messages → `end_operation()`
- **Query logs**: Use `jq 'select(.trace_id == "xyz")'` for request tracing
#### **Performance Considerations**
- JSON serialization adds minimal overhead (~1-2ms per log entry)
- Context variables are async-safe and thread-local
- Log rotation prevents disk space issues
- Structured queries are much faster than grep on large files
### **Troubleshooting**
#### **Common Issues**
**Logs not appearing:**
- Check log level configuration in environment
- Verify logs/ directory permissions
- Ensure handlers are properly configured
**JSON serialization errors:**
- Avoid logging complex objects directly
- Convert objects to strings or dicts before logging
- The JSONFormatter handles most common types automatically
**Context not appearing in logs:**
- Ensure `set_discord_context()` is called before logging
- Context is tied to the current async task
- Check that context is not cleared prematurely
**Performance issues:**
- Monitor log file sizes and rotation
- Consider reducing log level in production
- Use sampling for high-frequency operations
---
## 🔄 Redis Caching
**Location:** `utils/cache.py`
**Purpose:** Optional Redis-based caching system to improve performance for expensive API operations.
### **Quick Start**
```python
# In your service - caching is added via decorators
from utils.decorators import cached_api_call, cached_single_item
class PlayerService(BaseService[Player]):
@cached_api_call(ttl=600) # Cache for 10 minutes
async def get_players_by_team(self, team_id: int, season: int) -> List[Player]:
# Existing method - no changes needed
return await self.get_all_items(params=[('team_id', team_id), ('season', season)])
@cached_single_item(ttl=300) # Cache for 5 minutes
async def get_player(self, player_id: int) -> Optional[Player]:
# Existing method - no changes needed
return await self.get_by_id(player_id)
```
### **Configuration**
**Environment Variables** (optional):
```bash
REDIS_URL=redis://localhost:6379 # Empty string disables caching
REDIS_CACHE_TTL=300 # Default TTL in seconds
```
### **Key Features**
- **Graceful Fallback**: Works perfectly without Redis installed/configured
- **Zero Breaking Changes**: All existing functionality preserved
- **Selective Caching**: Add decorators only to expensive methods
- **Automatic Key Generation**: Cache keys based on method parameters
- **Intelligent Invalidation**: Cache patterns for data modification
### **Available Decorators**
**`@cached_api_call(ttl=None, cache_key_suffix="")`**
- For methods returning `List[T]`
- Caches full result sets (e.g., team rosters, player searches)
**`@cached_single_item(ttl=None, cache_key_suffix="")`**
- For methods returning `Optional[T]`
- Caches individual entities (e.g., specific players, teams)
**`@cache_invalidate("pattern1", "pattern2")`**
- For data modification methods
- Clears related cache entries when data changes
### **Usage Examples**
#### **Team Roster Caching**
```python
@cached_api_call(ttl=600, cache_key_suffix="roster")
async def get_players_by_team(self, team_id: int, season: int) -> List[Player]:
# 500+ players cached for 10 minutes
# Cache key: sba:players_get_players_by_team_roster_<hash>
```
#### **Search Results Caching**
```python
@cached_api_call(ttl=180, cache_key_suffix="search")
async def get_players_by_name(self, name: str, season: int) -> List[Player]:
# Search results cached for 3 minutes
# Reduces API load for common player searches
```
#### **Cache Invalidation**
```python
@cache_invalidate("by_team", "search")
async def update_player(self, player_id: int, updates: dict) -> Optional[Player]:
# Clears team roster and search caches when player data changes
result = await self.update_by_id(player_id, updates)
return result
```
### **Performance Impact**
**Memory Usage:**
- ~1-5MB per cached team roster (500 players)
- ~1KB per cached individual player
**Performance Gains:**
- 80-90% reduction in API calls for repeated queries
- ~50-200ms response time improvement for large datasets
- Significant reduction in database/API server load
### **Implementation Details**
**Cache Manager** (`utils/cache.py`):
- Redis connection management with auto-reconnection
- JSON serialization/deserialization
- TTL-based expiration
- Prefix-based cache invalidation
**Base Service Integration**:
- Automatic cache key generation from method parameters
- Model serialization/deserialization
- Error handling and fallback to API calls
---
## 🎯 Command Decorators
**Location:** `utils/decorators.py`
**Purpose:** Decorators to reduce boilerplate code in Discord commands and service methods.
### **Command Logging Decorator**
**`@logged_command(command_name=None, log_params=True, exclude_params=None)`**
Automatically handles comprehensive logging for Discord commands:
```python
from utils.decorators import logged_command
class PlayerCommands(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.logger = get_contextual_logger(f'{__name__}.PlayerCommands')
@discord.app_commands.command(name="player")
@logged_command("/player", exclude_params=["sensitive_data"])
async def player_info(self, interaction, player_name: str, season: int = None):
# Clean business logic only - no logging boilerplate needed
player = await player_service.search_player(player_name, season)
embed = create_player_embed(player)
await interaction.followup.send(embed=embed)
```
**Features:**
- Automatic Discord context setting with interaction details
- Operation timing with trace ID generation
- Parameter logging with exclusion support
- Error handling and re-raising
- Preserves Discord.py command registration compatibility
### **Caching Decorators**
See [Redis Caching](#-redis-caching) section above for caching decorator documentation.
---
## 🚀 Discord Helpers
**Location:** `utils/discord_helpers.py` (NEW - January 2025)
**Purpose:** Common Discord-related helper functions for channel lookups, message sending, and formatting.
### **Available Functions**
#### **`get_channel_by_name(bot, channel_name)`**
Get a text channel by name from the configured guild:
```python
from utils.discord_helpers import get_channel_by_name
# In your command or cog
channel = await get_channel_by_name(self.bot, "sba-network-news")
if channel:
await channel.send("Message content")
```
**Features:**
- Retrieves guild ID from environment (`GUILD_ID`)
- Returns `TextChannel` object or `None` if not found
- Handles errors gracefully with logging
- Works across all guilds the bot is in
#### **`send_to_channel(bot, channel_name, content=None, embed=None)`**
Send a message to a channel by name:
```python
from utils.discord_helpers import send_to_channel
# Send text message
success = await send_to_channel(
self.bot,
"sba-network-news",
content="Game results posted!"
)
# Send embed
success = await send_to_channel(
self.bot,
"sba-network-news",
embed=results_embed
)
# Send both
success = await send_to_channel(
self.bot,
"sba-network-news",
content="Check out these results:",
embed=results_embed
)
```
**Features:**
- Combined channel lookup and message sending
- Supports text content, embeds, or both
- Returns `True` on success, `False` on failure
- Comprehensive error logging
- Non-critical - doesn't raise exceptions
#### **`format_key_plays(plays, away_team, home_team)`**
Format top plays into embed field text for game results:
```python
from utils.discord_helpers import format_key_plays
from services.play_service import play_service
# Get top 3 plays by WPA
top_plays = await play_service.get_top_plays_by_wpa(game_id, limit=3)
# Format for display
key_plays_text = format_key_plays(top_plays, away_team, home_team)
# Add to embed
if key_plays_text:
embed.add_field(name="Key Plays", value=key_plays_text, inline=False)
```
**Output Example:**
```
Top 3: (NYY) homers in 2 runs, NYY up 3-1
Bot 5: (BOS) doubles scoring 1 run, tied at 3
Top 9: (NYY) singles scoring 1 run, NYY up 4-3
```
**Features:**
- Uses `Play.descriptive_text()` for human-readable descriptions
- Adds score context after each play
- Shows which team is leading or if tied
- Returns empty string if no plays provided
- Handles RBI adjustments for accurate score display
### **Real-World Usage**
#### **Scorecard Submission Results Posting**
From `commands/league/submit_scorecard.py`:
```python
# Create results embed
results_embed = await self._create_results_embed(
away_team, home_team, box_score, setup_data,
current, sheet_url, wp_id, lp_id, sv_id, hold_ids, game_id
)
# Post to news channel automatically
await send_to_channel(
self.bot,
SBA_NETWORK_NEWS_CHANNEL, # "sba-network-news"
content=None,
embed=results_embed
)
```
### **Configuration**
These functions rely on environment variables:
- **`GUILD_ID`**: Discord server ID where channels should be found
- **`SBA_NETWORK_NEWS_CHANNEL`**: Channel name for game results (constant)
### **Error Handling**
All functions handle errors gracefully:
- **Channel not found**: Logs warning and returns `None` or `False`
- **Missing GUILD_ID**: Logs error and returns `None` or `False`
- **Send failures**: Logs error with details and returns `False`
- **Empty data**: Returns empty string or `False` without errors
### **Testing Considerations**
When testing commands that use these utilities:
- Mock `get_channel_by_name()` to return test channel objects
- Mock `send_to_channel()` to verify message content
- Mock `format_key_plays()` to verify play formatting logic
- Use test guild IDs in environment variables
---
## 🚀 Future Utilities
Additional utility modules planned for future implementation:
### **Permission Utilities** (Planned)
- Permission checking decorators
- Role validation helpers
- User authorization utilities
### **API Utilities** (Planned)
- Rate limiting decorators
- Response caching mechanisms
- Retry logic with exponential backoff
- Request validation helpers
### **Data Processing** (Planned)
- CSV/JSON export utilities
- Statistical calculation helpers
- Date/time formatting for baseball seasons
- Text processing and search utilities
### **Testing Utilities** (Planned)
- Mock Discord objects for testing
- Fixture generators for common test data
- Assertion helpers for Discord responses
- Test database setup and teardown
---
## 📚 Usage Examples by Module
### **Logging Integration in Commands**
```python
# commands/teams/roster.py
from utils.logging import get_contextual_logger, set_discord_context
class TeamRosterCommands(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.logger = get_contextual_logger(f'{__name__}.TeamRosterCommands')
@discord.app_commands.command(name="roster")
async def team_roster(self, interaction, team_name: str, season: int = None):
set_discord_context(
interaction=interaction,
command="/roster",
team_name=team_name,
season=season
)
trace_id = self.logger.start_operation("team_roster_command")
try:
self.logger.info("Team roster command started")
# Command implementation
team = await team_service.find_team(team_name)
self.logger.debug("Team found", team_id=team.id, team_abbreviation=team.abbrev)
players = await team_service.get_roster(team.id, season)
self.logger.info("Roster retrieved", player_count=len(players))
# Create and send response
embed = create_roster_embed(team, players)
await interaction.followup.send(embed=embed)
self.logger.info("Team roster command completed")
except TeamNotFoundError as e:
self.logger.warning("Team not found", search_term=team_name)
await interaction.followup.send(f"❌ Team '{team_name}' not found", ephemeral=True)
except Exception as e:
self.logger.error("Team roster command failed", error=e)
await interaction.followup.send("❌ Error retrieving team roster", ephemeral=True)
```
### **Service Layer Logging**
```python
# services/team_service.py
from utils.logging import get_contextual_logger
class TeamService(BaseService[Team]):
def __init__(self):
super().__init__(Team, 'teams')
self.logger = get_contextual_logger(f'{__name__}.TeamService')
async def find_team(self, team_name: str) -> Team:
self.logger.debug("Starting team search", search_term=team_name)
# Try exact match first
teams = await self.get_by_field('name', team_name)
if len(teams) == 1:
self.logger.debug("Exact team match found", team_id=teams[0].id)
return teams[0]
# Try abbreviation match
teams = await self.get_by_field('abbrev', team_name.upper())
if len(teams) == 1:
self.logger.debug("Team abbreviation match found", team_id=teams[0].id)
return teams[0]
# Try fuzzy search
all_teams = await self.get_all_items()
matches = [t for t in all_teams if team_name.lower() in t.name.lower()]
if len(matches) == 0:
self.logger.warning("No team matches found", search_term=team_name)
raise TeamNotFoundError(f"No team found matching '{team_name}'")
elif len(matches) > 1:
match_names = [t.name for t in matches]
self.logger.warning("Multiple team matches found",
search_term=team_name,
matches=match_names)
raise MultipleTeamsFoundError(f"Multiple teams found: {', '.join(match_names)}")
self.logger.debug("Fuzzy team match found", team_id=matches[0].id)
return matches[0]
```
---
## 📁 File Structure
```
utils/
├── README.md # This documentation
├── __init__.py # Package initialization
├── cache.py # Redis caching system
├── decorators.py # Command and caching decorators
├── logging.py # Structured logging implementation
└── random_gen.py # Random generation utilities
# Future files:
├── discord_helpers.py # Discord utility functions
├── api_utils.py # API helper functions
├── data_processing.py # Data manipulation utilities
└── testing.py # Testing helper functions
```
---
## 🔍 Autocomplete Functions
**Location:** `utils/autocomplete.py`
**Purpose:** Shared autocomplete functions for Discord slash command parameters.
### **Available Functions**
#### **Player Autocomplete**
```python
async def player_autocomplete(interaction: discord.Interaction, current: str) -> List[discord.app_commands.Choice]:
"""Autocomplete for player names with priority ordering."""
```
**Features:**
- Fuzzy name matching with word boundaries
- Prioritizes exact matches and starts-with matches
- Limits to 25 results (Discord limit)
- Handles API errors gracefully
#### **Team Autocomplete (All Teams)**
```python
async def team_autocomplete(interaction: discord.Interaction, current: str) -> List[discord.app_commands.Choice]:
"""Autocomplete for all team abbreviations."""
```
**Features:**
- Matches team abbreviations (e.g., "WV", "NY", "WVMIL")
- Case-insensitive matching
- Includes full team names in display
#### **Major League Team Autocomplete**
```python
async def major_league_team_autocomplete(interaction: discord.Interaction, current: str) -> List[discord.app_commands.Choice]:
"""Autocomplete for Major League teams only (filtered by roster type)."""
```
**Features:**
- Filters to only Major League teams (≤3 character abbreviations)
- Uses Team model's `roster_type()` method for accurate filtering
- Excludes Minor League (MiL) and Injured List (IL) teams
### **Usage in Commands**
```python
from utils.autocomplete import player_autocomplete, major_league_team_autocomplete
class RosterCommands(commands.Cog):
@discord.app_commands.command(name="roster")
@discord.app_commands.describe(
team="Team abbreviation",
player="Player name (optional)"
)
async def roster_command(
self,
interaction: discord.Interaction,
team: str,
player: Optional[str] = None
):
# Command logic here
pass
# Autocomplete decorators
@roster_command.autocomplete('team')
async def roster_team_autocomplete(self, interaction, current):
return await major_league_team_autocomplete(interaction, current)
@roster_command.autocomplete('player')
async def roster_player_autocomplete(self, interaction, current):
return await player_autocomplete(interaction, current)
```
### **Recent Fixes (January 2025)**
#### **Team Filtering Issue**
- **Problem**: `major_league_team_autocomplete` was passing invalid `roster_type` parameter to API
- **Solution**: Removed parameter and implemented client-side filtering using `team.roster_type()` method
- **Benefit**: More accurate team filtering that respects edge cases like "BHMIL" vs "BHMMIL"
#### **Test Coverage**
- Added comprehensive test suite in `tests/test_utils_autocomplete.py`
- Tests cover all functions, error handling, and edge cases
- Validates prioritization logic and result limits
### **Implementation Notes**
- **Shared Functions**: Autocomplete logic centralized to avoid duplication across commands
- **Error Handling**: Functions return empty lists on API errors rather than crashing
- **Performance**: Uses cached service calls where possible
- **Discord Limits**: Respects 25-choice limit for autocomplete responses
---
**Last Updated:** January 2025 - Added Autocomplete Functions and Fixed Team Filtering
**Next Update:** When additional utility modules are added
For questions or improvements to the logging system, check the implementation in `utils/logging.py` or refer to the JSON log outputs in `logs/discord_bot_v2.json`.

View File

@ -1,585 +0,0 @@
# Views Directory
The views directory contains Discord UI components for Discord Bot v2.0, providing consistent visual interfaces and interactive elements. This includes embeds, modals, buttons, select menus, and other Discord UI components.
## Architecture
### Component-Based UI Design
Views in Discord Bot v2.0 follow these principles:
- **Consistent styling** via centralized templates
- **Reusable components** for common UI patterns
- **Error handling** with graceful degradation
- **User interaction tracking** and validation
- **Accessibility** with proper labeling and feedback
### Base Components
All view components inherit from Discord.py base classes with enhanced functionality:
- **BaseView** - Enhanced discord.ui.View with logging and user validation
- **BaseModal** - Enhanced discord.ui.Modal with error handling
- **EmbedTemplate** - Centralized embed creation with consistent styling
## View Components
### Base View System (`base.py`)
#### BaseView Class
Foundation for all interactive views:
```python
class BaseView(discord.ui.View):
def __init__(self, timeout=180.0, user_id=None):
super().__init__(timeout=timeout)
self.user_id = user_id
self.logger = get_contextual_logger(f'{__name__}.BaseView')
async def interaction_check(self, interaction) -> bool:
"""Validate user permissions for interaction."""
async def on_timeout(self) -> None:
"""Handle view timeout gracefully."""
async def on_error(self, interaction, error, item) -> None:
"""Handle view errors with user feedback."""
```
#### ConfirmationView Class (Updated January 2025)
Reusable confirmation dialog with Confirm/Cancel buttons (`confirmations.py`):
**Key Features:**
- **User restriction**: Only specified users can interact
- **Customizable labels and styles**: Flexible button appearance
- **Timeout handling**: Automatic cleanup after timeout
- **Three-state result**: `True` (confirmed), `False` (cancelled), `None` (timeout)
- **Clean interface**: Automatically removes buttons after interaction
**Usage Pattern:**
```python
from views.confirmations import ConfirmationView
# Create confirmation dialog
view = ConfirmationView(
responders=[interaction.user], # Only this user can interact
timeout=30.0, # 30 second timeout
confirm_label="Yes, delete", # Custom label
cancel_label="No, keep it" # Custom label
)
# Send confirmation
await interaction.edit_original_response(
content="⚠️ Are you sure you want to delete this?",
view=view
)
# Wait for user response
await view.wait()
# Check result
if view.confirmed is True:
# User clicked Confirm
await interaction.edit_original_response(
content="✅ Deleted successfully",
view=None
)
elif view.confirmed is False:
# User clicked Cancel
await interaction.edit_original_response(
content="❌ Cancelled",
view=None
)
else:
# Timeout occurred (view.confirmed is None)
await interaction.edit_original_response(
content="⏱️ Request timed out",
view=None
)
```
**Real-World Example (Scorecard Submission):**
```python
# From commands/league/submit_scorecard.py
if duplicate_game:
view = ConfirmationView(
responders=[interaction.user],
timeout=30.0
)
await interaction.edit_original_response(
content=(
f"⚠️ This game has already been played!\n"
f"Would you like me to wipe the old one and re-submit?"
),
view=view
)
await view.wait()
if view.confirmed:
# User confirmed - proceed with wipe and resubmit
await wipe_old_data()
else:
# User cancelled - exit gracefully
return
```
**Configuration Options:**
```python
ConfirmationView(
responders=[user1, user2], # Multiple users allowed
timeout=60.0, # Custom timeout
confirm_label="Approve", # Custom confirm text
cancel_label="Reject", # Custom cancel text
confirm_style=discord.ButtonStyle.red, # Custom button style
cancel_style=discord.ButtonStyle.grey # Custom button style
)
```
#### PaginationView Class
Multi-page navigation for large datasets:
```python
pages = [embed1, embed2, embed3]
pagination = PaginationView(
pages=pages,
user_id=interaction.user.id,
show_page_numbers=True
)
await interaction.followup.send(embed=pagination.get_current_embed(), view=pagination)
```
### Embed Templates (`embeds.py`)
#### EmbedTemplate Class
Centralized embed creation with consistent styling:
```python
# Success embed
embed = EmbedTemplate.success(
title="Operation Completed",
description="Your request was processed successfully."
)
# Error embed
embed = EmbedTemplate.error(
title="Operation Failed",
description="Please check your input and try again."
)
# Warning embed
embed = EmbedTemplate.warning(
title="Careful!",
description="This action cannot be undone."
)
# Info embed
embed = EmbedTemplate.info(
title="Information",
description="Here's what you need to know."
)
```
#### EmbedColors Dataclass
Consistent color scheme across all embeds:
```python
@dataclass(frozen=True)
class EmbedColors:
PRIMARY: int = 0xa6ce39 # SBA green
SUCCESS: int = 0x28a745 # Green
WARNING: int = 0xffc107 # Yellow
ERROR: int = 0xdc3545 # Red
INFO: int = 0x17a2b8 # Blue
SECONDARY: int = 0x6c757d # Gray
```
### Modal Forms (`modals.py`)
#### BaseModal Class
Foundation for interactive forms:
```python
class BaseModal(discord.ui.Modal):
def __init__(self, title: str, timeout=300.0):
super().__init__(title=title, timeout=timeout)
self.logger = get_contextual_logger(f'{__name__}.BaseModal')
self.result = None
async def on_submit(self, interaction):
"""Handle form submission."""
async def on_error(self, interaction, error):
"""Handle form errors."""
```
#### Usage Pattern
```python
class CustomCommandModal(BaseModal):
def __init__(self):
super().__init__(title="Create Custom Command")
name = discord.ui.TextInput(
label="Command Name",
placeholder="Enter command name...",
required=True,
max_length=50
)
response = discord.ui.TextInput(
label="Response",
placeholder="Enter command response...",
style=discord.TextStyle.paragraph,
required=True,
max_length=2000
)
async def on_submit(self, interaction):
# Process form data
command_data = {
"name": self.name.value,
"response": self.response.value
}
# Handle creation logic
```
### Common UI Elements (`common.py`)
#### Shared Components
- **Loading indicators** for async operations
- **Status messages** for operation feedback
- **Navigation elements** for multi-step processes
- **Validation displays** for form errors
### Specialized Views
#### Custom Commands (`custom_commands.py`)
Views specific to custom command management:
- Command creation forms
- Command listing with actions
- Bulk management interfaces
#### Transaction Management (`transaction_embed.py`)
Views for player transaction interfaces:
- Transaction builder with interactive controls
- Comprehensive validation and sWAR display
- Pre-existing transaction context
- Approval/submission workflows
## Styling Guidelines
### Embed Consistency
All embeds should use EmbedTemplate methods:
```python
# ✅ Consistent styling
embed = EmbedTemplate.success("Player Added", "Player successfully added to roster")
# ❌ Inconsistent styling
embed = discord.Embed(title="Player Added", color=0x00ff00)
```
### Color Usage
Use the standard color palette:
- **PRIMARY (SBA Green)** - Default for neutral information
- **SUCCESS (Green)** - Successful operations
- **ERROR (Red)** - Errors and failures
- **WARNING (Yellow)** - Warnings and cautions
- **INFO (Blue)** - General information
- **SECONDARY (Gray)** - Less important information
### User Feedback
Provide clear feedback for all user interactions:
```python
# Loading state
embed = EmbedTemplate.info("Processing", "Please wait while we process your request...")
# Success state
embed = EmbedTemplate.success("Complete", "Your request has been processed successfully.")
# Error state with helpful information
embed = EmbedTemplate.error(
"Request Failed",
"The player name was not found. Please check your spelling and try again."
)
```
## Interactive Components
### Button Patterns
#### Action Buttons
```python
@discord.ui.button(label="Confirm", style=discord.ButtonStyle.success, emoji="✅")
async def confirm_button(self, interaction, button):
self.increment_interaction_count()
# Handle confirmation
await interaction.response.edit_message(content="Confirmed!", view=None)
```
#### Navigation Buttons
```python
@discord.ui.button(emoji="◀️", style=discord.ButtonStyle.primary)
async def previous_page(self, interaction, button):
self.current_page = max(0, self.current_page - 1)
await interaction.response.edit_message(embed=self.get_current_embed(), view=self)
```
### Select Menu Patterns
#### Option Selection
```python
@discord.ui.select(placeholder="Choose an option...")
async def select_option(self, interaction, select):
selected_value = select.values[0]
# Handle selection
await interaction.response.send_message(f"You selected: {selected_value}")
```
#### Dynamic Options
```python
class PlayerSelectMenu(discord.ui.Select):
def __init__(self, players: List[Player]):
options = [
discord.SelectOption(
label=player.name,
value=str(player.id),
description=f"{player.position} - {player.team.abbrev}"
)
for player in players[:25] # Discord limit
]
super().__init__(placeholder="Select a player...", options=options)
```
## Error Handling
### View Error Handling
All views implement comprehensive error handling:
```python
async def on_error(self, interaction, error, item):
"""Handle view errors gracefully."""
self.logger.error("View error", error=error, item_type=type(item).__name__)
try:
embed = EmbedTemplate.error(
"Interaction Error",
"Something went wrong. Please try again."
)
if not interaction.response.is_done():
await interaction.response.send_message(embed=embed, ephemeral=True)
else:
await interaction.followup.send(embed=embed, ephemeral=True)
except Exception as e:
self.logger.error("Failed to send error message", error=e)
```
### User Input Validation
Forms validate user input before processing:
```python
async def on_submit(self, interaction):
# Validate input
if len(self.name.value) < 2:
embed = EmbedTemplate.error(
"Invalid Input",
"Command name must be at least 2 characters long."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
# Process valid input
await self.create_command(interaction)
```
## Accessibility Features
### User-Friendly Labels
- **Clear button labels** with descriptive text
- **Helpful placeholders** in form fields
- **Descriptive error messages** with actionable guidance
- **Consistent emoji usage** for visual recognition
### Permission Validation
Views respect user permissions and provide appropriate feedback:
```python
async def interaction_check(self, interaction) -> bool:
"""Check if user can interact with this view."""
if self.user_id and interaction.user.id != self.user_id:
await interaction.response.send_message(
"❌ You cannot interact with this menu.",
ephemeral=True
)
return False
return True
```
## Performance Considerations
### View Lifecycle Management
- **Timeout handling** prevents orphaned views
- **Resource cleanup** in view destructors
- **Interaction tracking** for usage analytics
- **Memory management** for large datasets
### Efficient Updates
```python
# ✅ Efficient - Only update what changed
await interaction.response.edit_message(embed=new_embed, view=self)
# ❌ Inefficient - Sends new message
await interaction.response.send_message(embed=new_embed, view=new_view)
```
## Testing Strategies
### View Testing
```python
@pytest.mark.asyncio
async def test_confirmation_view():
view = ConfirmationView(user_id=123)
# Mock interaction
interaction = Mock()
interaction.user.id = 123
# Test button click
await view.confirm_button.callback(interaction)
assert view.result is True
```
### Modal Testing
```python
@pytest.mark.asyncio
async def test_custom_command_modal():
modal = CustomCommandModal()
# Set form values
modal.name.value = "test"
modal.response.value = "Test response"
# Mock interaction
interaction = Mock()
# Test form submission
await modal.on_submit(interaction)
# Verify processing
assert modal.result is not None
```
## Development Guidelines
### Creating New Views
1. **Inherit from base classes** for consistency
2. **Use EmbedTemplate** for all embed creation
3. **Implement proper error handling** in all interactions
4. **Add user permission checks** where appropriate
5. **Include comprehensive logging** with context
6. **Follow timeout patterns** to prevent resource leaks
### View Composition
- **Keep views focused** on single responsibilities
- **Use composition** over complex inheritance
- **Separate business logic** from UI logic
- **Make views testable** with dependency injection
### UI Guidelines
- **Follow Discord design patterns** for familiarity
- **Use consistent colors** from EmbedColors
- **Provide clear user feedback** for all actions
- **Handle edge cases** gracefully
- **Consider mobile users** in layout design
## Transaction Embed Enhancements (January 2025)
### Enhanced Display Features
The transaction embed now provides comprehensive information for better decision-making:
#### New Embed Sections
```python
async def create_transaction_embed(builder: TransactionBuilder) -> discord.Embed:
"""
Creates enhanced transaction embed with sWAR and pre-existing transaction context.
"""
# Existing sections...
# NEW: Team Cost (sWAR) Display
swar_status = f"{validation.major_league_swar_status}\n{validation.minor_league_swar_status}"
embed.add_field(name="Team sWAR", value=swar_status, inline=False)
# NEW: Pre-existing Transaction Context (when applicable)
if validation.pre_existing_transactions_note:
embed.add_field(
name="📋 Transaction Context",
value=validation.pre_existing_transactions_note,
inline=False
)
```
### Enhanced Information Display
#### sWAR Tracking
- **Major League sWAR**: Projected team cost for ML roster
- **Minor League sWAR**: Projected team cost for MiL roster
- **Formatted Display**: Uses 📊 emoji with 1 decimal precision
#### Pre-existing Transaction Context
Dynamic context display based on scheduled moves:
```python
# Example displays:
" **Pre-existing Moves**: 3 scheduled moves (+3.7 sWAR)"
" **Pre-existing Moves**: 2 scheduled moves (-2.5 sWAR)"
" **Pre-existing Moves**: 1 scheduled moves (no sWAR impact)"
# No display when no pre-existing moves (clean interface)
```
### Complete Embed Structure
The enhanced transaction embed now includes:
1. **Current Moves** - List of moves in transaction builder
2. **Roster Status** - Legal/illegal roster counts with limits
3. **Team Cost (sWAR)** - sWAR for both rosters
4. **Transaction Context** - Pre-existing moves impact (conditional)
5. **Errors/Suggestions** - Validation feedback and recommendations
### Usage Examples
#### Basic Transaction Display
```python
# Standard transaction without pre-existing moves
builder = get_transaction_builder(user_id, team)
embed = await create_transaction_embed(builder)
# Shows: moves, roster status, sWAR, errors/suggestions
```
#### Enhanced Context Display
```python
# Transaction with pre-existing moves context
validation = await builder.validate_transaction(next_week=current_week + 1)
embed = await create_transaction_embed(builder)
# Shows: all above + pre-existing transaction impact
```
### User Experience Improvements
- **Complete Context**: Users see full impact including scheduled moves
- **Visual Clarity**: Consistent emoji usage and formatting
- **Conditional Display**: Context only shown when relevant
- **Decision Support**: sWAR projections help strategic planning
### Implementation Notes
- **Backwards Compatible**: Existing embed functionality preserved
- **Conditional Sections**: Pre-existing context only appears when applicable
- **Performance**: Validation data cached to avoid repeated calculations
- **Accessibility**: Clear visual hierarchy with emojis and formatting
---
**Next Steps for AI Agents:**
1. Review existing view implementations for patterns
2. Understand the Discord UI component system
3. Follow the EmbedTemplate system for consistent styling
4. Implement proper error handling and user validation
5. Test interactive components thoroughly
6. Consider accessibility and user experience in design
7. Leverage enhanced transaction context for better user guidance

View File

@ -4,7 +4,7 @@ Base View Classes for Discord Bot v2.0
Provides foundational view components with consistent styling and behavior.
"""
import logging
from typing import Optional, Any, Callable, Awaitable
from typing import List, Optional, Any, Callable, Awaitable, Union
from datetime import datetime, timezone
import discord
@ -21,20 +21,22 @@ class BaseView(discord.ui.View):
*,
timeout: float = 180.0,
user_id: Optional[int] = None,
responders: Optional[List[int | None]] = None,
logger_name: Optional[str] = None
):
super().__init__(timeout=timeout)
self.user_id = user_id
self.responders = responders
self.logger = get_contextual_logger(logger_name or f'{__name__}.BaseView')
self.interaction_count = 0
self.created_at = datetime.now(timezone.utc)
async def interaction_check(self, interaction: discord.Interaction) -> bool:
"""Check if user is authorized to interact with this view."""
if self.user_id is None:
if self.user_id is None and self.responders is None:
return True
if interaction.user.id != self.user_id:
if (self.user_id is not None and interaction.user.id != self.user_id) or (self.responders is not None and interaction.user.id not in self.responders):
await interaction.response.send_message(
"❌ You cannot interact with this menu.",
ephemeral=True
@ -95,14 +97,15 @@ class ConfirmationView(BaseView):
def __init__(
self,
*,
user_id: int,
user_id: Optional[int] = None,
responders: Optional[List[int | None]] = None,
timeout: float = 60.0,
confirm_callback: Optional[Callable[[discord.Interaction], Awaitable[None]]] = None,
cancel_callback: Optional[Callable[[discord.Interaction], Awaitable[None]]] = None,
confirm_label: str = "Confirm",
cancel_label: str = "Cancel"
):
super().__init__(timeout=timeout, user_id=user_id, logger_name=f'{__name__}.ConfirmationView')
super().__init__(timeout=timeout, user_id=user_id, responders=responders, logger_name=f'{__name__}.ConfirmationView')
self.confirm_callback = confirm_callback
self.cancel_callback = cancel_callback
self.result: Optional[bool] = None

View File

@ -485,4 +485,357 @@ def validate_season(season: str) -> bool:
season_num = int(season)
return 1 <= season_num <= 50
except ValueError:
return False
return False
class BatterInjuryModal(BaseModal):
"""Modal for collecting current week/game when logging batter injury."""
def __init__(
self,
player: 'Player',
injury_games: int,
season: int,
*,
timeout: Optional[float] = 300.0
):
"""
Initialize batter injury modal.
Args:
player: Player object for the injured batter
injury_games: Injury games from roll
season: Current season number
timeout: Modal timeout in seconds
"""
super().__init__(title=f"Batter Injury - {player.name}", timeout=timeout)
self.player = player
self.injury_games = injury_games
self.season = season
# Current week input
self.current_week = discord.ui.TextInput(
label="Current Week",
placeholder="Enter current week number (e.g., 5)",
required=True,
max_length=2,
style=discord.TextStyle.short
)
# Current game input
self.current_game = discord.ui.TextInput(
label="Current Game",
placeholder="Enter current game number (1-4)",
required=True,
max_length=1,
style=discord.TextStyle.short
)
self.add_item(self.current_week)
self.add_item(self.current_game)
async def on_submit(self, interaction: discord.Interaction):
"""Handle batter injury input and log injury."""
from services.player_service import player_service
from services.injury_service import injury_service
import math
# Validate current week
try:
week = int(self.current_week.value)
if week < 1 or week > 18:
raise ValueError("Week must be between 1 and 18")
except ValueError:
embed = EmbedTemplate.error(
title="Invalid Week",
description="Current week must be a number between 1 and 18."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
# Validate current game
try:
game = int(self.current_game.value)
if game < 1 or game > 4:
raise ValueError("Game must be between 1 and 4")
except ValueError:
embed = EmbedTemplate.error(
title="Invalid Game",
description="Current game must be a number between 1 and 4."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
# Calculate injury dates
out_weeks = math.floor(self.injury_games / 4)
out_games = self.injury_games % 4
return_week = week + out_weeks
return_game = game + 1 + out_games
if return_game > 4:
return_week += 1
return_game -= 4
# Adjust start date if injury starts after game 4
start_week = week if game != 4 else week + 1
start_game = game + 1 if game != 4 else 1
return_date = f'w{return_week:02d}g{return_game}'
# Create injury record
try:
injury = await injury_service.create_injury(
season=self.season,
player_id=self.player.id,
total_games=self.injury_games,
start_week=start_week,
start_game=start_game,
end_week=return_week,
end_game=return_game
)
if not injury:
raise ValueError("Failed to create injury record")
# Update player's il_return field
await player_service.update_player(self.player.id, {'il_return': return_date})
# Success response
embed = EmbedTemplate.success(
title="Injury Logged",
description=f"{self.player.name}'s injury has been logged."
)
embed.add_field(
name="Duration",
value=f"{self.injury_games} game{'s' if self.injury_games > 1 else ''}",
inline=True
)
embed.add_field(
name="Return Date",
value=return_date,
inline=True
)
if self.player.team:
embed.add_field(
name="Team",
value=f"{self.player.team.lname} ({self.player.team.abbrev})",
inline=False
)
self.is_submitted = True
self.result = {
'injury_id': injury.id,
'total_games': self.injury_games,
'return_date': return_date
}
await interaction.response.send_message(embed=embed)
except Exception as e:
self.logger.error("Failed to create batter injury", error=e, player_id=self.player.id)
embed = EmbedTemplate.error(
title="Error",
description="Failed to log the injury. Please try again or contact an administrator."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
class PitcherRestModal(BaseModal):
"""Modal for collecting pitcher rest games when logging injury."""
def __init__(
self,
player: 'Player',
injury_games: int,
season: int,
*,
timeout: Optional[float] = 300.0
):
"""
Initialize pitcher rest modal.
Args:
player: Player object for the injured pitcher
injury_games: Base injury games from roll
season: Current season number
timeout: Modal timeout in seconds
"""
super().__init__(title=f"Pitcher Rest - {player.name}", timeout=timeout)
self.player = player
self.injury_games = injury_games
self.season = season
# Current week input
self.current_week = discord.ui.TextInput(
label="Current Week",
placeholder="Enter current week number (e.g., 5)",
required=True,
max_length=2,
style=discord.TextStyle.short
)
# Current game input
self.current_game = discord.ui.TextInput(
label="Current Game",
placeholder="Enter current game number (1-4)",
required=True,
max_length=1,
style=discord.TextStyle.short
)
# Rest games input
self.rest_games = discord.ui.TextInput(
label="Pitcher Rest Games",
placeholder="Enter number of rest games (0 or more)",
required=True,
max_length=2,
style=discord.TextStyle.short
)
self.add_item(self.current_week)
self.add_item(self.current_game)
self.add_item(self.rest_games)
async def on_submit(self, interaction: discord.Interaction):
"""Handle pitcher rest input and log injury."""
from services.player_service import player_service
from services.injury_service import injury_service
from models.injury import Injury
import math
# Validate current week
try:
week = int(self.current_week.value)
if week < 1 or week > 18:
raise ValueError("Week must be between 1 and 18")
except ValueError:
embed = EmbedTemplate.error(
title="Invalid Week",
description="Current week must be a number between 1 and 18."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
# Validate current game
try:
game = int(self.current_game.value)
if game < 1 or game > 4:
raise ValueError("Game must be between 1 and 4")
except ValueError:
embed = EmbedTemplate.error(
title="Invalid Game",
description="Current game must be a number between 1 and 4."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
# Validate rest games
try:
rest = int(self.rest_games.value)
if rest < 0:
raise ValueError("Rest games cannot be negative")
except ValueError:
embed = EmbedTemplate.error(
title="Invalid Rest Games",
description="Rest games must be a non-negative number."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
# Calculate total injury
total_injury_games = self.injury_games + rest
# Calculate injury dates
out_weeks = math.floor(total_injury_games / 4)
out_games = total_injury_games % 4
return_week = week + out_weeks
return_game = game + 1 + out_games
if return_game > 4:
return_week += 1
return_game -= 4
# Adjust start date if injury starts after game 4
start_week = week if game != 4 else week + 1
start_game = game + 1 if game != 4 else 1
return_date = f'w{return_week:02d}g{return_game}'
# Create injury record
try:
injury = await injury_service.create_injury(
season=self.season,
player_id=self.player.id,
total_games=total_injury_games,
start_week=start_week,
start_game=start_game,
end_week=return_week,
end_game=return_game
)
if not injury:
raise ValueError("Failed to create injury record")
# Update player's il_return field
await player_service.update_player(self.player.id, {'il_return': return_date})
# Success response
embed = EmbedTemplate.success(
title="Injury Logged",
description=f"{self.player.name}'s injury has been logged."
)
embed.add_field(
name="Base Injury",
value=f"{self.injury_games} game{'s' if self.injury_games > 1 else ''}",
inline=True
)
embed.add_field(
name="Rest Requirement",
value=f"{rest} game{'s' if rest > 1 else ''}",
inline=True
)
embed.add_field(
name="Total Duration",
value=f"{total_injury_games} game{'s' if total_injury_games > 1 else ''}",
inline=True
)
embed.add_field(
name="Return Date",
value=return_date,
inline=True
)
if self.player.team:
embed.add_field(
name="Team",
value=f"{self.player.team.lname} ({self.player.team.abbrev})",
inline=False
)
self.is_submitted = True
self.result = {
'injury_id': injury.id,
'total_games': total_injury_games,
'return_date': return_date
}
await interaction.response.send_message(embed=embed)
except Exception as e:
self.logger.error("Failed to create pitcher injury", error=e, player_id=self.player.id)
embed = EmbedTemplate.error(
title="Error",
description="Failed to log the injury. Please try again or contact an administrator."
)
await interaction.response.send_message(embed=embed, ephemeral=True)

395
views/players.py Normal file
View File

@ -0,0 +1,395 @@
"""
Player View Components
Interactive Discord UI components for player information display with toggleable statistics.
"""
from typing import Optional, TYPE_CHECKING
import discord
from discord.ext import commands
from utils.logging import get_contextual_logger
from views.base import BaseView
from views.embeds import EmbedTemplate, EmbedColors
from models.team import RosterType
if TYPE_CHECKING:
from models.player import Player
from models.batting_stats import BattingStats
from models.pitching_stats import PitchingStats
class PlayerStatsView(BaseView):
"""
Interactive view for player information with toggleable batting and pitching statistics.
Features:
- Basic player info always visible
- Batting stats hidden by default, toggled with button
- Pitching stats hidden by default, toggled with button
- Buttons only appear if corresponding stats exist
- User restriction - only command caller can toggle
- 5 minute timeout with graceful cleanup
"""
def __init__(
self,
player: 'Player',
season: int,
batting_stats: Optional['BattingStats'] = None,
pitching_stats: Optional['PitchingStats'] = None,
user_id: Optional[int] = None
):
"""
Initialize the player stats view.
Args:
player: Player model with basic information
season: Season for statistics display
batting_stats: Batting statistics (if available)
pitching_stats: Pitching statistics (if available)
user_id: Discord user ID who can interact with this view
"""
super().__init__(timeout=300.0, user_id=user_id, logger_name=f'{__name__}.PlayerStatsView')
self.player = player
self.season = season
self.batting_stats = batting_stats
self.pitching_stats = pitching_stats
self.show_batting = False
self.show_pitching = False
# Only show batting button if stats are available
if not batting_stats:
self.remove_item(self.toggle_batting_button)
self.logger.debug("No batting stats available, batting button hidden")
# Only show pitching button if stats are available
if not pitching_stats:
self.remove_item(self.toggle_pitching_button)
self.logger.debug("No pitching stats available, pitching button hidden")
self.logger.info("PlayerStatsView initialized",
player_id=player.id,
player_name=player.name,
season=season,
has_batting=bool(batting_stats),
has_pitching=bool(pitching_stats),
user_id=user_id)
@discord.ui.button(
label="Show Batting Stats",
style=discord.ButtonStyle.primary,
emoji="💥",
row=0
)
async def toggle_batting_button(
self,
interaction: discord.Interaction,
button: discord.ui.Button
):
"""Toggle batting statistics visibility."""
self.increment_interaction_count()
self.show_batting = not self.show_batting
# Update button label
button.label = "Hide Batting Stats" if self.show_batting else "Show Batting Stats"
self.logger.info("Batting stats toggled",
player_id=self.player.id,
show_batting=self.show_batting,
user_id=interaction.user.id)
# Rebuild and update embed
await self._update_embed(interaction)
@discord.ui.button(
label="Show Pitching Stats",
style=discord.ButtonStyle.primary,
emoji="",
row=0
)
async def toggle_pitching_button(
self,
interaction: discord.Interaction,
button: discord.ui.Button
):
"""Toggle pitching statistics visibility."""
self.increment_interaction_count()
self.show_pitching = not self.show_pitching
# Update button label
button.label = "Hide Pitching Stats" if self.show_pitching else "Show Pitching Stats"
self.logger.info("Pitching stats toggled",
player_id=self.player.id,
show_pitching=self.show_pitching,
user_id=interaction.user.id)
# Rebuild and update embed
await self._update_embed(interaction)
async def _update_embed(self, interaction: discord.Interaction):
"""
Rebuild the player embed with current visibility settings and update the message.
Args:
interaction: Discord interaction from button click
"""
try:
# Create embed with current visibility state
embed = await self._create_player_embed()
# Update the message with new embed
await interaction.response.edit_message(embed=embed, view=self)
self.logger.debug("Embed updated successfully",
show_batting=self.show_batting,
show_pitching=self.show_pitching)
except Exception as e:
self.logger.error("Failed to update embed", error=str(e), exc_info=True)
# Try to send error message
try:
error_embed = EmbedTemplate.error(
title="Update Failed",
description="Failed to update player statistics. Please try again."
)
await interaction.response.send_message(embed=error_embed, ephemeral=True)
except Exception:
self.logger.error("Failed to send error message", exc_info=True)
async def _create_player_embed(self) -> discord.Embed:
"""
Create player embed with current visibility settings.
Returns:
Discord embed with player information and visible stats
"""
player = self.player
season = self.season
# Determine embed color based on team
embed_color = EmbedColors.PRIMARY
if hasattr(player, 'team') and player.team and hasattr(player.team, 'color'):
try:
# Convert hex color string to int
embed_color = int(player.team.color, 16)
except (ValueError, TypeError):
embed_color = EmbedColors.PRIMARY
# Create base embed with player name as title
# Add injury indicator emoji if player is injured
title = f"🤕 {player.name}" if player.il_return is not None else player.name
embed = EmbedTemplate.create_base_embed(
title=title,
color=embed_color
)
# Basic info section (always visible)
embed.add_field(
name="Position",
value=player.primary_position,
inline=True
)
if hasattr(player, 'team') and player.team:
embed.add_field(
name="Team",
value=f"{player.team.abbrev} - {player.team.sname}",
inline=True
)
# Add Major League affiliate if this is a Minor League team
if player.team.roster_type() == RosterType.MINOR_LEAGUE:
major_affiliate = player.team.get_major_league_affiliate()
if major_affiliate:
embed.add_field(
name="Major Affiliate",
value=major_affiliate,
inline=True
)
embed.add_field(
name="sWAR",
value=f"{player.wara:.1f}",
inline=True
)
embed.add_field(
name="Player ID",
value=str(player.id),
inline=True
)
# All positions if multiple
if len(player.positions) > 1:
embed.add_field(
name="Positions",
value=", ".join(player.positions),
inline=True
)
embed.add_field(
name="Season",
value=str(season),
inline=True
)
# Always show injury rating
embed.add_field(
name="Injury Rating",
value=player.injury_rating or "N/A",
inline=True
)
# Show injury return date only if player is currently injured
if player.il_return:
embed.add_field(
name="Injury Return",
value=player.il_return,
inline=True
)
# Add batting stats if visible and available
if self.show_batting and self.batting_stats:
embed.add_field(name='', value='', inline=False)
self.logger.debug("Adding batting statistics to embed")
batting_stats = self.batting_stats
rate_stats = (
"```\n"
"╭─────────────╮\n"
f"│ AVG {batting_stats.avg:.3f}\n"
f"│ OBP {batting_stats.obp:.3f}\n"
f"│ SLG {batting_stats.slg:.3f}\n"
f"│ OPS {batting_stats.ops:.3f}\n"
f"│ wOBA {batting_stats.woba:.3f}\n"
"╰─────────────╯\n"
"```"
)
embed.add_field(
name="Rate Stats",
value=rate_stats,
inline=True
)
count_stats = (
"```\n"
"╭───────────╮\n"
f"│ HR {batting_stats.homerun:>3}\n"
f"│ RBI {batting_stats.rbi:>3}\n"
f"│ R {batting_stats.run:>3}\n"
f"│ AB {batting_stats.ab:>4}\n"
f"│ H {batting_stats.hit:>4}\n"
f"│ BB {batting_stats.bb:>3}\n"
f"│ SO {batting_stats.so:>3}\n"
"╰───────────╯\n"
"```"
)
embed.add_field(
name='Counting Stats',
value=count_stats,
inline=True
)
# Add pitching stats if visible and available
if self.show_pitching and self.pitching_stats:
embed.add_field(name='', value='', inline=False)
self.logger.debug("Adding pitching statistics to embed")
pitching_stats = self.pitching_stats
ip = pitching_stats.innings_pitched
record_stats = (
"```\n"
"╭─────────────╮\n"
f"│ G-GS {pitching_stats.games:>2}-{pitching_stats.gs:<2}\n"
f"│ W-L {pitching_stats.win:>2}-{pitching_stats.loss:<2}\n"
f"│ H-SV {pitching_stats.hold:>2}-{pitching_stats.saves:<2}\n"
f"│ ERA {pitching_stats.era:>5.2f}\n"
f"│ WHIP {pitching_stats.whip:>5.2f}\n"
"╰─────────────╯\n"
"```"
)
embed.add_field(
name="Record Stats",
value=record_stats,
inline=True
)
strikeout_stats = (
"```\n"
"╭──────────╮\n"
f"│ IP{ip:>6.1f}\n"
f"│ SO {pitching_stats.so:>3}\n"
f"│ BB {pitching_stats.bb:>3}\n"
f"│ H {pitching_stats.hits:>3}\n"
"╰──────────╯\n"
"```"
)
embed.add_field(
name='Counting Stats',
value=strikeout_stats,
inline=True
)
# Add a note if no stats are visible
if not self.show_batting and not self.show_pitching:
if self.batting_stats or self.pitching_stats:
embed.add_field(
name="📊 Statistics",
value="Click the buttons below to show statistics.",
inline=False
)
else:
embed.add_field(
name="📊 Statistics",
value="No statistics available for this season.",
inline=False
)
# Set player card as main image
if player.image:
embed.set_image(url=player.image)
self.logger.debug("Player card image added to embed", image_url=player.image)
# Set thumbnail with priority: fancycard → headshot → team logo
thumbnail_url = None
thumbnail_source = None
if hasattr(player, 'vanity_card') and player.vanity_card:
thumbnail_url = player.vanity_card
thumbnail_source = "fancycard"
elif hasattr(player, 'headshot') and player.headshot:
thumbnail_url = player.headshot
thumbnail_source = "headshot"
elif hasattr(player, 'team') and player.team and hasattr(player.team, 'thumbnail') and player.team.thumbnail:
thumbnail_url = player.team.thumbnail
thumbnail_source = "team logo"
if thumbnail_url:
embed.set_thumbnail(url=thumbnail_url)
self.logger.debug(f"Thumbnail set from {thumbnail_source}", thumbnail_url=thumbnail_url)
# Footer with player ID
footer_text = f"Player ID: {player.id}"
embed.set_footer(text=footer_text)
return embed
async def get_initial_embed(self) -> discord.Embed:
"""
Get the initial embed with stats hidden.
Returns:
Discord embed with player information, stats hidden by default
"""
# Ensure stats are hidden for initial display
self.show_batting = False
self.show_pitching = False
return await self._create_player_embed()

View File

@ -13,18 +13,22 @@ from views.embeds import EmbedColors, EmbedTemplate
class TransactionEmbedView(discord.ui.View):
"""Interactive view for the transaction builder embed."""
def __init__(self, builder: TransactionBuilder, user_id: int):
def __init__(self, builder: TransactionBuilder, user_id: int, submission_handler: str = "scheduled", command_name: str = "/dropadd"):
"""
Initialize the transaction embed view.
Args:
builder: TransactionBuilder instance
user_id: Discord user ID (for permission checking)
submission_handler: Type of submission ("scheduled" for /dropadd, "immediate" for /ilmove)
command_name: Name of the command being used (for UI instructions)
"""
super().__init__(timeout=900.0) # 15 minute timeout
self.builder = builder
self.user_id = user_id
self.submission_handler = submission_handler
self.command_name = command_name
async def interaction_check(self, interaction: discord.Interaction) -> bool:
"""Check if user has permission to interact with this view."""
@ -54,9 +58,9 @@ class TransactionEmbedView(discord.ui.View):
return
# Create select menu for move removal
select_view = RemoveMoveView(self.builder, self.user_id)
embed = await create_transaction_embed(self.builder)
select_view = RemoveMoveView(self.builder, self.user_id, self.command_name)
embed = await create_transaction_embed(self.builder, self.command_name)
await interaction.response.edit_message(embed=embed, view=select_view)
@discord.ui.button(label="Submit Transaction", style=discord.ButtonStyle.primary, emoji="📤")
@ -83,20 +87,20 @@ class TransactionEmbedView(discord.ui.View):
return
# Show confirmation modal
modal = SubmitConfirmationModal(self.builder)
modal = SubmitConfirmationModal(self.builder, self.submission_handler)
await interaction.response.send_modal(modal)
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.secondary, emoji="")
async def cancel_button(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Handle cancel button click."""
self.builder.clear_moves()
embed = await create_transaction_embed(self.builder)
embed = await create_transaction_embed(self.builder, self.command_name)
# Disable all buttons after cancellation
for item in self.children:
if isinstance(item, discord.ui.Button):
item.disabled = True
await interaction.response.edit_message(
content="❌ **Transaction cancelled and cleared.**",
embed=embed,
@ -107,25 +111,28 @@ class TransactionEmbedView(discord.ui.View):
class RemoveMoveView(discord.ui.View):
"""View for selecting which move to remove."""
def __init__(self, builder: TransactionBuilder, user_id: int):
def __init__(self, builder: TransactionBuilder, user_id: int, command_name: str = "/dropadd"):
super().__init__(timeout=300.0) # 5 minute timeout
self.builder = builder
self.user_id = user_id
self.command_name = command_name
# Create select menu with current moves
if not builder.is_empty:
self.add_item(RemoveMoveSelect(builder))
self.add_item(RemoveMoveSelect(builder, command_name))
# Add back button
back_button = discord.ui.Button(label="Back", style=discord.ButtonStyle.secondary, emoji="⬅️")
back_button.callback = self.back_callback
self.add_item(back_button)
async def back_callback(self, interaction: discord.Interaction):
"""Handle back button to return to main view."""
main_view = TransactionEmbedView(self.builder, self.user_id)
embed = await create_transaction_embed(self.builder)
# Determine submission_handler from command_name
submission_handler = "immediate" if self.command_name == "/ilmove" else "scheduled"
main_view = TransactionEmbedView(self.builder, self.user_id, submission_handler, self.command_name)
embed = await create_transaction_embed(self.builder, self.command_name)
await interaction.response.edit_message(embed=embed, view=main_view)
async def interaction_check(self, interaction: discord.Interaction) -> bool:
@ -135,10 +142,11 @@ class RemoveMoveView(discord.ui.View):
class RemoveMoveSelect(discord.ui.Select):
"""Select menu for choosing which move to remove."""
def __init__(self, builder: TransactionBuilder):
def __init__(self, builder: TransactionBuilder, command_name: str = "/dropadd"):
self.builder = builder
self.command_name = command_name
# Create options from current moves
options = []
for i, move in enumerate(builder.moves[:25]): # Discord limit of 25 options
@ -147,30 +155,32 @@ class RemoveMoveSelect(discord.ui.Select):
description=move.description[:100], # Discord description limit
value=str(move.player.id)
))
super().__init__(
placeholder="Select a move to remove...",
min_values=1,
max_values=1,
options=options
)
async def callback(self, interaction: discord.Interaction):
"""Handle move removal selection."""
player_id = int(self.values[0])
move = self.builder.get_move_for_player(player_id)
if move:
self.builder.remove_move(player_id)
await interaction.response.send_message(
f"✅ Removed: {move.description}",
ephemeral=True
)
# Update the embed
main_view = TransactionEmbedView(self.builder, interaction.user.id)
embed = await create_transaction_embed(self.builder)
# Determine submission_handler from command_name
submission_handler = "immediate" if self.command_name == "/ilmove" else "scheduled"
main_view = TransactionEmbedView(self.builder, interaction.user.id, submission_handler, self.command_name)
embed = await create_transaction_embed(self.builder, self.command_name)
# Edit the original message
await interaction.edit_original_response(embed=embed, view=main_view)
else:
@ -183,10 +193,11 @@ class RemoveMoveSelect(discord.ui.Select):
class SubmitConfirmationModal(discord.ui.Modal):
"""Modal for confirming transaction submission."""
def __init__(self, builder: TransactionBuilder):
def __init__(self, builder: TransactionBuilder, submission_handler: str = "scheduled"):
super().__init__(title="Confirm Transaction Submission")
self.builder = builder
self.submission_handler = submission_handler
self.confirmation = discord.ui.TextInput(
label="Type 'CONFIRM' to submit",
@ -205,54 +216,89 @@ class SubmitConfirmationModal(discord.ui.Modal):
ephemeral=True
)
return
await interaction.response.defer(ephemeral=True)
try:
from services.league_service import LeagueService
from services.league_service import league_service
from services.transaction_service import transaction_service
from services.player_service import player_service
# Get current league state
league_service = LeagueService()
current_state = await league_service.get_current_state()
if not current_state:
await interaction.followup.send(
"❌ Could not get current league state. Please try again later.",
ephemeral=True
)
return
# Submit the transaction (for next week)
transactions = await self.builder.submit_transaction(week=current_state.week + 1)
# Create success message
success_msg = f"✅ **Transaction Submitted Successfully!**\n\n"
success_msg += f"**Move ID:** `{transactions[0].moveid}`\n"
success_msg += f"**Moves:** {len(transactions)}\n"
success_msg += f"**Effective Week:** {transactions[0].week}\n\n"
success_msg += "**Transaction Details:**\n"
for move in self.builder.moves:
success_msg += f"{move.description}\n"
success_msg += f"\n💡 Use `/mymoves` to check transaction status"
await interaction.followup.send(success_msg, ephemeral=True)
if self.submission_handler == "scheduled":
# SCHEDULED SUBMISSION (/dropadd behavior)
# Submit the transaction for NEXT week
transactions = await self.builder.submit_transaction(week=current_state.week + 1)
# Create success message
success_msg = f"✅ **Transaction Submitted Successfully!**\n\n"
success_msg += f"**Move ID:** `{transactions[0].moveid}`\n"
success_msg += f"**Moves:** {len(transactions)}\n"
success_msg += f"**Effective Week:** {transactions[0].week}\n\n"
success_msg += "**Transaction Details:**\n"
for move in self.builder.moves:
success_msg += f"{move.description}\n"
success_msg += f"\n💡 Use `/mymoves` to check transaction status"
await interaction.followup.send(success_msg, ephemeral=True)
elif self.submission_handler == "immediate":
# IMMEDIATE SUBMISSION (/ilmove behavior)
# Submit the transaction for THIS week
transactions = await self.builder.submit_transaction(week=current_state.week)
# POST transactions to database
created_transactions = await transaction_service.create_transaction_batch(transactions)
# Update each player's team assignment
player_updates = []
for txn in created_transactions:
updated_player = await player_service.update_player_team(
txn.player.id,
txn.newteam.id
)
player_updates.append(updated_player)
# Create success message
success_msg = f"✅ **IL Move Executed Successfully!**\n\n"
success_msg += f"**Move ID:** `{created_transactions[0].moveid}`\n"
success_msg += f"**Moves:** {len(created_transactions)}\n"
success_msg += f"**Week:** {created_transactions[0].week} (Current)\n\n"
success_msg += "**Executed Moves:**\n"
for txn in created_transactions:
success_msg += f"{txn.move_description}\n"
success_msg += f"\n✅ **All players have been moved to their new teams immediately**"
await interaction.followup.send(success_msg, ephemeral=True)
# Clear the builder after successful submission
from services.transaction_builder import clear_transaction_builder
clear_transaction_builder(interaction.user.id)
# Update the original embed to show completion
completion_title = "✅ Transaction Submitted" if self.submission_handler == "scheduled" else "✅ IL Move Executed"
completion_embed = discord.Embed(
title="✅ Transaction Submitted",
description=f"Your transaction has been submitted successfully!\n\nMove ID: `{transactions[0].moveid}`",
title=completion_title,
description=f"Your transaction has been processed successfully!",
color=0x00ff00
)
# Disable all buttons
view = discord.ui.View()
try:
# Find and update the original message
async for message in interaction.channel.history(limit=50): # type: ignore
@ -262,7 +308,7 @@ class SubmitConfirmationModal(discord.ui.Modal):
break
except:
pass
except Exception as e:
await interaction.followup.send(
f"❌ Error submitting transaction: {str(e)}",
@ -270,19 +316,26 @@ class SubmitConfirmationModal(discord.ui.Modal):
)
async def create_transaction_embed(builder: TransactionBuilder) -> discord.Embed:
async def create_transaction_embed(builder: TransactionBuilder, command_name: str = "/dropadd") -> discord.Embed:
"""
Create the main transaction builder embed.
Args:
builder: TransactionBuilder instance
command_name: Name of the command to use for adding more moves (default: "/dropadd")
Returns:
Discord embed with current transaction state
"""
# Determine description based on command
if command_name == "/ilmove":
description = "Build your real-time roster move for this week"
else:
description = "Build your transaction for next week"
embed = EmbedTemplate.create_base_embed(
title=f"📋 Transaction Builder - {builder.team.abbrev}",
description=f"Build your transaction for next week",
description=description,
color=EmbedColors.PRIMARY
)
@ -354,7 +407,7 @@ async def create_transaction_embed(builder: TransactionBuilder) -> discord.Embed
# Add instructions for adding more moves
embed.add_field(
name=" Add More Moves",
value="Use `/dropadd` to add more moves",
value=f"Use `{command_name}` to add more moves",
inline=False
)