Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ RUN pip install --no-cache-dir -r /app/python/requirements.txt

COPY python /app/python

EXPOSE 8080

CMD ["python", "python/WOM.py"]
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ services:
- ./python/config.ini:/app/python/config.ini:ro
- ./python/ranks.ini:/app/python/ranks.ini:ro
- ./data:/app/data
ports:
- "8080:8080"
restart: unless-stopped
60 changes: 49 additions & 11 deletions python/WOM.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
from utils.rank_utils import load_ranks, save_ranks
from utils.log_csv import log_ehb_to_csv
from utils.commands import setup_commands
import uvicorn
from web import create_app
from web.services.bot_state import BotState


class Client(BaseClient):
Expand Down Expand Up @@ -52,7 +55,9 @@ def log(message: str):
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
formatted_message = f"{timestamp} - {message}"
print(formatted_message) # Print to terminal

if 'bot_state' in globals() and bot_state is not None:
bot_state.log_buffer.append(formatted_message)

# Send to GUI if it's running
botgui_module = sys.modules.get('gui') or sys.modules.get('__main__')
if botgui_module and hasattr(botgui_module, 'BotGUI'):
Expand Down Expand Up @@ -92,6 +97,11 @@ def get_messageable_channel(channel_id: int) -> Optional[discord.abc.Messageable
silent = config['settings'].getboolean('silent', False)
debug = config['settings'].getboolean('debug', False)

# Web interface settings
web_enabled = config['web'].getboolean('enabled', False) if config.has_section('web') else False
web_host = config['web'].get('host', '0.0.0.0') if config.has_section('web') else '0.0.0.0'
web_port = int(config['web'].get('port', '8080')) if config.has_section('web') else 8080

if api_key:
log("Wise Old Man API key loaded.")
else:
Expand Down Expand Up @@ -247,7 +257,8 @@ async def check_for_rank_changes():

save_ranks(ranks_data)
log("Rank check completed successfully!")

bot_state.last_rank_check = datetime.now()

else:
log(f"Failed to fetch group details: {result.unwrap_err()}")
except Exception as e:
Expand Down Expand Up @@ -344,6 +355,7 @@ async def refresh_group_data():
@tasks.loop(seconds=check_interval * 48)
async def refresh_group_task():
msg = await refresh_group_data()
bot_state.last_group_refresh = datetime.now()
if post_to_discord:
channel = get_messageable_channel(channel_id)
if channel:
Expand All @@ -360,16 +372,16 @@ async def send_rank_up_message(username, new_rank, old_rank, ehb):
# Ensure discord_names is always a list
if not isinstance(discord_names, list):
discord_names = [discord_names] if discord_names else []
fans_display = " ".join(discord_names) if discord_names else "0 😭😭😭"
fans_display = " ".join(discord_names) if discord_names else "0 😭😭😭"

# Only send message if the rank has changed
if new_rank != old_rank:
channel = get_messageable_channel(channel_id)
if channel:
if post_to_discord:
await channel.send(
f'🎉 Congratulations **{username}** on moving up to the rank of **{new_rank}** '
f'with **{ehb}** EHB! 🎉\n'
f'🎉 Congratulations **{username}** on moving up to the rank of **{new_rank}** '
f'with **{ehb}** EHB! 🎉\n'
f'**Fans:** {fans_display}'
)
log(f"Sent rank up message for {username} to channel: {channel}")
Expand All @@ -379,6 +391,20 @@ async def send_rank_up_message(username, new_rank, old_rank, ehb):
log(f"Error sending message: {e}")


# Shared state for web interface
bot_state = BotState(
wom_client=wom_client,
discord_client=discord_client,
group_id=group_id,
group_passcode=group_passcode,
get_rank=get_rank,
check_interval=check_interval,
post_to_discord=post_to_discord,
silent=silent,
debug=debug,
)


# Initialize Additional Commands


Expand Down Expand Up @@ -409,11 +435,24 @@ async def send_rank_up_message(username, new_rank, old_rank, ehb):

async def main():
async with wom_client, contextlib.AsyncExitStack() as stack:
await discord_client.start(discord_token)
try:
await asyncio.Future() # run forever
except asyncio.CancelledError:
await discord_client.close()
bot_state.list_all_members_and_ranks = list_all_members_and_ranks
bot_state.check_for_rank_changes = check_for_rank_changes
bot_state.refresh_group_data = refresh_group_data
bot_state.log_func = log
bot_state.bot_started_at = datetime.now()

tasks_to_run = [discord_client.start(discord_token)]

if web_enabled:
web_app = create_app(bot_state)
uvi_config = uvicorn.Config(
web_app, host=web_host, port=web_port, log_level="info"
)
server = uvicorn.Server(uvi_config)
tasks_to_run.append(server.serve())
log(f"Web interface starting on http://{web_host}:{web_port}")

await asyncio.gather(*tasks_to_run)

try:
loop.run_until_complete(main())
Expand All @@ -431,4 +470,3 @@ async def main():
except Exception as e:
print(f"Error during final cleanup: {e}")
print("Cleanup complete. Goodbye!")

5 changes: 4 additions & 1 deletion python/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@ wom.py>=0.1.0
aiohttp>=3.9.1
asyncio>=3.4.3
requests
pytest
pytest
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
jinja2>=3.1.0
5 changes: 5 additions & 0 deletions python/web/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Web interface for WOMupdtr."""

from .app import create_app

__all__ = ["create_app"]
40 changes: 40 additions & 0 deletions python/web/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""FastAPI application factory for the WOMupdtr web interface."""

from __future__ import annotations

import os
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles

from .services.bot_state import BotState

_BASE_DIR = os.path.dirname(os.path.abspath(__file__))


def create_app(state: BotState) -> FastAPI:
"""Build and return the configured FastAPI application."""

@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.bot_state = state
yield

app = FastAPI(title="WOMupdtr Dashboard", lifespan=lifespan)

# Static files
static_dir = os.path.join(_BASE_DIR, "static")
app.mount("/static", StaticFiles(directory=static_dir), name="static")

# Register routers (imported here to avoid circular imports)
from .routers import admin, charts, dashboard, group, players, reports

app.include_router(dashboard.router)
app.include_router(players.router, prefix="/players", tags=["players"])
app.include_router(reports.router, prefix="/reports", tags=["reports"])
app.include_router(charts.router, prefix="/charts", tags=["charts"])
app.include_router(admin.router, prefix="/admin", tags=["admin"])
app.include_router(group.router, prefix="/group", tags=["group"])

return app
10 changes: 10 additions & 0 deletions python/web/dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""FastAPI dependency injection helpers."""

from fastapi import Request

from .services.bot_state import BotState


def get_bot_state(request: Request) -> BotState:
"""Retrieve the shared BotState from the FastAPI application."""
return request.app.state.bot_state
Empty file added python/web/routers/__init__.py
Empty file.
75 changes: 75 additions & 0 deletions python/web/routers/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Admin router - bot control, logs, config."""

from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates

from ..dependencies import get_bot_state
from ..services.bot_state import BotState

import os

router = APIRouter()
templates = Jinja2Templates(
directory=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "templates")
)


@router.get("/", response_class=HTMLResponse)
async def admin_page(request: Request, state: BotState = Depends(get_bot_state)):
return templates.TemplateResponse("admin.html", {
"request": request,
"bot_state": state,
})


@router.post("/force-check", response_class=HTMLResponse)
async def force_check(state: BotState = Depends(get_bot_state)):
if state.check_for_rank_changes and hasattr(state.check_for_rank_changes, '__call__'):
try:
await state.check_for_rank_changes()
return HTMLResponse('<p class="success">Rank check triggered successfully.</p>')
except Exception as e:
return HTMLResponse(f'<p class="error">Error: {e}</p>')
return HTMLResponse('<p class="error">Rank check function not available.</p>')


@router.post("/refresh-group", response_class=HTMLResponse)
async def refresh_group(state: BotState = Depends(get_bot_state)):
if state.refresh_group_data:
try:
msg = await state.refresh_group_data()
return HTMLResponse(f'<p>{msg}</p>')
except Exception as e:
return HTMLResponse(f'<p class="error">Error: {e}</p>')
return HTMLResponse('<p class="error">Refresh function not available.</p>')


@router.get("/logs", response_class=HTMLResponse)
async def get_logs(request: Request, state: BotState = Depends(get_bot_state)):
log_lines = list(state.log_buffer)[-100:] # Last 100 lines
return templates.TemplateResponse("partials/log_feed.html", {
"request": request,
"log_lines": log_lines,
})


@router.get("/status")
async def bot_status(state: BotState = Depends(get_bot_state)):
return JSONResponse(content={
"bot_started_at": state.bot_started_at.isoformat() if state.bot_started_at else None,
"last_rank_check": state.last_rank_check.isoformat() if state.last_rank_check else None,
"last_group_refresh": state.last_group_refresh.isoformat() if state.last_group_refresh else None,
"check_interval": state.check_interval,
"silent": state.silent,
"debug": state.debug,
"post_to_discord": state.post_to_discord,
})


@router.post("/config", response_class=HTMLResponse)
async def update_config(request: Request, state: BotState = Depends(get_bot_state)):
form = await request.form()
state.silent = "silent" in form
state.debug = "debug" in form
return HTMLResponse('<p class="success">Configuration updated.</p>')
42 changes: 42 additions & 0 deletions python/web/routers/charts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Charts router - chart pages and JSON data APIs."""

from fastapi import APIRouter, Depends, Request, Query
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates

from ..services.ranks_service import get_all_players_sorted, get_rank_distribution
from ..services.csv_service import get_player_ehb_history

import os

router = APIRouter()
templates = Jinja2Templates(
directory=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "templates")
)


@router.get("/", response_class=HTMLResponse)
async def charts_page(request: Request):
players = get_all_players_sorted()
return templates.TemplateResponse("chart.html", {
"request": request,
"players": players,
})


@router.get("/api/ehb-history")
async def ehb_history_api(player: str = Query(...)):
history = get_player_ehb_history(player)
return JSONResponse(content=history)


@router.get("/api/rank-distribution")
async def rank_distribution_api():
dist = get_rank_distribution()
return JSONResponse(content=dist)


@router.get("/api/top-players")
async def top_players_api(limit: int = Query(15)):
players = get_all_players_sorted()[:limit]
return JSONResponse(content=players)
32 changes: 32 additions & 0 deletions python/web/routers/dashboard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Dashboard router - main landing page."""

from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates

from ..dependencies import get_bot_state
from ..services.bot_state import BotState
from ..services.ranks_service import get_all_players_sorted, get_rank_distribution
from ..services.csv_service import get_recent_changes

import os

router = APIRouter()
templates = Jinja2Templates(
directory=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "templates")
)


@router.get("/", response_class=HTMLResponse)
async def dashboard(request: Request, state: BotState = Depends(get_bot_state)):
players = get_all_players_sorted()
rank_dist = get_rank_distribution()
recent = get_recent_changes(limit=20)
return templates.TemplateResponse("dashboard.html", {
"request": request,
"players": players,
"rank_distribution": rank_dist,
"recent_changes": recent,
"bot_state": state,
"player_count": len(players),
})
Loading
Loading