Merge pull request #1 from calcorum/readme-to-claude-auto
Readme to claude auto
This commit is contained in:
commit
64e60232dd
@ -70,6 +70,7 @@ docs/
|
|||||||
# CI/CD
|
# CI/CD
|
||||||
.github/
|
.github/
|
||||||
.gitlab-ci.yml
|
.gitlab-ci.yml
|
||||||
|
.gitlab/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
237
.gitlab-ci.yml
Normal file
237
.gitlab-ci.yml
Normal 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
536
.gitlab/DEPLOYMENT_SETUP.md
Normal 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
315
.gitlab/QUICK_REFERENCE.md
Normal 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
517
.gitlab/VPS_SCRIPTS.md
Normal 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
13
.mcp.json
Normal 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
49
Dockerfile.versioned
Normal 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"]
|
||||||
@ -7,7 +7,7 @@ Provides connection pooling, proper error handling, and session management.
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, List, Dict, Any, Union
|
from typing import Optional, List, Dict, Any, Union
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin, quote
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from config import get_config
|
from config import get_config
|
||||||
@ -60,26 +60,28 @@ class APIClient:
|
|||||||
'User-Agent': 'SBA-Discord-Bot-v2/1.0'
|
'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.
|
Build complete API URL from components.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
endpoint: API endpoint path
|
endpoint: API endpoint path
|
||||||
api_version: API version number (default: 3)
|
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:
|
Returns:
|
||||||
Complete URL for API request
|
Complete URL for API request
|
||||||
"""
|
"""
|
||||||
# Handle already complete URLs
|
# Handle already complete URLs
|
||||||
if endpoint.startswith(('http://', 'https://')) or '/api/' in endpoint:
|
if endpoint.startswith(('http://', 'https://')) or '/api/' in endpoint:
|
||||||
return endpoint
|
return endpoint
|
||||||
|
|
||||||
path = f"v{api_version}/{endpoint}"
|
path = f"v{api_version}/{endpoint}"
|
||||||
if object_id is not None:
|
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)
|
return urljoin(self.base_url.rstrip('/') + '/', path)
|
||||||
|
|
||||||
def _add_params(self, url: str, params: Optional[List[tuple]] = None) -> str:
|
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")
|
logger.debug("Created new aiohttp session with connection pooling")
|
||||||
|
|
||||||
async def get(
|
async def get(
|
||||||
self,
|
self,
|
||||||
endpoint: str,
|
endpoint: str,
|
||||||
object_id: Optional[int] = None,
|
object_id: Optional[Union[int, str]] = None,
|
||||||
params: Optional[List[tuple]] = None,
|
params: Optional[List[tuple]] = None,
|
||||||
api_version: int = 3,
|
api_version: int = 3,
|
||||||
timeout: Optional[int] = None
|
timeout: Optional[int] = None
|
||||||
@ -251,10 +253,10 @@ class APIClient:
|
|||||||
raise APIException(f"POST failed: {e}")
|
raise APIException(f"POST failed: {e}")
|
||||||
|
|
||||||
async def put(
|
async def put(
|
||||||
self,
|
self,
|
||||||
endpoint: str,
|
endpoint: str,
|
||||||
data: Dict[str, Any],
|
data: Dict[str, Any],
|
||||||
object_id: Optional[int] = None,
|
object_id: Optional[Union[int, str]] = None,
|
||||||
api_version: int = 3,
|
api_version: int = 3,
|
||||||
timeout: Optional[int] = None
|
timeout: Optional[int] = None
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
@ -313,7 +315,7 @@ class APIClient:
|
|||||||
self,
|
self,
|
||||||
endpoint: str,
|
endpoint: str,
|
||||||
data: Optional[Dict[str, Any]] = None,
|
data: Optional[Dict[str, Any]] = None,
|
||||||
object_id: Optional[int] = None,
|
object_id: Optional[Union[int, str]] = None,
|
||||||
api_version: int = 3,
|
api_version: int = 3,
|
||||||
timeout: Optional[int] = None,
|
timeout: Optional[int] = None,
|
||||||
use_query_params: bool = False
|
use_query_params: bool = False
|
||||||
@ -339,13 +341,17 @@ class APIClient:
|
|||||||
|
|
||||||
# Add data as query parameters if requested
|
# Add data as query parameters if requested
|
||||||
if use_query_params and data:
|
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)
|
url = self._add_params(url, params)
|
||||||
|
|
||||||
await self._ensure_session()
|
await self._ensure_session()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.debug(f"PATCH: {endpoint} id: {object_id} data: {data} use_query_params: {use_query_params}")
|
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
|
request_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None
|
||||||
|
|
||||||
@ -353,6 +359,7 @@ class APIClient:
|
|||||||
kwargs = {}
|
kwargs = {}
|
||||||
if data is not None and not use_query_params:
|
if data is not None and not use_query_params:
|
||||||
kwargs['json'] = data
|
kwargs['json'] = data
|
||||||
|
logger.debug(f"PATCH JSON body: {data}")
|
||||||
|
|
||||||
async with self._session.patch(url, timeout=request_timeout, **kwargs) as response:
|
async with self._session.patch(url, timeout=request_timeout, **kwargs) as response:
|
||||||
if response.status == 401:
|
if response.status == 401:
|
||||||
@ -381,9 +388,9 @@ class APIClient:
|
|||||||
raise APIException(f"PATCH failed: {e}")
|
raise APIException(f"PATCH failed: {e}")
|
||||||
|
|
||||||
async def delete(
|
async def delete(
|
||||||
self,
|
self,
|
||||||
endpoint: str,
|
endpoint: str,
|
||||||
object_id: Optional[int] = None,
|
object_id: Optional[Union[int, str]] = None,
|
||||||
api_version: int = 3,
|
api_version: int = 3,
|
||||||
timeout: Optional[int] = None
|
timeout: Optional[int] = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|||||||
22
bot.py
22
bot.py
@ -93,7 +93,7 @@ class SBABot(commands.Bot):
|
|||||||
# Initialize cleanup tasks
|
# Initialize cleanup tasks
|
||||||
await self._setup_background_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()
|
config = get_config()
|
||||||
if config.is_development:
|
if config.is_development:
|
||||||
if await self._should_sync_commands():
|
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")
|
self.logger.info("Development mode: no command changes detected, skipping sync")
|
||||||
else:
|
else:
|
||||||
self.logger.info("Production mode: commands loaded but not auto-synced")
|
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):
|
async def _load_command_packages(self):
|
||||||
"""Load all command packages with resilient error handling."""
|
"""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
|
from tasks.custom_command_cleanup import setup_cleanup_task
|
||||||
self.custom_command_cleanup = setup_cleanup_task(self)
|
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
|
# Initialize voice channel cleanup service
|
||||||
from commands.voice.cleanup_service import VoiceChannelCleanupService
|
from commands.voice.cleanup_service import VoiceChannelCleanupService
|
||||||
self.voice_cleanup_service = VoiceChannelCleanupService()
|
self.voice_cleanup_service = VoiceChannelCleanupService()
|
||||||
@ -285,7 +290,7 @@ class SBABot(commands.Bot):
|
|||||||
async def _sync_commands(self):
|
async def _sync_commands(self):
|
||||||
"""Internal method to sync commands."""
|
"""Internal method to sync commands."""
|
||||||
config = get_config()
|
config = get_config()
|
||||||
if config.guild_id:
|
if config.testing and config.guild_id:
|
||||||
guild = discord.Object(id=config.guild_id)
|
guild = discord.Object(id=config.guild_id)
|
||||||
self.tree.copy_global_to(guild=guild)
|
self.tree.copy_global_to(guild=guild)
|
||||||
synced = await self.tree.sync(guild=guild)
|
synced = await self.tree.sync(guild=guild)
|
||||||
@ -313,7 +318,7 @@ class SBABot(commands.Bot):
|
|||||||
async def close(self):
|
async def close(self):
|
||||||
"""Clean shutdown of the bot."""
|
"""Clean shutdown of the bot."""
|
||||||
self.logger.info("Bot shutting down...")
|
self.logger.info("Bot shutting down...")
|
||||||
|
|
||||||
# Stop background tasks
|
# Stop background tasks
|
||||||
if hasattr(self, 'custom_command_cleanup'):
|
if hasattr(self, 'custom_command_cleanup'):
|
||||||
try:
|
try:
|
||||||
@ -322,13 +327,20 @@ class SBABot(commands.Bot):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error stopping cleanup task: {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'):
|
if hasattr(self, 'voice_cleanup_service'):
|
||||||
try:
|
try:
|
||||||
self.voice_cleanup_service.stop_monitoring()
|
self.voice_cleanup_service.stop_monitoring()
|
||||||
self.logger.info("Voice channel cleanup service stopped")
|
self.logger.info("Voice channel cleanup service stopped")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error stopping voice cleanup service: {e}")
|
self.logger.error(f"Error stopping voice cleanup service: {e}")
|
||||||
|
|
||||||
# Call parent close method
|
# Call parent close method
|
||||||
await super().close()
|
await super().close()
|
||||||
self.logger.info("Bot shutdown complete")
|
self.logger.info("Bot shutdown complete")
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -11,6 +11,7 @@ from discord.ext import commands
|
|||||||
|
|
||||||
from .management import AdminCommands
|
from .management import AdminCommands
|
||||||
from .users import UserManagementCommands
|
from .users import UserManagementCommands
|
||||||
|
from .league_management import LeagueManagementCommands
|
||||||
|
|
||||||
logger = logging.getLogger(f'{__name__}.setup_admin')
|
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]]] = [
|
admin_cogs: List[Tuple[str, Type[commands.Cog]]] = [
|
||||||
("AdminCommands", AdminCommands),
|
("AdminCommands", AdminCommands),
|
||||||
("UserManagementCommands", UserManagementCommands),
|
("UserManagementCommands", UserManagementCommands),
|
||||||
|
("LeagueManagementCommands", LeagueManagementCommands),
|
||||||
]
|
]
|
||||||
|
|
||||||
successful = 0
|
successful = 0
|
||||||
|
|||||||
560
commands/admin/league_management.py
Normal file
560
commands/admin/league_management.py
Normal 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))
|
||||||
@ -3,7 +3,6 @@ Admin Management Commands
|
|||||||
|
|
||||||
Administrative commands for league management and bot maintenance.
|
Administrative commands for league management and bot maintenance.
|
||||||
"""
|
"""
|
||||||
from typing import Optional, Union
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
@ -25,6 +24,14 @@ class AdminCommands(commands.Cog):
|
|||||||
|
|
||||||
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
||||||
"""Check if user has admin permissions."""
|
"""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:
|
if not interaction.user.guild_permissions.administrator:
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
"❌ You need administrator permissions to use admin commands.",
|
"❌ You need administrator permissions to use admin commands.",
|
||||||
@ -196,20 +203,64 @@ class AdminCommands(commands.Cog):
|
|||||||
name="admin-sync",
|
name="admin-sync",
|
||||||
description="Sync application commands with Discord"
|
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")
|
@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."""
|
"""Sync slash commands with Discord API."""
|
||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
|
|
||||||
try:
|
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(
|
embed = EmbedTemplate.create_base_embed(
|
||||||
title="✅ Commands Synced Successfully",
|
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
|
color=EmbedColors.SUCCESS
|
||||||
)
|
)
|
||||||
|
|
||||||
# Show some of the synced commands
|
# Show some of the synced commands
|
||||||
command_names = [cmd.name for cmd in synced_commands[:10]]
|
command_names = [cmd.name for cmd in synced_commands[:10]]
|
||||||
embed.add_field(
|
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 ""),
|
(f"\n... and {len(synced_commands) - 10} more" if len(synced_commands) > 10 else ""),
|
||||||
inline=False
|
inline=False
|
||||||
)
|
)
|
||||||
|
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="Sync Details",
|
name="Sync Details",
|
||||||
value=f"**Total Commands:** {len(synced_commands)}\n"
|
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')}",
|
f"**Time:** {discord.utils.utcnow().strftime('%H:%M:%S UTC')}",
|
||||||
inline=False
|
inline=False
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
self.logger.error(f"Sync failed: {e}", exc_info=True)
|
||||||
embed = EmbedTemplate.create_base_embed(
|
embed = EmbedTemplate.create_base_embed(
|
||||||
title="❌ Sync Failed",
|
title="❌ Sync Failed",
|
||||||
description=f"Failed to sync commands: {str(e)}",
|
description=f"Failed to sync commands: {str(e)}",
|
||||||
color=EmbedColors.ERROR
|
color=EmbedColors.ERROR
|
||||||
)
|
)
|
||||||
|
|
||||||
await interaction.followup.send(embed=embed)
|
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(
|
@app_commands.command(
|
||||||
name="admin-clear",
|
name="admin-clear",
|
||||||
@ -254,7 +355,15 @@ class AdminCommands(commands.Cog):
|
|||||||
return
|
return
|
||||||
|
|
||||||
await interaction.response.defer()
|
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:
|
try:
|
||||||
deleted = await interaction.channel.purge(limit=count)
|
deleted = await interaction.channel.purge(limit=count)
|
||||||
|
|
||||||
@ -276,10 +385,11 @@ class AdminCommands(commands.Cog):
|
|||||||
# Send confirmation and auto-delete after 5 seconds
|
# Send confirmation and auto-delete after 5 seconds
|
||||||
message = await interaction.followup.send(embed=embed)
|
message = await interaction.followup.send(embed=embed)
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
try:
|
if message:
|
||||||
await message.delete()
|
try:
|
||||||
except discord.NotFound:
|
await message.delete()
|
||||||
pass # Message already deleted
|
except discord.NotFound:
|
||||||
|
pass # Message already deleted
|
||||||
|
|
||||||
except discord.Forbidden:
|
except discord.Forbidden:
|
||||||
await interaction.followup.send(
|
await interaction.followup.send(
|
||||||
@ -320,10 +430,12 @@ class AdminCommands(commands.Cog):
|
|||||||
text=f"Announcement by {interaction.user.display_name}",
|
text=f"Announcement by {interaction.user.display_name}",
|
||||||
icon_url=interaction.user.display_avatar.url
|
icon_url=interaction.user.display_avatar.url
|
||||||
)
|
)
|
||||||
|
|
||||||
content = "@everyone" if mention_everyone else None
|
# Send with or without mention based on flag
|
||||||
|
if mention_everyone:
|
||||||
await interaction.followup.send(content=content, embed=embed)
|
await interaction.followup.send(content="@everyone", embed=embed)
|
||||||
|
else:
|
||||||
|
await interaction.followup.send(embed=embed)
|
||||||
|
|
||||||
# Log the announcement
|
# Log the announcement
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
|
|||||||
@ -11,8 +11,12 @@ from dataclasses import dataclass
|
|||||||
import discord
|
import discord
|
||||||
from discord.ext import commands
|
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.logging import get_contextual_logger
|
||||||
from utils.decorators import logged_command
|
from utils.decorators import logged_command
|
||||||
|
from utils.team_utils import get_user_major_league_team
|
||||||
from views.embeds import EmbedColors, EmbedTemplate
|
from views.embeds import EmbedColors, EmbedTemplate
|
||||||
|
|
||||||
|
|
||||||
@ -93,13 +97,20 @@ class DiceRollCommands(commands.Cog):
|
|||||||
async def ab_dice(self, interaction: discord.Interaction):
|
async def ab_dice(self, interaction: discord.Interaction):
|
||||||
"""Roll the standard baseball at-bat dice combination."""
|
"""Roll the standard baseball at-bat dice combination."""
|
||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
|
embed_color = await self._get_channel_embed_color(interaction)
|
||||||
|
|
||||||
# Use the standard baseball dice combination
|
# Use the standard baseball dice combination
|
||||||
dice_notation = "1d6;2d6;1d20"
|
dice_notation = "1d6;2d6;1d20"
|
||||||
roll_results = self._parse_and_roll_multiple_dice(dice_notation)
|
roll_results = self._parse_and_roll_multiple_dice(dice_notation)
|
||||||
|
|
||||||
# Create embed for the roll results
|
# 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}'
|
embed.title = f'At bat roll for {interaction.user.display_name}'
|
||||||
await interaction.followup.send(embed=embed)
|
await interaction.followup.send(embed=embed)
|
||||||
|
|
||||||
@ -107,6 +118,10 @@ class DiceRollCommands(commands.Cog):
|
|||||||
async def ab_dice_prefix(self, ctx: commands.Context):
|
async def ab_dice_prefix(self, ctx: commands.Context):
|
||||||
"""Roll baseball at-bat dice using prefix commands (!ab, !atbat)."""
|
"""Roll baseball at-bat dice using prefix commands (!ab, !atbat)."""
|
||||||
self.logger.info(f"At Bat dice command started by {ctx.author.display_name}")
|
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
|
# Use the standard baseball dice combination
|
||||||
dice_notation = "1d6;2d6;1d20"
|
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))
|
self.logger.info("At Bat dice rolled successfully", roll_count=len(roll_results))
|
||||||
|
|
||||||
# Create embed for the 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}'
|
embed.title = f'At bat roll for {ctx.author.display_name}'
|
||||||
await ctx.send(embed=embed)
|
await ctx.send(embed=embed)
|
||||||
|
|
||||||
@ -158,6 +173,7 @@ class DiceRollCommands(commands.Cog):
|
|||||||
position="Defensive position"
|
position="Defensive position"
|
||||||
)
|
)
|
||||||
@discord.app_commands.choices(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="Catcher (C)", value="C"),
|
||||||
discord.app_commands.Choice(name="First Base (1B)", value="1B"),
|
discord.app_commands.Choice(name="First Base (1B)", value="1B"),
|
||||||
discord.app_commands.Choice(name="Second Base (2B)", value="2B"),
|
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)
|
embed = self._create_fielding_embed(parsed_position, roll_results, ctx.author)
|
||||||
await ctx.send(embed=embed)
|
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:
|
def _parse_position(self, position: str) -> str | None:
|
||||||
"""Parse and validate fielding position input for prefix commands."""
|
"""Parse and validate fielding position input for prefix commands."""
|
||||||
if not position:
|
if not position:
|
||||||
@ -221,6 +313,7 @@ class DiceRollCommands(commands.Cog):
|
|||||||
|
|
||||||
# Map common inputs to standard position names
|
# Map common inputs to standard position names
|
||||||
position_map = {
|
position_map = {
|
||||||
|
'P': 'P', 'PITCHER': 'P',
|
||||||
'C': 'C', 'CATCHER': 'C',
|
'C': 'C', 'CATCHER': 'C',
|
||||||
'1': '1B', '1B': '1B', 'FIRST': '1B', 'FIRSTBASE': '1B',
|
'1': '1B', '1B': '1B', 'FIRST': '1B', 'FIRSTBASE': '1B',
|
||||||
'2': '2B', '2B': '2B', 'SECOND': '2B', 'SECONDBASE': '2B',
|
'2': '2B', '2B': '2B', 'SECOND': '2B', 'SECONDBASE': '2B',
|
||||||
@ -266,8 +359,8 @@ class DiceRollCommands(commands.Cog):
|
|||||||
# Add fielding check summary
|
# Add fielding check summary
|
||||||
range_result = self._get_range_result(position, d20_result)
|
range_result = self._get_range_result(position, d20_result)
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name=f"{position} Fielding Check Summary",
|
name=f"{position} Range Result",
|
||||||
value=f"```\nRange Result\n 1 | 2 | 3 | 4 | 5\n{range_result}```",
|
value=f"```md\n 1 | 2 | 3 | 4 | 5\n{range_result}```",
|
||||||
inline=False
|
inline=False
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -280,26 +373,87 @@ class DiceRollCommands(commands.Cog):
|
|||||||
inline=False
|
inline=False
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add help commands
|
# # Add help commands
|
||||||
embed.add_field(
|
# embed.add_field(
|
||||||
name="Help Commands",
|
# name="Help Commands",
|
||||||
value="Run !<result> for full chart readout (e.g. !g1 or !do3)",
|
# value="Run !<result> for full chart readout (e.g. !g1 or !do3)",
|
||||||
inline=False
|
# 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
|
if show_author:
|
||||||
embed.add_field(
|
# Set user info
|
||||||
name="References",
|
embed.set_author(
|
||||||
value="Range Chart / Error Chart / Result Reference",
|
name=user.name,
|
||||||
inline=False
|
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
|
return embed
|
||||||
|
|
||||||
def _get_range_result(self, position: str, d20_roll: int) -> str:
|
def _get_range_result(self, position: str, d20_roll: int) -> str:
|
||||||
"""Get the range result display for a position and d20 roll."""
|
"""Get the range result display for a position and d20 roll."""
|
||||||
# Infield positions share the same range chart
|
if position == 'P':
|
||||||
if position in ['1B', '2B', '3B', 'SS']:
|
return self._get_pitcher_range(d20_roll)
|
||||||
|
elif position in ['1B', '2B', '3B', 'SS']:
|
||||||
return self._get_infield_range(d20_roll)
|
return self._get_infield_range(d20_roll)
|
||||||
elif position in ['LF', 'CF', 'RF']:
|
elif position in ['LF', 'CF', 'RF']:
|
||||||
return self._get_outfield_range(d20_roll)
|
return self._get_outfield_range(d20_roll)
|
||||||
@ -385,10 +539,38 @@ class DiceRollCommands(commands.Cog):
|
|||||||
}
|
}
|
||||||
return catcher_ranges.get(d20_roll, 'Unknown')
|
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:
|
def _get_error_result(self, position: str, d6_total: int) -> str:
|
||||||
"""Get the error result for a position and 3d6 total."""
|
"""Get the error result for a position and 3d6 total."""
|
||||||
# Get the appropriate error chart
|
# 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)
|
return self._get_1b_error(d6_total)
|
||||||
elif position == '2B':
|
elif position == '2B':
|
||||||
return self._get_2b_error(d6_total)
|
return self._get_2b_error(d6_total)
|
||||||
@ -560,6 +742,28 @@ class DiceRollCommands(commands.Cog):
|
|||||||
}
|
}
|
||||||
return errors.get(d6_total, 'No error')
|
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]:
|
def _parse_and_roll_multiple_dice(self, dice_notation: str) -> list[DiceRoll]:
|
||||||
"""Parse dice notation (supports multiple rolls) and return roll results."""
|
"""Parse dice notation (supports multiple rolls) and return roll results."""
|
||||||
# Split by semicolon for multiple rolls
|
# Split by semicolon for multiple rolls
|
||||||
@ -637,11 +841,11 @@ class DiceRollCommands(commands.Cog):
|
|||||||
|
|
||||||
return [first_d6_result, second_result, third_result]
|
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."""
|
"""Create an embed for multiple dice roll results."""
|
||||||
embed = EmbedTemplate.create_base_embed(
|
embed = EmbedTemplate.create_base_embed(
|
||||||
title="🎲 Dice Roll",
|
title="🎲 Dice Roll",
|
||||||
color=EmbedColors.PRIMARY
|
color=embed_color
|
||||||
)
|
)
|
||||||
|
|
||||||
if set_author:
|
if set_author:
|
||||||
|
|||||||
@ -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`
|
|
||||||
@ -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
|
|
||||||
@ -17,14 +17,21 @@ from discord import app_commands
|
|||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
from config import get_config
|
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.player_service import player_service
|
||||||
from services.injury_service import injury_service
|
from services.injury_service import injury_service
|
||||||
from services.league_service import league_service
|
from services.league_service import league_service
|
||||||
from services.giphy_service import GiphyService
|
from services.giphy_service import GiphyService
|
||||||
|
from utils import team_utils
|
||||||
from utils.logging import get_contextual_logger
|
from utils.logging import get_contextual_logger
|
||||||
from utils.decorators import logged_command
|
from utils.decorators import logged_command
|
||||||
from utils.autocomplete import player_autocomplete
|
from utils.autocomplete import player_autocomplete
|
||||||
|
from views.base import ConfirmationView
|
||||||
from views.embeds import EmbedTemplate
|
from views.embeds import EmbedTemplate
|
||||||
|
from views.modals import PitcherRestModal, BatterInjuryModal
|
||||||
from exceptions import BotException
|
from exceptions import BotException
|
||||||
|
|
||||||
|
|
||||||
@ -77,6 +84,16 @@ class InjuryGroup(app_commands.Group):
|
|||||||
|
|
||||||
player = players[0]
|
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
|
# Check for injury_rating field
|
||||||
if not player.injury_rating:
|
if not player.injury_rating:
|
||||||
embed = EmbedTemplate.error(
|
embed = EmbedTemplate.error(
|
||||||
@ -145,16 +162,55 @@ class InjuryGroup(app_commands.Group):
|
|||||||
inline=False
|
inline=False
|
||||||
)
|
)
|
||||||
|
|
||||||
# Format result
|
view = None
|
||||||
|
|
||||||
|
# Format result and create callbacks for confirmation
|
||||||
if isinstance(injury_result, int):
|
if isinstance(injury_result, int):
|
||||||
result_text = f"**{injury_result} game{'s' if injury_result > 1 else ''}**"
|
result_text = f"**{injury_result} game{'s' if injury_result > 1 else ''}**"
|
||||||
embed.color = discord.Color.orange()
|
embed.color = discord.Color.orange()
|
||||||
|
|
||||||
if injury_result > 6:
|
if injury_result > 6:
|
||||||
gif_search_text = ['well shit', 'well fuck', 'god dammit']
|
gif_search_text = ['well shit', 'well fuck', 'god dammit']
|
||||||
else:
|
else:
|
||||||
gif_search_text = ['bummer', 'well damn']
|
gif_search_text = ['bummer', 'well damn']
|
||||||
|
|
||||||
if player.is_pitcher:
|
if player.is_pitcher:
|
||||||
result_text += ' plus their current rest requirement'
|
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':
|
elif injury_result == 'REM':
|
||||||
if player.is_pitcher:
|
if player.is_pitcher:
|
||||||
result_text = '**FATIGUED**'
|
result_text = '**FATIGUED**'
|
||||||
@ -167,24 +223,27 @@ class InjuryGroup(app_commands.Group):
|
|||||||
embed.color = discord.Color.green()
|
embed.color = discord.Color.green()
|
||||||
gif_search_text = ['we are so back', 'all good', 'totally fine']
|
gif_search_text = ['we are so back', 'all good', 'totally fine']
|
||||||
|
|
||||||
# embed.add_field(name='', value='', inline=False)
|
|
||||||
|
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="Injury Length",
|
name="Injury Length",
|
||||||
value=result_text,
|
value=result_text,
|
||||||
inline=True
|
inline=True
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
injury_gif = await GiphyService().get_gif(
|
injury_gif = await GiphyService().get_gif(
|
||||||
phrase_options=gif_search_text
|
phrase_options=gif_search_text
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
injury_gif = ''
|
injury_gif = ''
|
||||||
|
|
||||||
embed.set_image(url=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):
|
def _get_injury_result(self, rating: str, games_played: int, roll: int):
|
||||||
"""
|
"""
|
||||||
@ -397,7 +456,7 @@ class InjuryGroup(app_commands.Group):
|
|||||||
# Success response
|
# Success response
|
||||||
embed = EmbedTemplate.success(
|
embed = EmbedTemplate.success(
|
||||||
title="Injury Recorded",
|
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(
|
embed.add_field(
|
||||||
@ -434,9 +493,46 @@ class InjuryGroup(app_commands.Group):
|
|||||||
season=current.season,
|
season=current.season,
|
||||||
injury_id=injury.id
|
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.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.describe(player_name="Player name to clear injury")
|
||||||
|
@app_commands.autocomplete(player_name=player_autocomplete)
|
||||||
@logged_command("/injury clear")
|
@logged_command("/injury clear")
|
||||||
async def injury_clear(self, interaction: discord.Interaction, player_name: str):
|
async def injury_clear(self, interaction: discord.Interaction, player_name: str):
|
||||||
"""Clear a player's active injury."""
|
"""Clear a player's active injury."""
|
||||||
@ -480,35 +576,18 @@ class InjuryGroup(app_commands.Group):
|
|||||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Clear the injury
|
# Create confirmation embed
|
||||||
success = await injury_service.clear_injury(injury.id)
|
embed = EmbedTemplate.info(
|
||||||
|
title=f"{player.name}",
|
||||||
if not success:
|
description=f"Is **{player.name}** cleared to return?"
|
||||||
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."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
embed.add_field(
|
if player.team and player.team.thumbnail is not None:
|
||||||
name="Previous Return Date",
|
embed.set_thumbnail(url=player.team.thumbnail)
|
||||||
value=injury.return_date,
|
|
||||||
inline=True
|
|
||||||
)
|
|
||||||
|
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="Total Games Missed",
|
name="Player",
|
||||||
value=injury.duration_display,
|
value=f"{player.name} ({player.primary_position})",
|
||||||
inline=True
|
inline=True
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -516,19 +595,91 @@ class InjuryGroup(app_commands.Group):
|
|||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="Team",
|
name="Team",
|
||||||
value=f"{player.team.lname} ({player.team.abbrev})",
|
value=f"{player.team.lname} ({player.team.abbrev})",
|
||||||
inline=False
|
inline=True
|
||||||
)
|
)
|
||||||
|
|
||||||
await interaction.followup.send(embed=embed)
|
embed.add_field(
|
||||||
|
name="Expected Return",
|
||||||
# Log for debugging
|
value=injury.return_date,
|
||||||
self.logger.info(
|
inline=True
|
||||||
f"Injury cleared for {player.name}",
|
|
||||||
player_id=player.id,
|
|
||||||
season=current.season,
|
|
||||||
injury_id=injury.id
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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):
|
async def setup(bot: commands.Bot):
|
||||||
"""Setup function for loading the injury commands."""
|
"""Setup function for loading the injury commands."""
|
||||||
|
|||||||
@ -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`
|
|
||||||
@ -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
|
|
||||||
@ -14,8 +14,7 @@ from services.player_service import player_service
|
|||||||
from services.stats_service import stats_service
|
from services.stats_service import stats_service
|
||||||
from utils.logging import get_contextual_logger
|
from utils.logging import get_contextual_logger
|
||||||
from utils.decorators import logged_command
|
from utils.decorators import logged_command
|
||||||
from views.embeds import EmbedColors, EmbedTemplate
|
from views.players import PlayerStatsView
|
||||||
from models.team import RosterType
|
|
||||||
|
|
||||||
|
|
||||||
async def player_name_autocomplete(
|
async def player_name_autocomplete(
|
||||||
@ -155,176 +154,21 @@ class PlayerInfoCommands(commands.Cog):
|
|||||||
batting_stats=bool(batting_stats),
|
batting_stats=bool(batting_stats),
|
||||||
pitching_stats=bool(pitching_stats))
|
pitching_stats=bool(pitching_stats))
|
||||||
|
|
||||||
# Create comprehensive player embed with statistics
|
# Create interactive player view with toggleable statistics
|
||||||
self.logger.debug("Creating Discord embed with statistics")
|
self.logger.debug("Creating PlayerStatsView with toggleable statistics")
|
||||||
embed = await self._create_player_embed_with_stats(
|
view = PlayerStatsView(
|
||||||
player_with_team,
|
player=player_with_team,
|
||||||
search_season,
|
season=search_season,
|
||||||
batting_stats,
|
batting_stats=batting_stats,
|
||||||
pitching_stats
|
pitching_stats=pitching_stats,
|
||||||
)
|
user_id=interaction.user.id
|
||||||
|
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
embed.add_field(
|
# Get initial embed with stats hidden
|
||||||
name="Player ID",
|
embed = await view.get_initial_embed()
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add injury rating if available
|
# Send with interactive view
|
||||||
if player.injury_rating:
|
await interaction.followup.send(embed=embed, view=view)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def setup(bot: commands.Bot):
|
async def setup(bot: commands.Bot):
|
||||||
|
|||||||
@ -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`
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -12,6 +12,7 @@ from discord.ext import commands
|
|||||||
from .management import TransactionCommands
|
from .management import TransactionCommands
|
||||||
from .dropadd import DropAddCommands
|
from .dropadd import DropAddCommands
|
||||||
from .trade import TradeCommands
|
from .trade import TradeCommands
|
||||||
|
from .ilmove import ILMoveCommands
|
||||||
|
|
||||||
logger = logging.getLogger(f'{__name__}.setup_transactions')
|
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),
|
("TransactionCommands", TransactionCommands),
|
||||||
("DropAddCommands", DropAddCommands),
|
("DropAddCommands", DropAddCommands),
|
||||||
("TradeCommands", TradeCommands),
|
("TradeCommands", TradeCommands),
|
||||||
|
("ILMoveCommands", ILMoveCommands),
|
||||||
]
|
]
|
||||||
|
|
||||||
successful = 0
|
successful = 0
|
||||||
|
|||||||
@ -74,8 +74,8 @@ class DropAddCommands(commands.Cog):
|
|||||||
|
|
||||||
if success:
|
if success:
|
||||||
# Move added successfully - show updated transaction builder
|
# Move added successfully - show updated transaction builder
|
||||||
embed = await create_transaction_embed(builder)
|
embed = await create_transaction_embed(builder, command_name="/dropadd")
|
||||||
view = TransactionEmbedView(builder, interaction.user.id)
|
view = TransactionEmbedView(builder, interaction.user.id, submission_handler="scheduled", command_name="/dropadd")
|
||||||
|
|
||||||
success_msg = f"✅ **Added {player} → {destination.upper()}**"
|
success_msg = f"✅ **Added {player} → {destination.upper()}**"
|
||||||
if builder.move_count > 1:
|
if builder.move_count > 1:
|
||||||
@ -91,8 +91,8 @@ class DropAddCommands(commands.Cog):
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
# Failed to add move - still show current transaction state
|
# Failed to add move - still show current transaction state
|
||||||
embed = await create_transaction_embed(builder)
|
embed = await create_transaction_embed(builder, command_name="/dropadd")
|
||||||
view = TransactionEmbedView(builder, interaction.user.id)
|
view = TransactionEmbedView(builder, interaction.user.id, submission_handler="scheduled", command_name="/dropadd")
|
||||||
|
|
||||||
await interaction.followup.send(
|
await interaction.followup.send(
|
||||||
content=f"❌ **{error_message}**\n"
|
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}")
|
self.logger.warning(f"Failed to add move: {player} → {destination}: {error_message}")
|
||||||
else:
|
else:
|
||||||
# No parameters or incomplete parameters - show current transaction state
|
# No parameters or incomplete parameters - show current transaction state
|
||||||
embed = await create_transaction_embed(builder)
|
embed = await create_transaction_embed(builder, command_name="/dropadd")
|
||||||
view = TransactionEmbedView(builder, interaction.user.id)
|
view = TransactionEmbedView(builder, interaction.user.id, submission_handler="scheduled", command_name="/dropadd")
|
||||||
await interaction.followup.send(embed=embed, view=view, ephemeral=True)
|
await interaction.followup.send(embed=embed, view=view, ephemeral=True)
|
||||||
|
|
||||||
async def _add_quick_move(
|
async def _add_quick_move(
|
||||||
|
|||||||
242
commands/transactions/ilmove.py
Normal file
242
commands/transactions/ilmove.py
Normal 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))
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -224,12 +224,17 @@ class VoiceChannelCommands(commands.Cog):
|
|||||||
|
|
||||||
# Find opponent from current week's schedule
|
# Find opponent from current week's schedule
|
||||||
try:
|
try:
|
||||||
team_games = await self.schedule_service.get_team_schedule(
|
# Get all games for the current week
|
||||||
current_season, user_team.abbrev, weeks=1
|
week_games = await self.schedule_service.get_week_schedule(current_season, current_week)
|
||||||
)
|
|
||||||
|
|
||||||
current_week_games = [g for g in team_games
|
# Filter for games involving this team that haven't been completed
|
||||||
if g.week == current_week and not g.is_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:
|
if not current_week_games:
|
||||||
embed = EmbedTemplate.warning(
|
embed = EmbedTemplate.warning(
|
||||||
|
|||||||
@ -33,6 +33,7 @@ class BotConfig(BaseSettings):
|
|||||||
weeks_per_season: int = 18
|
weeks_per_season: int = 18
|
||||||
games_per_week: int = 4
|
games_per_week: int = 4
|
||||||
modern_stats_start_season: int = 8
|
modern_stats_start_season: int = 8
|
||||||
|
offseason_flag: bool = False # When True, relaxes roster limits and disables weekly freeze/thaw
|
||||||
|
|
||||||
# Current Season Constants
|
# Current Season Constants
|
||||||
sba_current_season: int = 12
|
sba_current_season: int = 12
|
||||||
@ -45,7 +46,7 @@ class BotConfig(BaseSettings):
|
|||||||
|
|
||||||
# Draft Constants
|
# Draft Constants
|
||||||
default_pick_minutes: int = 10
|
default_pick_minutes: int = 10
|
||||||
draft_rounds: int = 25
|
draft_rounds: int = 32
|
||||||
|
|
||||||
# Special Team IDs
|
# Special Team IDs
|
||||||
free_agent_team_id: int = 498
|
free_agent_team_id: int = 498
|
||||||
@ -63,7 +64,7 @@ class BotConfig(BaseSettings):
|
|||||||
# Application settings
|
# Application settings
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
environment: str = "development"
|
environment: str = "development"
|
||||||
testing: bool = False
|
testing: bool = True
|
||||||
|
|
||||||
# Google Sheets settings
|
# Google Sheets settings
|
||||||
sheets_credentials_path: str = "/app/data/major-domo-service-creds.json"
|
sheets_credentials_path: str = "/app/data/major-domo-service-creds.json"
|
||||||
|
|||||||
657
models/README.md
657
models/README.md
@ -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
|
|
||||||
@ -27,7 +27,7 @@ class CustomCommand(SBABaseModel):
|
|||||||
id: int = Field(..., description="Database ID") # type: ignore
|
id: int = Field(..., description="Database ID") # type: ignore
|
||||||
name: str = Field(..., description="Command name (unique)")
|
name: str = Field(..., description="Command name (unique)")
|
||||||
content: str = Field(..., description="Command response content")
|
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")
|
creator: Optional[CustomCommandCreator] = Field(None, description="Creator details")
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
|
|||||||
@ -3,8 +3,8 @@ Injury model for tracking player injuries
|
|||||||
|
|
||||||
Represents an injury record with game timeline and status information.
|
Represents an injury record with game timeline and status information.
|
||||||
"""
|
"""
|
||||||
from typing import Optional
|
from typing import Optional, Any, Dict
|
||||||
from pydantic import Field
|
from pydantic import Field, model_validator
|
||||||
|
|
||||||
from models.base import SBABaseModel
|
from models.base import SBABaseModel
|
||||||
|
|
||||||
@ -19,6 +19,26 @@ class Injury(SBABaseModel):
|
|||||||
player_id: int = Field(..., description="Player ID who is injured")
|
player_id: int = Field(..., description="Player ID who is injured")
|
||||||
total_games: int = Field(..., description="Total games player will be out")
|
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
|
# Injury timeline
|
||||||
start_week: int = Field(..., description="Week injury started")
|
start_week: int = Field(..., description="Week injury started")
|
||||||
start_game: int = Field(..., description="Game number injury started (1-4)")
|
start_game: int = Field(..., description="Game number injury started (1-4)")
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -155,13 +155,22 @@ class InjuryService(BaseService[Injury]):
|
|||||||
'is_active': True
|
'is_active': True
|
||||||
}
|
}
|
||||||
|
|
||||||
injury = await self.create(injury_data)
|
# Call the API to create the injury
|
||||||
if injury:
|
client = await self.get_client()
|
||||||
logger.info(f"Created injury for player {player_id}: {total_games} games")
|
response = await client.post(self.endpoint, injury_data)
|
||||||
return injury
|
|
||||||
|
|
||||||
logger.error(f"Failed to create injury for player {player_id}")
|
if not response:
|
||||||
return None
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error creating injury for player {player_id}: {e}")
|
logger.error(f"Error creating injury for player {player_id}: {e}")
|
||||||
|
|||||||
@ -32,26 +32,76 @@ class LeagueService(BaseService[Current]):
|
|||||||
async def get_current_state(self) -> Optional[Current]:
|
async def get_current_state(self) -> Optional[Current]:
|
||||||
"""
|
"""
|
||||||
Get the current league state including week, season, and settings.
|
Get the current league state including week, season, and settings.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Current league state or None if not available
|
Current league state or None if not available
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
client = await self.get_client()
|
client = await self.get_client()
|
||||||
data = await client.get('current')
|
data = await client.get('current')
|
||||||
|
|
||||||
if data:
|
if data:
|
||||||
current = Current.from_api_data(data)
|
current = Current.from_api_data(data)
|
||||||
logger.debug(f"Retrieved current state: Week {current.week}, Season {current.season}")
|
logger.debug(f"Retrieved current state: Week {current.week}, Season {current.season}")
|
||||||
return current
|
return current
|
||||||
|
|
||||||
logger.debug("No current state data found")
|
logger.debug("No current state data found")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get current league state: {e}")
|
logger.error(f"Failed to get current league state: {e}")
|
||||||
return None
|
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]]]:
|
async def get_standings(self, season: Optional[int] = None) -> Optional[List[Dict[str, Any]]]:
|
||||||
"""
|
"""
|
||||||
Get league standings for a season.
|
Get league standings for a season.
|
||||||
|
|||||||
@ -298,6 +298,38 @@ class PlayerService(BaseService[Player]):
|
|||||||
logger.error(f"Failed to update player {player_id}: {e}")
|
logger.error(f"Failed to update player {player_id}: {e}")
|
||||||
return None
|
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
|
# Global service instance - will be properly initialized in __init__.py
|
||||||
player_service = PlayerService()
|
player_service = PlayerService()
|
||||||
@ -380,7 +380,7 @@ class TransactionBuilder:
|
|||||||
ml_limit = 26
|
ml_limit = 26
|
||||||
mil_limit = 6
|
mil_limit = 6
|
||||||
else:
|
else:
|
||||||
ml_limit = 25
|
ml_limit = 26
|
||||||
mil_limit = 14
|
mil_limit = 14
|
||||||
|
|
||||||
# Validate roster limits
|
# Validate roster limits
|
||||||
@ -452,16 +452,35 @@ class TransactionBuilder:
|
|||||||
|
|
||||||
for move in self.moves:
|
for move in self.moves:
|
||||||
# Determine old and new teams based on roster locations
|
# 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:
|
if move.from_roster == RosterType.FREE_AGENCY:
|
||||||
old_team = fa_team
|
old_team = fa_team
|
||||||
else:
|
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:
|
if move.to_roster == RosterType.FREE_AGENCY:
|
||||||
new_team = fa_team
|
new_team = fa_team
|
||||||
else:
|
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
|
# For cases where we don't have specific teams, fall back to defaults
|
||||||
if not old_team:
|
if not old_team:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@ -187,44 +187,203 @@ class TransactionService(BaseService[Transaction]):
|
|||||||
is_legal=False,
|
is_legal=False,
|
||||||
errors=[f"Validation error: {str(e)}"]
|
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:
|
async def cancel_transaction(self, transaction_id: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Cancel a pending transaction.
|
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:
|
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:
|
Returns:
|
||||||
True if cancelled successfully
|
True if cancelled successfully
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
transaction = await self.get_by_id(transaction_id)
|
# Update transaction status using direct API call to handle bulk updates
|
||||||
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_data = {
|
update_data = {
|
||||||
'cancelled': True,
|
'cancelled': True,
|
||||||
'cancelled_at': datetime.now(UTC).isoformat()
|
'cancelled_at': datetime.now(UTC).isoformat()
|
||||||
}
|
}
|
||||||
|
|
||||||
updated_transaction = await self.update(transaction_id, update_data)
|
# Call API directly since bulk update returns a message string, not a Transaction object
|
||||||
|
client = await self.get_client()
|
||||||
if updated_transaction:
|
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}")
|
logger.info(f"Cancelled transaction {transaction_id}")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
|
logger.warning(f"Failed to cancel transaction {transaction_id}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error cancelling transaction {transaction_id}: {e}")
|
logger.error(f"Error cancelling transaction {transaction_id}: {e}")
|
||||||
return False
|
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]:
|
async def get_contested_transactions(self, season: int, week: int) -> List[Transaction]:
|
||||||
"""
|
"""
|
||||||
Get transactions that may be contested (multiple teams want same player).
|
Get transactions that may be contested (multiple teams want same player).
|
||||||
|
|||||||
364
tasks/README.md
364
tasks/README.md
@ -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
685
tasks/transaction_freeze.py
Normal 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)
|
||||||
293
tests/README.md
293
tests/README.md
@ -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!
|
|
||||||
@ -458,12 +458,13 @@ class TestVoiceChannelCommands:
|
|||||||
|
|
||||||
with patch('commands.voice.channels.team_service') as mock_team_service:
|
with patch('commands.voice.channels.team_service') as mock_team_service:
|
||||||
with patch('commands.voice.channels.league_service') as mock_league_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.object(mock_interaction.guild, 'create_voice_channel', return_value=mock_channel) as mock_create:
|
||||||
with patch('discord.utils.get') as mock_utils_get:
|
with patch('discord.utils.get') as mock_utils_get:
|
||||||
|
|
||||||
mock_team_service.get_teams_by_owner = AsyncMock(return_value=[mock_user_team])
|
mock_team_service.get_teams_by_owner = AsyncMock(return_value=[mock_user_team])
|
||||||
mock_league_service.get_current_state = AsyncMock(return_value=mock_current)
|
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_schedule.return_value = [mock_game]
|
||||||
|
|
||||||
# Mock discord.utils.get calls
|
# Mock discord.utils.get calls
|
||||||
|
|||||||
1172
tests/test_tasks_transaction_freeze.py
Normal file
1172
tests/test_tasks_transaction_freeze.py
Normal file
File diff suppressed because it is too large
Load Diff
941
utils/README.md
941
utils/README.md
@ -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`.
|
|
||||||
585
views/README.md
585
views/README.md
@ -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
|
|
||||||
@ -4,7 +4,7 @@ Base View Classes for Discord Bot v2.0
|
|||||||
Provides foundational view components with consistent styling and behavior.
|
Provides foundational view components with consistent styling and behavior.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, Any, Callable, Awaitable
|
from typing import List, Optional, Any, Callable, Awaitable, Union
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
@ -21,20 +21,22 @@ class BaseView(discord.ui.View):
|
|||||||
*,
|
*,
|
||||||
timeout: float = 180.0,
|
timeout: float = 180.0,
|
||||||
user_id: Optional[int] = None,
|
user_id: Optional[int] = None,
|
||||||
|
responders: Optional[List[int | None]] = None,
|
||||||
logger_name: Optional[str] = None
|
logger_name: Optional[str] = None
|
||||||
):
|
):
|
||||||
super().__init__(timeout=timeout)
|
super().__init__(timeout=timeout)
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
|
self.responders = responders
|
||||||
self.logger = get_contextual_logger(logger_name or f'{__name__}.BaseView')
|
self.logger = get_contextual_logger(logger_name or f'{__name__}.BaseView')
|
||||||
self.interaction_count = 0
|
self.interaction_count = 0
|
||||||
self.created_at = datetime.now(timezone.utc)
|
self.created_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
||||||
"""Check if user is authorized to interact with this view."""
|
"""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
|
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(
|
await interaction.response.send_message(
|
||||||
"❌ You cannot interact with this menu.",
|
"❌ You cannot interact with this menu.",
|
||||||
ephemeral=True
|
ephemeral=True
|
||||||
@ -95,14 +97,15 @@ class ConfirmationView(BaseView):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
user_id: int,
|
user_id: Optional[int] = None,
|
||||||
|
responders: Optional[List[int | None]] = None,
|
||||||
timeout: float = 60.0,
|
timeout: float = 60.0,
|
||||||
confirm_callback: Optional[Callable[[discord.Interaction], Awaitable[None]]] = None,
|
confirm_callback: Optional[Callable[[discord.Interaction], Awaitable[None]]] = None,
|
||||||
cancel_callback: Optional[Callable[[discord.Interaction], Awaitable[None]]] = None,
|
cancel_callback: Optional[Callable[[discord.Interaction], Awaitable[None]]] = None,
|
||||||
confirm_label: str = "Confirm",
|
confirm_label: str = "Confirm",
|
||||||
cancel_label: str = "Cancel"
|
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.confirm_callback = confirm_callback
|
||||||
self.cancel_callback = cancel_callback
|
self.cancel_callback = cancel_callback
|
||||||
self.result: Optional[bool] = None
|
self.result: Optional[bool] = None
|
||||||
|
|||||||
355
views/modals.py
355
views/modals.py
@ -485,4 +485,357 @@ def validate_season(season: str) -> bool:
|
|||||||
season_num = int(season)
|
season_num = int(season)
|
||||||
return 1 <= season_num <= 50
|
return 1 <= season_num <= 50
|
||||||
except ValueError:
|
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
395
views/players.py
Normal 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()
|
||||||
@ -13,18 +13,22 @@ from views.embeds import EmbedColors, EmbedTemplate
|
|||||||
|
|
||||||
class TransactionEmbedView(discord.ui.View):
|
class TransactionEmbedView(discord.ui.View):
|
||||||
"""Interactive view for the transaction builder embed."""
|
"""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.
|
Initialize the transaction embed view.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
builder: TransactionBuilder instance
|
builder: TransactionBuilder instance
|
||||||
user_id: Discord user ID (for permission checking)
|
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
|
super().__init__(timeout=900.0) # 15 minute timeout
|
||||||
self.builder = builder
|
self.builder = builder
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
|
self.submission_handler = submission_handler
|
||||||
|
self.command_name = command_name
|
||||||
|
|
||||||
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
||||||
"""Check if user has permission to interact with this view."""
|
"""Check if user has permission to interact with this view."""
|
||||||
@ -54,9 +58,9 @@ class TransactionEmbedView(discord.ui.View):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Create select menu for move removal
|
# Create select menu for move removal
|
||||||
select_view = RemoveMoveView(self.builder, self.user_id)
|
select_view = RemoveMoveView(self.builder, self.user_id, self.command_name)
|
||||||
embed = await create_transaction_embed(self.builder)
|
embed = await create_transaction_embed(self.builder, self.command_name)
|
||||||
|
|
||||||
await interaction.response.edit_message(embed=embed, view=select_view)
|
await interaction.response.edit_message(embed=embed, view=select_view)
|
||||||
|
|
||||||
@discord.ui.button(label="Submit Transaction", style=discord.ButtonStyle.primary, emoji="📤")
|
@discord.ui.button(label="Submit Transaction", style=discord.ButtonStyle.primary, emoji="📤")
|
||||||
@ -83,20 +87,20 @@ class TransactionEmbedView(discord.ui.View):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Show confirmation modal
|
# Show confirmation modal
|
||||||
modal = SubmitConfirmationModal(self.builder)
|
modal = SubmitConfirmationModal(self.builder, self.submission_handler)
|
||||||
await interaction.response.send_modal(modal)
|
await interaction.response.send_modal(modal)
|
||||||
|
|
||||||
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.secondary, emoji="❌")
|
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.secondary, emoji="❌")
|
||||||
async def cancel_button(self, interaction: discord.Interaction, button: discord.ui.Button):
|
async def cancel_button(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||||
"""Handle cancel button click."""
|
"""Handle cancel button click."""
|
||||||
self.builder.clear_moves()
|
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
|
# Disable all buttons after cancellation
|
||||||
for item in self.children:
|
for item in self.children:
|
||||||
if isinstance(item, discord.ui.Button):
|
if isinstance(item, discord.ui.Button):
|
||||||
item.disabled = True
|
item.disabled = True
|
||||||
|
|
||||||
await interaction.response.edit_message(
|
await interaction.response.edit_message(
|
||||||
content="❌ **Transaction cancelled and cleared.**",
|
content="❌ **Transaction cancelled and cleared.**",
|
||||||
embed=embed,
|
embed=embed,
|
||||||
@ -107,25 +111,28 @@ class TransactionEmbedView(discord.ui.View):
|
|||||||
|
|
||||||
class RemoveMoveView(discord.ui.View):
|
class RemoveMoveView(discord.ui.View):
|
||||||
"""View for selecting which move to remove."""
|
"""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
|
super().__init__(timeout=300.0) # 5 minute timeout
|
||||||
self.builder = builder
|
self.builder = builder
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
|
self.command_name = command_name
|
||||||
|
|
||||||
# Create select menu with current moves
|
# Create select menu with current moves
|
||||||
if not builder.is_empty:
|
if not builder.is_empty:
|
||||||
self.add_item(RemoveMoveSelect(builder))
|
self.add_item(RemoveMoveSelect(builder, command_name))
|
||||||
|
|
||||||
# Add back button
|
# Add back button
|
||||||
back_button = discord.ui.Button(label="Back", style=discord.ButtonStyle.secondary, emoji="⬅️")
|
back_button = discord.ui.Button(label="Back", style=discord.ButtonStyle.secondary, emoji="⬅️")
|
||||||
back_button.callback = self.back_callback
|
back_button.callback = self.back_callback
|
||||||
self.add_item(back_button)
|
self.add_item(back_button)
|
||||||
|
|
||||||
async def back_callback(self, interaction: discord.Interaction):
|
async def back_callback(self, interaction: discord.Interaction):
|
||||||
"""Handle back button to return to main view."""
|
"""Handle back button to return to main view."""
|
||||||
main_view = TransactionEmbedView(self.builder, self.user_id)
|
# Determine submission_handler from command_name
|
||||||
embed = await create_transaction_embed(self.builder)
|
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)
|
await interaction.response.edit_message(embed=embed, view=main_view)
|
||||||
|
|
||||||
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
||||||
@ -135,10 +142,11 @@ class RemoveMoveView(discord.ui.View):
|
|||||||
|
|
||||||
class RemoveMoveSelect(discord.ui.Select):
|
class RemoveMoveSelect(discord.ui.Select):
|
||||||
"""Select menu for choosing which move to remove."""
|
"""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.builder = builder
|
||||||
|
self.command_name = command_name
|
||||||
|
|
||||||
# Create options from current moves
|
# Create options from current moves
|
||||||
options = []
|
options = []
|
||||||
for i, move in enumerate(builder.moves[:25]): # Discord limit of 25 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
|
description=move.description[:100], # Discord description limit
|
||||||
value=str(move.player.id)
|
value=str(move.player.id)
|
||||||
))
|
))
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
placeholder="Select a move to remove...",
|
placeholder="Select a move to remove...",
|
||||||
min_values=1,
|
min_values=1,
|
||||||
max_values=1,
|
max_values=1,
|
||||||
options=options
|
options=options
|
||||||
)
|
)
|
||||||
|
|
||||||
async def callback(self, interaction: discord.Interaction):
|
async def callback(self, interaction: discord.Interaction):
|
||||||
"""Handle move removal selection."""
|
"""Handle move removal selection."""
|
||||||
player_id = int(self.values[0])
|
player_id = int(self.values[0])
|
||||||
move = self.builder.get_move_for_player(player_id)
|
move = self.builder.get_move_for_player(player_id)
|
||||||
|
|
||||||
if move:
|
if move:
|
||||||
self.builder.remove_move(player_id)
|
self.builder.remove_move(player_id)
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
f"✅ Removed: {move.description}",
|
f"✅ Removed: {move.description}",
|
||||||
ephemeral=True
|
ephemeral=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update the embed
|
# Update the embed
|
||||||
main_view = TransactionEmbedView(self.builder, interaction.user.id)
|
# Determine submission_handler from command_name
|
||||||
embed = await create_transaction_embed(self.builder)
|
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
|
# Edit the original message
|
||||||
await interaction.edit_original_response(embed=embed, view=main_view)
|
await interaction.edit_original_response(embed=embed, view=main_view)
|
||||||
else:
|
else:
|
||||||
@ -183,10 +193,11 @@ class RemoveMoveSelect(discord.ui.Select):
|
|||||||
|
|
||||||
class SubmitConfirmationModal(discord.ui.Modal):
|
class SubmitConfirmationModal(discord.ui.Modal):
|
||||||
"""Modal for confirming transaction submission."""
|
"""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")
|
super().__init__(title="Confirm Transaction Submission")
|
||||||
self.builder = builder
|
self.builder = builder
|
||||||
|
self.submission_handler = submission_handler
|
||||||
|
|
||||||
self.confirmation = discord.ui.TextInput(
|
self.confirmation = discord.ui.TextInput(
|
||||||
label="Type 'CONFIRM' to submit",
|
label="Type 'CONFIRM' to submit",
|
||||||
@ -205,54 +216,89 @@ class SubmitConfirmationModal(discord.ui.Modal):
|
|||||||
ephemeral=True
|
ephemeral=True
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
await interaction.response.defer(ephemeral=True)
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
|
||||||
try:
|
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
|
# Get current league state
|
||||||
league_service = LeagueService()
|
|
||||||
current_state = await league_service.get_current_state()
|
current_state = await league_service.get_current_state()
|
||||||
|
|
||||||
if not current_state:
|
if not current_state:
|
||||||
await interaction.followup.send(
|
await interaction.followup.send(
|
||||||
"❌ Could not get current league state. Please try again later.",
|
"❌ Could not get current league state. Please try again later.",
|
||||||
ephemeral=True
|
ephemeral=True
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Submit the transaction (for next week)
|
if self.submission_handler == "scheduled":
|
||||||
transactions = await self.builder.submit_transaction(week=current_state.week + 1)
|
# SCHEDULED SUBMISSION (/dropadd behavior)
|
||||||
|
# Submit the transaction for NEXT week
|
||||||
# Create success message
|
transactions = await self.builder.submit_transaction(week=current_state.week + 1)
|
||||||
success_msg = f"✅ **Transaction Submitted Successfully!**\n\n"
|
|
||||||
success_msg += f"**Move ID:** `{transactions[0].moveid}`\n"
|
# Create success message
|
||||||
success_msg += f"**Moves:** {len(transactions)}\n"
|
success_msg = f"✅ **Transaction Submitted Successfully!**\n\n"
|
||||||
success_msg += f"**Effective Week:** {transactions[0].week}\n\n"
|
success_msg += f"**Move ID:** `{transactions[0].moveid}`\n"
|
||||||
|
success_msg += f"**Moves:** {len(transactions)}\n"
|
||||||
success_msg += "**Transaction Details:**\n"
|
success_msg += f"**Effective Week:** {transactions[0].week}\n\n"
|
||||||
for move in self.builder.moves:
|
|
||||||
success_msg += f"• {move.description}\n"
|
success_msg += "**Transaction Details:**\n"
|
||||||
|
for move in self.builder.moves:
|
||||||
success_msg += f"\n💡 Use `/mymoves` to check transaction status"
|
success_msg += f"• {move.description}\n"
|
||||||
|
|
||||||
await interaction.followup.send(success_msg, ephemeral=True)
|
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
|
# Clear the builder after successful submission
|
||||||
from services.transaction_builder import clear_transaction_builder
|
from services.transaction_builder import clear_transaction_builder
|
||||||
clear_transaction_builder(interaction.user.id)
|
clear_transaction_builder(interaction.user.id)
|
||||||
|
|
||||||
# Update the original embed to show completion
|
# Update the original embed to show completion
|
||||||
|
completion_title = "✅ Transaction Submitted" if self.submission_handler == "scheduled" else "✅ IL Move Executed"
|
||||||
completion_embed = discord.Embed(
|
completion_embed = discord.Embed(
|
||||||
title="✅ Transaction Submitted",
|
title=completion_title,
|
||||||
description=f"Your transaction has been submitted successfully!\n\nMove ID: `{transactions[0].moveid}`",
|
description=f"Your transaction has been processed successfully!",
|
||||||
color=0x00ff00
|
color=0x00ff00
|
||||||
)
|
)
|
||||||
|
|
||||||
# Disable all buttons
|
# Disable all buttons
|
||||||
view = discord.ui.View()
|
view = discord.ui.View()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Find and update the original message
|
# Find and update the original message
|
||||||
async for message in interaction.channel.history(limit=50): # type: ignore
|
async for message in interaction.channel.history(limit=50): # type: ignore
|
||||||
@ -262,7 +308,7 @@ class SubmitConfirmationModal(discord.ui.Modal):
|
|||||||
break
|
break
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await interaction.followup.send(
|
await interaction.followup.send(
|
||||||
f"❌ Error submitting transaction: {str(e)}",
|
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.
|
Create the main transaction builder embed.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
builder: TransactionBuilder instance
|
builder: TransactionBuilder instance
|
||||||
|
command_name: Name of the command to use for adding more moves (default: "/dropadd")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Discord embed with current transaction state
|
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(
|
embed = EmbedTemplate.create_base_embed(
|
||||||
title=f"📋 Transaction Builder - {builder.team.abbrev}",
|
title=f"📋 Transaction Builder - {builder.team.abbrev}",
|
||||||
description=f"Build your transaction for next week",
|
description=description,
|
||||||
color=EmbedColors.PRIMARY
|
color=EmbedColors.PRIMARY
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -354,7 +407,7 @@ async def create_transaction_embed(builder: TransactionBuilder) -> discord.Embed
|
|||||||
# Add instructions for adding more moves
|
# Add instructions for adding more moves
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name="➕ Add More Moves",
|
name="➕ Add More Moves",
|
||||||
value="Use `/dropadd` to add more moves",
|
value=f"Use `{command_name}` to add more moves",
|
||||||
inline=False
|
inline=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user