CLAUDE: Add automated weekly transaction freeze/thaw system

Implements comprehensive automated system for weekly transaction freeze periods
with priority-based contested player resolution.

New Features:
- Weekly freeze/thaw task (Monday 00:00 freeze, Saturday 00:00 thaw)
- Priority resolution for contested transactions (worst teams get first priority)
- Admin league management commands (/freeze-begin, /freeze-end, /advance-week)
- Enhanced API client to handle string-based transaction IDs (moveids)
- Service layer methods for transaction cancellation, unfreezing, and bulk operations
- Offseason mode configuration flag to disable freeze operations

Technical Changes:
- api/client.py: URL-encode object_id parameter to handle colons in moveids
- bot.py: Initialize and shutdown transaction freeze task
- config.py: Add offseason_flag to BotConfig
- services/league_service.py: Add update_current_state() for week/freeze updates
- services/transaction_service.py: Add cancel/unfreeze methods with bulk support
- tasks/transaction_freeze.py: Main freeze/thaw automation with error recovery
- commands/admin/league_management.py: Manual admin controls for freeze system

Infrastructure:
- .gitlab-ci.yml and .gitlab/: GitLab CI/CD pipeline configuration
- .mcp.json: MCP server configuration
- Dockerfile.versioned: Versioned Docker build support
- .dockerignore: Added .gitlab/ to ignore list

Testing:
- tests/test_tasks_transaction_freeze.py: Comprehensive freeze task tests

The system uses team standings to fairly resolve contested players (multiple teams
trying to acquire the same player), with worst-record teams getting priority.
Includes comprehensive error handling, GM notifications, and admin reporting.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-10-20 12:16:13 -05:00
parent 82abf3d9e6
commit 62c658fb57
16 changed files with 4283 additions and 43 deletions

View File

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

237
.gitlab-ci.yml Normal file
View File

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

536
.gitlab/DEPLOYMENT_SETUP.md Normal file
View File

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

315
.gitlab/QUICK_REFERENCE.md Normal file
View File

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

517
.gitlab/VPS_SCRIPTS.md Normal file
View File

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

13
.mcp.json Normal file
View File

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

49
Dockerfile.versioned Normal file
View File

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

View File

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

16
bot.py
View File

@ -173,6 +173,11 @@ class SBABot(commands.Bot):
from tasks.custom_command_cleanup import setup_cleanup_task
self.custom_command_cleanup = setup_cleanup_task(self)
# Initialize transaction freeze/thaw task
from tasks.transaction_freeze import setup_freeze_task
self.transaction_freeze = setup_freeze_task(self)
self.logger.info("✅ Transaction freeze/thaw task started")
# Initialize voice channel cleanup service
from commands.voice.cleanup_service import VoiceChannelCleanupService
self.voice_cleanup_service = VoiceChannelCleanupService()
@ -313,7 +318,7 @@ class SBABot(commands.Bot):
async def close(self):
"""Clean shutdown of the bot."""
self.logger.info("Bot shutting down...")
# Stop background tasks
if hasattr(self, 'custom_command_cleanup'):
try:
@ -322,13 +327,20 @@ class SBABot(commands.Bot):
except Exception as e:
self.logger.error(f"Error stopping cleanup task: {e}")
if hasattr(self, 'transaction_freeze'):
try:
self.transaction_freeze.weekly_loop.cancel()
self.logger.info("Transaction freeze/thaw task stopped")
except Exception as e:
self.logger.error(f"Error stopping transaction freeze task: {e}")
if hasattr(self, 'voice_cleanup_service'):
try:
self.voice_cleanup_service.stop_monitoring()
self.logger.info("Voice channel cleanup service stopped")
except Exception as e:
self.logger.error(f"Error stopping voice cleanup service: {e}")
# Call parent close method
await super().close()
self.logger.info("Bot shutdown complete")

View File

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

View File

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

View File

@ -33,6 +33,7 @@ class BotConfig(BaseSettings):
weeks_per_season: int = 18
games_per_week: int = 4
modern_stats_start_season: int = 8
offseason_flag: bool = False # When True, relaxes roster limits and disables weekly freeze/thaw
# Current Season Constants
sba_current_season: int = 12

View File

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

View File

@ -191,40 +191,126 @@ class TransactionService(BaseService[Transaction]):
async def cancel_transaction(self, transaction_id: str) -> bool:
"""
Cancel a pending transaction.
Note: When using moveid, this updates ALL transactions with that moveid (bulk update).
The API returns a message string like "Updated 4 transactions" instead of the transaction object.
Args:
transaction_id: ID of transaction to cancel
transaction_id: Move ID of transaction to cancel (e.g., "Season-012-Week-17-08-18:57:21")
Returns:
True if cancelled successfully
"""
try:
transaction = await self.get_by_id(transaction_id)
if not transaction:
return False
if not transaction.is_pending:
logger.warning(f"Cannot cancel transaction {transaction_id}: not pending (cancelled={transaction.cancelled}, frozen={transaction.frozen})")
return False
# Update transaction status
# Update transaction status using direct API call to handle bulk updates
update_data = {
'cancelled': True,
'cancelled_at': datetime.now(UTC).isoformat()
}
updated_transaction = await self.update(transaction_id, update_data)
if updated_transaction:
# Call API directly since bulk update returns a message string, not a Transaction object
client = await self.get_client()
response = await client.patch(
self.endpoint,
update_data,
object_id=transaction_id,
use_query_params=True
)
# Check if response indicates success
# Response will be a string like "Updated 4 transactions" for bulk updates
if response and (isinstance(response, str) and 'Updated' in response):
logger.info(f"Cancelled transaction(s) {transaction_id}: {response}")
return True
elif response:
# If we got a dict response, it's a single transaction update
logger.info(f"Cancelled transaction {transaction_id}")
return True
else:
logger.warning(f"Failed to cancel transaction {transaction_id}")
return False
except Exception as e:
logger.error(f"Error cancelling transaction {transaction_id}: {e}")
return False
async def unfreeze_transaction(self, transaction_id: str) -> bool:
"""
Unfreeze a frozen transaction, allowing it to be processed.
Note: When using moveid, this updates ALL transactions with that moveid (bulk update).
The API returns a message string like "Updated 4 transactions" instead of the transaction object.
Args:
transaction_id: Move ID of transaction to unfreeze (e.g., "Season-012-Week-17-08-18:57:21")
Returns:
True if unfrozen successfully
"""
try:
# Call API directly since bulk update returns a message string, not a Transaction object
client = await self.get_client()
response = await client.patch(
self.endpoint,
{'frozen': False},
object_id=transaction_id,
use_query_params=True
)
# Check if response indicates success
# Response will be a string like "Updated 4 transactions" for bulk updates
if response and (isinstance(response, str) and 'Updated' in response):
logger.info(f"Unfroze transaction(s) {transaction_id}: {response}")
return True
elif response:
# If we got a dict response, it's a single transaction update
logger.info(f"Unfroze transaction {transaction_id}")
return True
else:
logger.warning(f"Failed to unfreeze transaction {transaction_id}")
return False
except Exception as e:
logger.error(f"Error unfreezing transaction {transaction_id}: {e}")
return False
async def get_frozen_transactions_by_week(
self,
season: int,
week_start: int,
week_end: int
) -> List[Transaction]:
"""
Get all frozen transactions for a week range (all teams).
This is used during freeze processing to get all contested transactions
across the entire league.
Args:
season: Season number
week_start: Starting week number
week_end: Ending week number
Returns:
List of frozen transactions for the week range
"""
try:
params = [
('season', str(season)),
('week_start', str(week_start)),
('week_end', str(week_end)),
('frozen', 'true')
]
transactions = await self.get_all_items(params=params)
logger.debug(f"Retrieved {len(transactions)} frozen transactions for weeks {week_start}-{week_end}")
return transactions
except Exception as e:
logger.error(f"Error getting frozen transactions for weeks {week_start}-{week_end}: {e}")
return []
async def get_contested_transactions(self, season: int, week: int) -> List[Transaction]:
"""
Get transactions that may be contested (multiple teams want same player).

685
tasks/transaction_freeze.py Normal file
View File

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

File diff suppressed because it is too large Load Diff