Auto merge dev → main (#1353)

* feat(gateway): hashed key store with grant + hot reload

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(gateway): channel registry + aiohttp app (keyed auth, whoami, per-channel ws/proxy)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(gateway): manage_keys CLI (add/list/revoke)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(gateway): retire srebot_external, run relay-gateway under PM2

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(gateway): point ecosystem + README at relay-gateway

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tss): replay outbox producer for relay gateway

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tss): forward processed games to relay outbox

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tss-api): db helpers, app skeleton, info endpoint, fixtures

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tss-api): player, games, history, search endpoints

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tss-api): live, match, scoreboard, matches-search, maps

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tss-api): filter-required leaderboards (players/vehicles/stats)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(tss-api): tournament list/detail/standings/matches

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: wire tss upstream through gateway + tssbot-api PM2 app

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
NotSoToothless
2026-06-28 03:38:20 -07:00
committed by GitHub
parent f2a1a33c28
commit 24335a2677
18 changed files with 777 additions and 0 deletions
View File
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
from fastapi import APIRouter, FastAPI, Request
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from web.api import db
ENDPOINTS = [
"/api/tss/info", "/api/tss/live", "/api/tss/player/{uid}",
"/api/tss/player/{uid}/games", "/api/tss/player/{uid}/history",
"/api/tss/search/{nick}", "/api/tss/match/{session_id}",
"/api/tss/match/{session_id}/scoreboard", "/api/tss/matches/search",
"/api/tss/maps", "/api/tss/leaderboard/players",
"/api/tss/leaderboard/vehicles", "/api/tss/leaderboard/stats",
"/api/tss/tournaments", "/api/tss/tournament/{id}",
"/api/tss/tournament/{id}/standings", "/api/tss/tournament/{id}/matches",
]
info_router = APIRouter()
@info_router.get("/api/tss/info")
async def info() -> dict:
matches = await db.query_one(db.battles_path(), "SELECT COUNT(*) c FROM match_summary")
pg = await db.query_one(db.battles_path(), "SELECT COUNT(*) c FROM player_games_hist")
tn = await db.query_one(db.tournaments_path(), "SELECT COUNT(*) c FROM tournaments")
return {
"service": "tss",
"counts": {
"matches": matches["c"] if matches else 0,
"player_games": pg["c"] if pg else 0,
"tournaments": tn["c"] if tn else 0,
},
"endpoints": ENDPOINTS,
}
def create_app() -> FastAPI:
app = FastAPI(title="TSS HTTP API", version="1.0.0")
@app.exception_handler(StarletteHTTPException)
async def _err(_: Request, exc: StarletteHTTPException) -> JSONResponse:
detail = exc.detail
body = detail if isinstance(detail, dict) else {"error": str(detail)}
return JSONResponse(status_code=exc.status_code, content=body)
app.include_router(info_router)
from web.api.routes_players import router as players_router
from web.api.routes_matches import router as matches_router
from web.api.routes_leaderboard import router as lb_router
from web.api.routes_tournaments import router as tour_router
app.include_router(players_router)
app.include_router(matches_router)
app.include_router(lb_router)
app.include_router(tour_router)
return app
app = create_app()
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
import os
import sqlite3
import sys
from pathlib import Path
from typing import Any
import aiosqlite
# Make TSSBOT root importable for BOT.storage defaults.
_TSSBOT_ROOT = Path(__file__).resolve().parents[2]
if str(_TSSBOT_ROOT) not in sys.path:
sys.path.insert(0, str(_TSSBOT_ROOT))
def battles_path() -> Path:
override = os.getenv("TSS_API_BATTLES_DB", "").strip()
if override:
return Path(override)
from BOT.storage import TSS_BATTLES_DB_PATH
return TSS_BATTLES_DB_PATH
def tournaments_path() -> Path:
override = os.getenv("TSS_API_TOURNAMENTS_DB", "").strip()
if override:
return Path(override)
from BOT.storage import STORAGE_DIR
return STORAGE_DIR / "tss_tournaments.db"
def _ro_uri(path: Path) -> str:
return f"file:{path}?mode=ro"
async def query(path: Path, sql: str, params: tuple[Any, ...] = ()) -> list[dict]:
async with aiosqlite.connect(_ro_uri(path), uri=True) as conn:
conn.row_factory = sqlite3.Row
async with conn.execute(sql, params) as cur:
rows = await cur.fetchall()
return [dict(r) for r in rows]
async def query_one(path: Path, sql: str, params: tuple[Any, ...] = ()) -> dict | None:
rows = await query(path, sql, params)
return rows[0] if rows else None
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from web.api import db
router = APIRouter()
def _window(start_date, end_date, tournament_id):
"""Return (where_sql, params) or raise 400 if no filter given."""
clauses: list[str] = []
params: list = []
if tournament_id is not None:
clauses.append("p.session_id IN (SELECT session_id FROM match_summary WHERE tournament_id = ?)")
params.append(tournament_id)
if start_date is not None:
clauses.append("p.endtime_unix >= ?"); params.append(start_date)
if end_date is not None:
clauses.append("p.endtime_unix <= ?"); params.append(end_date)
if not clauses:
raise HTTPException(status_code=400, detail={
"error": "a date window (start_date/end_date) or tournament_id is required",
"code": "FILTER_REQUIRED"})
return "WHERE " + " AND ".join(clauses), params
@router.get("/api/tss/leaderboard/players")
async def leaderboard_players(start_date: int | None = None, end_date: int | None = None,
tournament_id: int | None = None, limit: int = 100) -> dict:
where, params = _window(start_date, end_date, tournament_id)
params.append(max(1, min(limit, 500)))
rows = await db.query(db.battles_path(), f"""
SELECT p.UID uid, MAX(p.nick) nick, COUNT(*) battles,
SUM(CASE WHEN p.victor_bool='Win' THEN 1 ELSE 0 END) wins,
SUM(p.air_kills+p.ground_kills) kills, SUM(p.deaths) deaths, SUM(p.score) score
FROM player_games_hist p {where}
GROUP BY p.UID ORDER BY score DESC LIMIT ?""", tuple(params))
for r in rows:
r["uid"] = str(r["uid"])
return {"players": rows}
@router.get("/api/tss/leaderboard/vehicles")
async def leaderboard_vehicles(start_date: int | None = None, end_date: int | None = None,
tournament_id: int | None = None, limit: int = 100) -> dict:
where, params = _window(start_date, end_date, tournament_id)
params.append(max(1, min(limit, 500)))
rows = await db.query(db.battles_path(), f"""
SELECT p.vehicle_internal, MAX(p.vehicle) vehicle, COUNT(*) battles,
SUM(p.air_kills+p.ground_kills) kills, SUM(p.deaths) deaths, SUM(p.score) score
FROM player_games_hist p {where}
GROUP BY p.vehicle_internal ORDER BY battles DESC LIMIT ?""", tuple(params))
return {"vehicles": rows}
@router.get("/api/tss/leaderboard/stats")
async def leaderboard_stats(start_date: int | None = None, end_date: int | None = None,
tournament_id: int | None = None) -> dict:
where, params = _window(start_date, end_date, tournament_id)
totals = await db.query_one(db.battles_path(), f"""
SELECT COUNT(*) battles, COUNT(DISTINCT p.UID) players,
SUM(p.air_kills+p.ground_kills) kills, SUM(p.score) score
FROM player_games_hist p {where}""", tuple(params))
top = await db.query(db.battles_path(), f"""
SELECT p.vehicle_internal, MAX(p.vehicle) vehicle, COUNT(*) battles
FROM player_games_hist p {where}
GROUP BY p.vehicle_internal ORDER BY battles DESC LIMIT 10""", tuple(params))
return {"totals": totals or {}, "top_vehicles": top}
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
import json
from fastapi import APIRouter, HTTPException
from web.api import db
router = APIRouter()
@router.get("/api/tss/live")
async def live(limit: int = 50) -> dict:
rows = await db.query(db.battles_path(),
"SELECT * FROM match_summary ORDER BY endtime_unix DESC LIMIT ?",
(max(1, min(limit, 200)),))
return {"total": len(rows), "matches": rows}
def _roster_team(rows: list[dict], slot: str, winning: str, losing: str, draw: int) -> dict:
players = [r for r in rows if (r.get("team_slot") or "") == slot]
for p in players:
p["UID"] = str(p["UID"])
return {
"team_slot": slot,
"team_name": players[0]["team_name"] if players else None,
"team_id": players[0]["team_id"] if players else None,
"is_winner": (not draw) and slot == winning,
"is_loser": (not draw) and slot == losing,
"players": players,
}
@router.get("/api/tss/match/{session_id}")
async def match(session_id: str) -> dict:
summary = await db.query_one(db.battles_path(),
"SELECT * FROM match_summary WHERE session_id = ?", (session_id,))
if not summary:
raise HTTPException(status_code=404, detail=f"match {session_id} not found")
rows = await db.query(db.battles_path(),
"SELECT * FROM player_games_hist WHERE session_id = ?", (session_id,))
slots = sorted({(r.get("team_slot") or "") for r in rows if r.get("team_slot")})
teams = [_roster_team(rows, s, summary["winning_slot"], summary["losing_slot"],
summary["draw"]) for s in slots]
return {"match": summary, "teams": teams}
@router.get("/api/tss/match/{session_id}/scoreboard")
async def scoreboard(session_id: str) -> dict:
base = await match(session_id) # reuses 404 + roster logic
logs_row = await db.query_one(db.battles_path(),
"SELECT chat_log_json, battle_log_json, event_log_json FROM match_logs WHERE session_id = ?",
(session_id,))
if logs_row:
def _parse(v):
try:
return json.loads(v) if v else None
except (json.JSONDecodeError, TypeError):
return None
logs = {"available": True,
"chat": _parse(logs_row["chat_log_json"]),
"battle": _parse(logs_row["battle_log_json"]),
"events": _parse(logs_row["event_log_json"])}
else:
logs = {"available": False}
return {**base, "logs": logs}
@router.get("/api/tss/matches/search")
async def matches_search(player: str | None = None, team: str | None = None,
mission: str | None = None, tournament: str | None = None,
time_from: int | None = None, time_to: int | None = None,
limit: int = 50) -> dict:
clauses: list[str] = []
params: list = []
join = ""
if player or team:
join = "JOIN player_games_hist p ON p.session_id = m.session_id"
if player:
clauses.append("p.UID = ?"); params.append(player)
if team:
clauses.append("p.team_name = ?"); params.append(team)
if mission:
clauses.append("m.mission_name LIKE ?"); params.append(f"%{mission}%")
if tournament:
clauses.append("m.tournament_name LIKE ?"); params.append(f"%{tournament}%")
if time_from is not None:
clauses.append("m.endtime_unix >= ?"); params.append(time_from)
if time_to is not None:
clauses.append("m.endtime_unix <= ?"); params.append(time_to)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
params.append(max(1, min(limit, 200)))
rows = await db.query(db.battles_path(),
f"SELECT DISTINCT m.* FROM match_summary m {join} {where} "
f"ORDER BY m.endtime_unix DESC LIMIT ?", tuple(params))
return {"total": len(rows), "matches": rows}
@router.get("/api/tss/maps")
async def maps() -> dict:
rows = await db.query(db.battles_path(),
"SELECT DISTINCT mission_name, level_path FROM match_summary "
"WHERE mission_name IS NOT NULL ORDER BY mission_name")
return {"total": len(rows), "maps": rows}
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from web.api import db
router = APIRouter()
@router.get("/api/tss/player/{uid}")
async def player(uid: str) -> dict:
bp = db.battles_path()
summary = await db.query_one(bp, """
SELECT COUNT(*) battles,
SUM(CASE WHEN victor_bool='Win' THEN 1 ELSE 0 END) wins,
SUM(CASE WHEN victor_bool!='Win' THEN 1 ELSE 0 END) losses,
SUM(ground_kills) ground_kills, SUM(air_kills) air_kills,
SUM(assists) assists, SUM(captures) captures, SUM(deaths) deaths,
SUM(score) score, AVG(pvp_ratio) avg_pvp_ratio
FROM player_games_hist WHERE UID = ?""", (uid,))
if not summary or summary["battles"] == 0:
raise HTTPException(status_code=404, detail=f"player {uid} not found")
vehicles = await db.query(bp, """
SELECT vehicle, vehicle_internal, COUNT(*) battles, SUM(air_kills+ground_kills) kills,
SUM(deaths) deaths, SUM(score) score
FROM player_games_hist WHERE UID = ?
GROUP BY vehicle_internal ORDER BY battles DESC""", (uid,))
nicks = await db.query(bp, """
SELECT nick, MIN(endtime_unix) first_seen, MAX(endtime_unix) last_seen
FROM player_games_hist WHERE UID = ? GROUP BY nick ORDER BY last_seen DESC""", (uid,))
team_history = await db.query(bp, """
SELECT team_id, team_name, tss_role, COUNT(*) battles,
MIN(endtime_unix) first_seen, MAX(endtime_unix) last_seen
FROM player_games_hist WHERE UID = ?
GROUP BY team_id, team_name, tss_role ORDER BY last_seen DESC""", (uid,))
win_rate = round(summary["wins"] / summary["battles"], 4) if summary["battles"] else 0.0
return {"uid": str(uid), "summary": {**summary, "win_rate": win_rate},
"vehicles": vehicles, "nicks": nicks, "team_history": team_history}
@router.get("/api/tss/player/{uid}/games")
async def player_games(uid: str, limit: int = 100,
time_from: int | None = None, time_to: int | None = None) -> dict:
clauses = ["p.UID = ?"]
params: list = [uid]
if time_from is not None:
clauses.append("p.endtime_unix >= ?"); params.append(time_from)
if time_to is not None:
clauses.append("p.endtime_unix <= ?"); params.append(time_to)
params.append(max(1, min(limit, 1000)))
rows = await db.query(db.battles_path(), f"""
SELECT p.*, m.mission_name, m.tournament_name
FROM player_games_hist p LEFT JOIN match_summary m ON m.session_id = p.session_id
WHERE {' AND '.join(clauses)} ORDER BY p.endtime_unix DESC LIMIT ?""", tuple(params))
return {"uid": str(uid), "total": len(rows), "games": rows}
@router.get("/api/tss/player/{uid}/history")
async def player_history(uid: str) -> dict:
rows = await db.query(db.battles_path(), """
SELECT date(endtime_unix, 'unixepoch') day, COUNT(*) battles,
SUM(CASE WHEN victor_bool='Win' THEN 1 ELSE 0 END) wins,
SUM(air_kills+ground_kills) kills, SUM(deaths) deaths, SUM(score) score
FROM player_games_hist WHERE UID = ? GROUP BY day ORDER BY day DESC""", (uid,))
return {"uid": str(uid), "days_with_battles_only": True, "history": rows}
@router.get("/api/tss/search/{nick}")
async def search(nick: str, limit: int = 25) -> dict:
rows = await db.query(db.battles_path(), """
SELECT UID uid, nick, MAX(endtime_unix) last_seen, COUNT(*) battles
FROM player_games_hist WHERE nick LIKE ? COLLATE NOCASE
GROUP BY UID, nick ORDER BY last_seen DESC LIMIT ?""",
(f"%{nick}%", max(1, min(limit, 100))))
for r in rows:
r["uid"] = str(r["uid"])
return {"query": nick, "players": rows}
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from web.api import db
router = APIRouter()
def _nullify(row: dict, keys: tuple[str, ...]) -> dict:
for k in keys:
if row.get(k) == "":
row[k] = None
return row
@router.get("/api/tss/tournaments")
async def tournaments(status: str | None = None, game_mode: str | None = None,
cluster: str | None = None, limit: int = 100) -> dict:
clauses: list[str] = []
params: list = []
if status:
clauses.append("status = ?"); params.append(status)
if game_mode:
clauses.append("game_mode = ?"); params.append(game_mode)
if cluster:
clauses.append("cluster = ?"); params.append(cluster)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
params.append(max(1, min(limit, 500)))
rows = await db.query(db.tournaments_path(),
f"SELECT * FROM tournaments {where} ORDER BY date_end DESC LIMIT ?", tuple(params))
return {"total": len(rows), "tournaments": rows}
async def _matches(tid: int) -> list[dict]:
rows = await db.query(db.tournaments_path(),
"SELECT * FROM tournament_matches WHERE tournament_id = ? ORDER BY round, position",
(tid,))
battles = await db.query(db.tournaments_path(),
"SELECT match_id, type_bracket, session_hex FROM tournament_battles WHERE tournament_id = ?",
(tid,))
by_match: dict[tuple, list[str]] = {}
for b in battles:
by_match.setdefault((b["match_id"], b["type_bracket"]), []).append(b["session_hex"])
out = []
for m in rows:
_nullify(m, ("round", "position"))
m["session_ids"] = by_match.get((m["match_id"], m["type_bracket"]), [])
out.append(m)
return out
@router.get("/api/tss/tournament/{tid}")
async def tournament(tid: int) -> dict:
meta = await db.query_one(db.tournaments_path(),
"SELECT * FROM tournaments WHERE tournament_id = ?", (tid,))
if not meta:
raise HTTPException(status_code=404, detail=f"tournament {tid} not found")
standings = await db.query(db.tournaments_path(),
"SELECT * FROM tournament_standings WHERE tournament_id = ? ORDER BY group_index, rank",
(tid,))
return {"tournament": meta, "standings": standings, "matches": await _matches(tid)}
@router.get("/api/tss/tournament/{tid}/standings")
async def standings(tid: int) -> dict:
rows = await db.query(db.tournaments_path(),
"SELECT * FROM tournament_standings WHERE tournament_id = ? ORDER BY group_index, rank",
(tid,))
return {"tournament_id": tid, "standings": rows}
@router.get("/api/tss/tournament/{tid}/matches")
async def tournament_matches(tid: int) -> dict:
rows = await _matches(tid)
return {"tournament_id": tid, "total": len(rows), "matches": rows}