Skip to content

Commit f132326

Browse files
committed
Add WebSocket for real-time message count broadcasting
1 parent db65e42 commit f132326

35 files changed

+3420
-94
lines changed

src/api/__init__.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"""
2+
SyriaBot - API Package
3+
======================
4+
5+
FastAPI-based REST API for the XP Leaderboard Dashboard.
6+
7+
Author: حَـــــنَّـــــا
8+
Server: discord.gg/syria
9+
10+
Features:
11+
- XP leaderboard with pagination and period filters
12+
- User profiles with detailed stats
13+
- Server statistics and daily activity
14+
- Per-channel message counts
15+
- Protected XP modification endpoints (API key required)
16+
- Response caching with TTL
17+
- Rate limiting per IP
18+
19+
Usage with bot:
20+
from src.api import APIService
21+
22+
# In your bot's setup
23+
api_service = APIService(bot)
24+
await api_service.start()
25+
26+
# On shutdown
27+
await api_service.stop()
28+
29+
Standalone (for development):
30+
uvicorn src.api.app:app --reload --port 8088
31+
"""
32+
33+
import asyncio
34+
from typing import Any, Optional
35+
36+
import uvicorn
37+
38+
from src.core.logger import logger
39+
from src.api.config import get_api_config, APIConfig
40+
from src.api.app import create_app
41+
from src.api.dependencies import set_bot
42+
from src.api.services.cache import get_cache_service, CacheService
43+
from src.api.services.background import (
44+
BackgroundTaskService,
45+
get_background_service,
46+
init_background_service,
47+
)
48+
49+
50+
# =============================================================================
51+
# API Service
52+
# =============================================================================
53+
54+
class APIService:
55+
"""
56+
Manages the FastAPI server lifecycle within the Discord bot.
57+
58+
This service runs the API server in a background task, allowing
59+
the bot and API to run concurrently.
60+
"""
61+
62+
def __init__(self, bot: Any):
63+
"""
64+
Initialize the API service.
65+
66+
Args:
67+
bot: The Discord bot instance
68+
"""
69+
self._bot = bot
70+
self._config = get_api_config()
71+
self._app = create_app(bot)
72+
self._server: Optional[uvicorn.Server] = None
73+
self._task: Optional[asyncio.Task] = None
74+
self._background_service = init_background_service(bot)
75+
76+
@property
77+
def is_running(self) -> bool:
78+
"""Check if the API server is running."""
79+
return self._task is not None and not self._task.done()
80+
81+
@property
82+
def cache(self) -> CacheService:
83+
"""Get the cache service."""
84+
return get_cache_service()
85+
86+
@property
87+
def background_service(self) -> BackgroundTaskService:
88+
"""Get the background task service."""
89+
return self._background_service
90+
91+
async def start(self) -> None:
92+
"""Start the API server in a background task."""
93+
if self.is_running:
94+
logger.warning("API Already Running", [])
95+
return
96+
97+
# Configure uvicorn
98+
config = uvicorn.Config(
99+
app=self._app,
100+
host=self._config.host,
101+
port=self._config.port,
102+
log_level="warning", # Reduce uvicorn logging
103+
access_log=False, # We have our own logging middleware
104+
)
105+
106+
self._server = uvicorn.Server(config)
107+
108+
# Run in background
109+
self._task = asyncio.create_task(self._run_server())
110+
111+
# Start background services
112+
await self._background_service.start()
113+
114+
logger.tree("Syria API Ready", [
115+
("Host", self._config.host),
116+
("Port", str(self._config.port)),
117+
("Endpoints", "/api/syria/leaderboard, /api/syria/user/{id}, /api/syria/stats"),
118+
("Rate Limit", "60 req/min"),
119+
("Midnight Refresh", "Enabled"),
120+
("Daily Snapshots", "Enabled (UTC midnight)"),
121+
], emoji="🌐")
122+
123+
async def _run_server(self) -> None:
124+
"""Run the uvicorn server."""
125+
try:
126+
await self._server.serve()
127+
except asyncio.CancelledError:
128+
logger.debug("API Server Cancelled", [])
129+
except Exception as e:
130+
logger.error("API Server Error", [
131+
("Error Type", type(e).__name__),
132+
("Error", str(e)[:100]),
133+
])
134+
135+
async def stop(self) -> None:
136+
"""Stop the API server gracefully."""
137+
if not self.is_running:
138+
return
139+
140+
logger.tree("Syria API Stopping", [], emoji="🛑")
141+
142+
# Stop background services
143+
await self._background_service.stop()
144+
145+
# Signal server to stop
146+
if self._server:
147+
self._server.should_exit = True
148+
149+
# Wait for task to complete
150+
if self._task:
151+
try:
152+
await asyncio.wait_for(self._task, timeout=5.0)
153+
except asyncio.TimeoutError:
154+
self._task.cancel()
155+
try:
156+
await self._task
157+
except asyncio.CancelledError:
158+
pass
159+
160+
self._server = None
161+
self._task = None
162+
163+
logger.tree("Syria API Stopped", [
164+
("Status", "Shutdown complete"),
165+
], emoji="✅")
166+
167+
168+
# =============================================================================
169+
# Exports
170+
# =============================================================================
171+
172+
__all__ = [
173+
# Main service
174+
"APIService",
175+
# Config
176+
"get_api_config",
177+
"APIConfig",
178+
# App factory
179+
"create_app",
180+
# Services
181+
"get_cache_service",
182+
"CacheService",
183+
"get_background_service",
184+
"BackgroundTaskService",
185+
# Dependencies
186+
"set_bot",
187+
]

src/api/app.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""
2+
SyriaBot - FastAPI Application
3+
==============================
4+
5+
FastAPI application factory and configuration.
6+
7+
Author: حَـــــنَّـــــا
8+
Server: discord.gg/syria
9+
"""
10+
11+
from contextlib import asynccontextmanager
12+
from typing import Any, Optional
13+
14+
from fastapi import FastAPI, Request
15+
from fastapi.middleware.cors import CORSMiddleware
16+
from fastapi.responses import JSONResponse
17+
from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR
18+
19+
from src.core.logger import logger
20+
from src.api.config import get_api_config
21+
from src.api.middleware.rate_limit import RateLimitMiddleware, get_rate_limiter
22+
from src.api.middleware.logging import LoggingMiddleware
23+
from src.api.dependencies import set_bot
24+
from src.api.routers import (
25+
health_router,
26+
leaderboard_router,
27+
users_router,
28+
stats_router,
29+
channels_router,
30+
xp_router,
31+
ws_router,
32+
)
33+
from src.api.routers.health import set_start_time
34+
35+
36+
# =============================================================================
37+
# Lifespan
38+
# =============================================================================
39+
40+
@asynccontextmanager
41+
async def lifespan(app: FastAPI):
42+
"""
43+
Application lifespan handler.
44+
45+
Handles startup and shutdown events.
46+
"""
47+
# Startup
48+
set_start_time()
49+
logger.tree("API Starting", [
50+
("Version", "2.0.0"),
51+
("Framework", "FastAPI"),
52+
], emoji="🚀")
53+
54+
yield
55+
56+
# Shutdown
57+
logger.tree("API Stopping", [], emoji="🛑")
58+
59+
60+
# =============================================================================
61+
# Application Factory
62+
# =============================================================================
63+
64+
def create_app(bot: Optional[Any] = None) -> FastAPI:
65+
"""
66+
Create and configure the FastAPI application.
67+
68+
Args:
69+
bot: Optional Discord bot instance for dependency injection
70+
71+
Returns:
72+
Configured FastAPI application
73+
"""
74+
config = get_api_config()
75+
76+
# Create app
77+
app = FastAPI(
78+
title="SyriaBot API",
79+
description="XP Leaderboard Dashboard API for SyriaBot",
80+
version="2.0.0",
81+
docs_url="/api/syria/docs" if config.debug else None,
82+
redoc_url="/api/syria/redoc" if config.debug else None,
83+
openapi_url="/api/syria/openapi.json" if config.debug else None,
84+
lifespan=lifespan,
85+
)
86+
87+
# Set bot reference
88+
if bot:
89+
set_bot(bot)
90+
91+
# ==========================================================================
92+
# Middleware (order matters - last added = first executed)
93+
# ==========================================================================
94+
95+
# CORS
96+
app.add_middleware(
97+
CORSMiddleware,
98+
allow_origins=list(config.cors_origins),
99+
allow_credentials=True,
100+
allow_methods=["GET", "POST", "OPTIONS"],
101+
allow_headers=["*"],
102+
)
103+
104+
# Rate limiting
105+
app.add_middleware(RateLimitMiddleware, rate_limiter=get_rate_limiter())
106+
107+
# Request logging
108+
app.add_middleware(LoggingMiddleware)
109+
110+
# ==========================================================================
111+
# Exception Handlers
112+
# ==========================================================================
113+
114+
@app.exception_handler(Exception)
115+
async def global_exception_handler(request: Request, exc: Exception):
116+
"""Handle uncaught exceptions."""
117+
logger.error("Unhandled API Error", [
118+
("Path", str(request.url.path)[:50]),
119+
("Error", str(exc)[:100]),
120+
])
121+
122+
return JSONResponse(
123+
status_code=HTTP_500_INTERNAL_SERVER_ERROR,
124+
content={"error": "Internal server error"},
125+
)
126+
127+
# ==========================================================================
128+
# Routers
129+
# ==========================================================================
130+
131+
# Health check at root level
132+
app.include_router(health_router)
133+
134+
# API endpoints
135+
app.include_router(leaderboard_router)
136+
app.include_router(users_router)
137+
app.include_router(stats_router)
138+
app.include_router(channels_router)
139+
app.include_router(xp_router)
140+
app.include_router(ws_router)
141+
142+
return app
143+
144+
145+
# =============================================================================
146+
# Module-level app for uvicorn standalone
147+
# =============================================================================
148+
149+
# This allows running with: uvicorn src.api.app:app
150+
app = create_app()
151+
152+
153+
__all__ = ["create_app", "app"]

0 commit comments

Comments
 (0)