Pulling master over to main #3

Merged
cal merged 6 commits from master into main 2026-02-04 06:16:30 +00:00
6 changed files with 2663 additions and 1692 deletions

View File

@ -19,6 +19,14 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
if: github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
run: |
@ -34,30 +42,35 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
push: false
push: ${{ github.ref == 'refs/heads/main' }}
tags: |
paper-dynasty:latest
paper-dynasty:v${{ steps.meta.outputs.version }}
paper-dynasty:${{ steps.meta.outputs.version_sha }}
manticorum67/paper-dynasty:latest
manticorum67/paper-dynasty:v${{ steps.meta.outputs.version }}
manticorum67/paper-dynasty:${{ steps.meta.outputs.version_sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
outputs: type=docker,dest=/tmp/paper-dynasty-image.tar
- name: Build Summary
run: |
echo "## 🐳 Docker Build Successful! ✅" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Image Tags:**" >> $GITHUB_STEP_SUMMARY
echo "- \`paper-dynasty:latest\`" >> $GITHUB_STEP_SUMMARY
echo "- \`paper-dynasty:v${{ steps.meta.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
echo "- \`paper-dynasty:${{ steps.meta.outputs.version_sha }}\`" >> $GITHUB_STEP_SUMMARY
echo "- \`manticorum67/paper-dynasty:latest\`" >> $GITHUB_STEP_SUMMARY
echo "- \`manticorum67/paper-dynasty:v${{ steps.meta.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
echo "- \`manticorum67/paper-dynasty:${{ steps.meta.outputs.version_sha }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Build Details:**" >> $GITHUB_STEP_SUMMARY
echo "- Branch: \`${{ steps.meta.outputs.branch }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Timestamp: \`${{ steps.meta.outputs.timestamp }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "_Ready to deploy to production!_" >> $GITHUB_STEP_SUMMARY
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "🚀 **Pushed to Docker Hub!**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Pull with: \`docker pull manticorum67/paper-dynasty:latest\`" >> $GITHUB_STEP_SUMMARY
else
echo "_PR build - image not pushed to Docker Hub_" >> $GITHUB_STEP_SUMMARY
fi
- name: Save build info
run: |

File diff suppressed because it is too large Load Diff

View File

@ -129,3 +129,14 @@ class LegalityCheckNotRequired(GameException):
class InvalidResponder(GameException):
pass
class PlayLockedException(GameException):
"""
Raised when attempting to process a play that is already locked by another interaction.
This prevents concurrent modification of the same play record, which could cause
database deadlocks or data corruption.
"""
pass

View File

@ -3,6 +3,7 @@ HTTP health check server for Paper Dynasty Discord bot.
Provides health and readiness endpoints for container monitoring and orchestration.
"""
import asyncio
import logging
from typing import Optional
@ -10,13 +11,13 @@ from aiohttp import web
import discord
from discord.ext import commands
logger = logging.getLogger('discord_app.health')
logger = logging.getLogger("discord_app.health")
class HealthServer:
"""HTTP server for health checks and metrics."""
def __init__(self, bot: commands.Bot, host: str = '0.0.0.0', port: int = 8080):
def __init__(self, bot: commands.Bot, host: str = "0.0.0.0", port: int = 8080):
"""
Initialize health server.
@ -33,9 +34,10 @@ class HealthServer:
self.site: Optional[web.TCPSite] = None
# Setup routes
self.app.router.add_get('/health', self.health_check)
self.app.router.add_get('/ready', self.readiness_check)
self.app.router.add_get('/metrics', self.metrics)
self.app.router.add_get("/health", self.health_check)
self.app.router.add_get("/ready", self.readiness_check)
self.app.router.add_get("/metrics", self.metrics)
self.app.router.add_get("/diagnostics", self.diagnostics)
async def health_check(self, request: web.Request) -> web.Response:
"""
@ -43,10 +45,9 @@ class HealthServer:
Returns 200 if the server is responsive.
"""
return web.json_response({
'status': 'healthy',
'service': 'paper-dynasty-discord-bot'
})
return web.json_response(
{"status": "healthy", "service": "paper-dynasty-discord-bot"}
)
async def readiness_check(self, request: web.Request) -> web.Response:
"""
@ -57,16 +58,19 @@ class HealthServer:
503 if bot is not ready
"""
if self.bot.is_ready():
return web.json_response({
'status': 'ready',
'discord_connected': True,
'latency_ms': round(self.bot.latency * 1000, 2) if self.bot.latency else None
})
return web.json_response(
{
"status": "ready",
"discord_connected": True,
"latency_ms": round(self.bot.latency * 1000, 2)
if self.bot.latency
else None,
}
)
else:
return web.json_response({
'status': 'not_ready',
'discord_connected': False
}, status=503)
return web.json_response(
{"status": "not_ready", "discord_connected": False}, status=503
)
async def metrics(self, request: web.Request) -> web.Response:
"""
@ -75,33 +79,65 @@ class HealthServer:
Provides detailed information about bot state for external monitoring systems.
"""
metrics_data = {
'bot': {
'is_ready': self.bot.is_ready(),
'is_closed': self.bot.is_closed(),
'latency_ms': round(self.bot.latency * 1000, 2) if self.bot.latency else None,
"bot": {
"is_ready": self.bot.is_ready(),
"is_closed": self.bot.is_closed(),
"latency_ms": round(self.bot.latency * 1000, 2)
if self.bot.latency
else None,
},
'guilds': {
'count': len(self.bot.guilds),
'guild_ids': [g.id for g in self.bot.guilds]
"guilds": {
"count": len(self.bot.guilds),
"guild_ids": [g.id for g in self.bot.guilds],
},
'users': {
'count': len(self.bot.users)
},
'cogs': {
'loaded': list(self.bot.cogs.keys()),
'count': len(self.bot.cogs)
}
"users": {"count": len(self.bot.users)},
"cogs": {"loaded": list(self.bot.cogs.keys()), "count": len(self.bot.cogs)},
}
return web.json_response(metrics_data)
async def diagnostics(self, request: web.Request) -> web.Response:
"""
Detailed diagnostics for troubleshooting frozen bot.
Captures state before container restart.
"""
import sys
tasks_info = []
try:
for task in asyncio.all_tasks():
tasks_info.append(
{
"name": task.get_name(),
"done": task.done(),
"cancelled": task.cancelled(),
}
)
except Exception as e:
tasks_info = [f"Error capturing tasks: {e}"]
diagnostics_data = {
"bot": {
"is_ready": self.bot.is_ready(),
"is_closed": self.bot.is_closed(),
"latency_ms": round(self.bot.latency * 1000, 2)
if self.bot.latency
else None,
},
"tasks": {"count": len(tasks_info), "tasks": tasks_info[:20]},
"cogs": {"loaded": list(self.bot.cogs.keys()), "count": len(self.bot.cogs)},
"python_version": sys.version,
}
return web.json_response(diagnostics_data)
async def start(self):
"""Start the health check server."""
self.runner = web.AppRunner(self.app)
await self.runner.setup()
self.site = web.TCPSite(self.runner, self.host, self.port)
await self.site.start()
logger.info(f'Health check server started on {self.host}:{self.port}')
logger.info(f"Health check server started on {self.host}:{self.port}")
async def stop(self):
"""Stop the health check server."""
@ -109,10 +145,10 @@ class HealthServer:
await self.site.stop()
if self.runner:
await self.runner.cleanup()
logger.info('Health check server stopped')
logger.info("Health check server stopped")
async def run_health_server(bot: commands.Bot, host: str = '0.0.0.0', port: int = 8080):
async def run_health_server(bot: commands.Bot, host: str = "0.0.0.0", port: int = 8080):
"""
Run health server as a background task.

57
notify_restart.py Normal file
View File

@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""
Notify admin channel when bot restarts due to healthcheck failure.
Uses Discord webhook for instant notification.
"""
import os
import urllib.request
import json
from datetime import datetime
def send_restart_notification():
"""Send notification to Discord via webhook."""
webhook_url = os.getenv("RESTART_WEBHOOK_URL")
if not webhook_url:
print("No RESTART_WEBHOOK_URL configured, skipping notification")
return False
try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S CST")
data = {
"content": (
f"**Paper Dynasty Bot Restarted** at {timestamp}\n\n"
"Bot has successfully connected to Discord.\n\n"
"Check `/logs/discord.log` for startup details."
),
"username": "Paper Dynasty Monitor",
}
req = urllib.request.Request(
webhook_url,
data=json.dumps(data).encode("utf-8"),
headers={
"Content-Type": "application/json",
"User-Agent": "Paper-Dynasty-Discord-Bot/1.0",
},
)
response = urllib.request.urlopen(req, timeout=5)
if response.status == 204:
print(f"Restart notification sent successfully at {timestamp}")
return True
else:
print(f"Webhook returned status {response.status}")
return False
except Exception as e:
print(f"Failed to send restart notification: {e}")
return False
if __name__ == "__main__":
send_restart_notification()

View File

@ -9,6 +9,7 @@ from discord.ext import commands
from in_game.gameplay_models import create_db_and_tables
from health_server import run_health_server
from notify_restart import send_restart_notification
raw_log_level = os.getenv('LOG_LEVEL')
if raw_log_level == 'DEBUG':
@ -70,6 +71,9 @@ async def on_ready():
logger.info(bot.user.name)
logger.info(bot.user.id)
# Send restart notification if configured
send_restart_notification()
@bot.tree.error
async def on_app_command_error(interaction: discord.Interaction, error: discord.app_commands.AppCommandError):