Files
comunidadhll/backend/app/rcon_admin_log_storage.py
2026-05-21 15:18:03 +02:00

590 lines
22 KiB
Python

"""Storage helpers for parsed RCON AdminLog events."""
from __future__ import annotations
import json
import re
import sqlite3
from collections.abc import Mapping
from contextlib import closing
from pathlib import Path
from .config import get_storage_path, use_postgres_rcon_storage
from .rcon_admin_log_parser import parse_rcon_admin_log_entry
from .rcon_admin_log_parser import parse_rcon_player_profile_snapshot
from .rcon_historical_storage import initialize_rcon_historical_storage
from .sqlite_utils import connect_sqlite_writer
def initialize_rcon_admin_log_storage(*, db_path: Path | None = None) -> Path:
"""Create SQLite structures for parsed RCON AdminLog events."""
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import initialize_postgres_rcon_storage
initialize_postgres_rcon_storage()
return get_storage_path()
resolved_path = initialize_rcon_historical_storage(db_path=db_path)
with connect_sqlite_writer(resolved_path) as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS rcon_admin_log_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_key TEXT NOT NULL,
external_server_id TEXT,
event_timestamp TEXT,
server_time INTEGER,
relative_time TEXT,
event_type TEXT NOT NULL,
raw_message TEXT NOT NULL,
canonical_message TEXT NOT NULL,
parsed_payload_json TEXT NOT NULL,
raw_entry_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_rcon_admin_log_events_dedupe
ON rcon_admin_log_events(target_key, server_time, canonical_message);
CREATE INDEX IF NOT EXISTS idx_rcon_admin_log_events_target_time
ON rcon_admin_log_events(target_key, server_time DESC);
CREATE INDEX IF NOT EXISTS idx_rcon_admin_log_events_type
ON rcon_admin_log_events(event_type);
CREATE TABLE IF NOT EXISTS rcon_player_profile_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_key TEXT NOT NULL,
external_server_id TEXT,
player_id TEXT NOT NULL,
player_name TEXT NOT NULL,
source_server_time INTEGER NOT NULL,
event_timestamp TEXT,
first_seen TEXT,
sessions INTEGER,
matches_played INTEGER,
play_time TEXT,
total_kills INTEGER,
total_deaths INTEGER,
teamkills_done INTEGER,
teamkills_received INTEGER,
kd_ratio REAL,
favorite_weapons_json TEXT NOT NULL DEFAULT '{}',
victims_json TEXT NOT NULL DEFAULT '{}',
nemesis_json TEXT NOT NULL DEFAULT '{}',
averages_json TEXT NOT NULL DEFAULT '{}',
sanctions_json TEXT NOT NULL DEFAULT '{}',
raw_content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(target_key, player_id, source_server_time)
);
CREATE INDEX IF NOT EXISTS idx_rcon_player_profile_snapshots_player
ON rcon_player_profile_snapshots(target_key, player_id, source_server_time DESC);
"""
)
_ensure_canonical_message_column(connection)
return resolved_path
def persist_rcon_admin_log_entries(
*,
target: Mapping[str, object],
entries: list[dict[str, object]],
db_path: Path | None = None,
) -> dict[str, int]:
"""Persist raw and parsed AdminLog entries idempotently."""
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
return _persist_rcon_admin_log_entries_postgres(target=target, entries=entries)
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
target_key = str(target.get("target_key") or target.get("external_server_id") or "")
if not target_key:
raise ValueError("target must include target_key or external_server_id")
external_server_id = target.get("external_server_id")
inserted = 0
duplicates = 0
with connect_sqlite_writer(resolved_path) as connection:
for entry in entries:
parsed = parse_rcon_admin_log_entry(entry)
raw_message = str(parsed.get("raw_message") or "")
canonical_message = _canonicalize_admin_log_message(raw_message)
cursor = connection.execute(
"""
INSERT INTO rcon_admin_log_events (
target_key,
external_server_id,
event_timestamp,
server_time,
relative_time,
event_type,
raw_message,
canonical_message,
parsed_payload_json,
raw_entry_json
)
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
WHERE NOT EXISTS (
SELECT 1
FROM rcon_admin_log_events
WHERE target_key = ?
AND server_time IS ?
AND canonical_message = ?
)
""",
(
target_key,
external_server_id,
parsed.get("timestamp"),
parsed.get("server_time"),
parsed.get("relative_time"),
parsed.get("event_type") or "unknown",
raw_message,
canonical_message,
json.dumps(parsed, ensure_ascii=False, separators=(",", ":")),
json.dumps(entry, ensure_ascii=False, separators=(",", ":")),
target_key,
parsed.get("server_time"),
canonical_message,
),
)
if int(cursor.rowcount or 0):
inserted += 1
else:
duplicates += 1
_persist_profile_snapshot_if_present(
connection,
target_key=target_key,
external_server_id=external_server_id,
parsed=parsed,
)
return {
"events_seen": len(entries),
"events_inserted": inserted,
"duplicate_events": duplicates,
}
def _persist_profile_snapshot_if_present(
connection: sqlite3.Connection,
*,
target_key: str,
external_server_id: object,
parsed: dict[str, object],
) -> None:
snapshot = parse_rcon_player_profile_snapshot(parsed)
if snapshot is None:
return
connection.execute(
"""
INSERT INTO rcon_player_profile_snapshots (
target_key,
external_server_id,
player_id,
player_name,
source_server_time,
event_timestamp,
first_seen,
sessions,
matches_played,
play_time,
total_kills,
total_deaths,
teamkills_done,
teamkills_received,
kd_ratio,
favorite_weapons_json,
victims_json,
nemesis_json,
averages_json,
sanctions_json,
raw_content
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(target_key, player_id, source_server_time) DO UPDATE SET
external_server_id = excluded.external_server_id,
player_name = excluded.player_name,
event_timestamp = excluded.event_timestamp,
first_seen = excluded.first_seen,
sessions = excluded.sessions,
matches_played = excluded.matches_played,
play_time = excluded.play_time,
total_kills = excluded.total_kills,
total_deaths = excluded.total_deaths,
teamkills_done = excluded.teamkills_done,
teamkills_received = excluded.teamkills_received,
kd_ratio = excluded.kd_ratio,
favorite_weapons_json = excluded.favorite_weapons_json,
victims_json = excluded.victims_json,
nemesis_json = excluded.nemesis_json,
averages_json = excluded.averages_json,
sanctions_json = excluded.sanctions_json,
raw_content = excluded.raw_content,
updated_at = CURRENT_TIMESTAMP
""",
(
target_key,
external_server_id,
snapshot.player_id,
snapshot.player_name,
snapshot.source_server_time,
snapshot.event_timestamp,
snapshot.first_seen,
snapshot.sessions,
snapshot.matches_played,
snapshot.play_time,
snapshot.total_kills,
snapshot.total_deaths,
snapshot.teamkills_done,
snapshot.teamkills_received,
snapshot.kd_ratio,
json.dumps(snapshot.favorite_weapons, ensure_ascii=False, separators=(",", ":")),
json.dumps(snapshot.victims, ensure_ascii=False, separators=(",", ":")),
json.dumps(snapshot.nemesis, ensure_ascii=False, separators=(",", ":")),
json.dumps(snapshot.averages, ensure_ascii=False, separators=(",", ":")),
json.dumps(snapshot.sanctions, ensure_ascii=False, separators=(",", ":")),
snapshot.raw_content,
),
)
_PREFIX_RE = re.compile(r"^\[.*?\(\d+\)\]\s+", re.DOTALL)
def _canonicalize_admin_log_message(raw_message: str) -> str:
"""Return a stable message body for deduplication across repeated AdminLog reads."""
normalized = str(raw_message or "").strip()
return _PREFIX_RE.sub("", normalized).strip()
def _ensure_canonical_message_column(connection: sqlite3.Connection) -> None:
columns = {
row["name"]
for row in connection.execute("PRAGMA table_info(rcon_admin_log_events)").fetchall()
}
if "canonical_message" not in columns:
connection.execute(
"ALTER TABLE rcon_admin_log_events ADD COLUMN canonical_message TEXT NOT NULL DEFAULT ''"
)
connection.execute(
"""
UPDATE rcon_admin_log_events
SET canonical_message = raw_message
WHERE canonical_message = ''
"""
)
connection.execute(
"""
CREATE INDEX IF NOT EXISTS idx_rcon_admin_log_events_dedupe
ON rcon_admin_log_events(target_key, server_time, canonical_message)
"""
)
def list_rcon_admin_log_event_counts(*, db_path: Path | None = None) -> list[dict[str, object]]:
"""Return event counts grouped by target and event type."""
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import connect_postgres_compat
with connect_postgres_compat() as connection:
rows = connection.execute(
"""
SELECT
target_key,
event_type,
COUNT(*) AS event_count,
MIN(server_time) AS first_server_time,
MAX(server_time) AS last_server_time
FROM rcon_admin_log_events
GROUP BY target_key, event_type
ORDER BY target_key ASC, event_count DESC
"""
).fetchall()
return [dict(row) for row in rows]
resolved_path = db_path or get_storage_path()
initialize_rcon_admin_log_storage(db_path=resolved_path)
with sqlite3.connect(resolved_path) as connection:
connection.row_factory = sqlite3.Row
rows = connection.execute(
"""
SELECT
target_key,
event_type,
COUNT(*) AS event_count,
MIN(server_time) AS first_server_time,
MAX(server_time) AS last_server_time
FROM rcon_admin_log_events
GROUP BY target_key, event_type
ORDER BY target_key ASC, event_count DESC
"""
).fetchall()
return [dict(row) for row in rows]
def list_current_match_kill_feed(
*,
server_key: str,
limit: int = 30,
db_path: Path | None = None,
) -> dict[str, object]:
"""Return safe recent kill rows for one AdminLog server window."""
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import connect_postgres_compat
connection_scope = connect_postgres_compat()
else:
connection_scope = closing(sqlite3.connect(resolved_path))
with connection_scope as connection:
if isinstance(connection, sqlite3.Connection):
connection.row_factory = sqlite3.Row
boundary = connection.execute(
"""
SELECT event_type, server_time
FROM rcon_admin_log_events
WHERE (target_key = ? OR external_server_id = ?)
AND event_type IN ('match_start', 'match_end')
AND server_time IS NOT NULL
ORDER BY server_time DESC, id DESC
LIMIT 1
""",
(server_key, server_key),
).fetchone()
open_start_time = (
boundary["server_time"]
if boundary is not None and boundary["event_type"] == "match_start"
else None
)
if open_start_time is None:
rows = connection.execute(
"""
SELECT id, target_key, external_server_id, event_timestamp, server_time,
parsed_payload_json
FROM rcon_admin_log_events
WHERE (target_key = ? OR external_server_id = ?)
AND event_type = 'kill'
ORDER BY server_time DESC, id DESC
LIMIT ?
""",
(server_key, server_key, limit),
).fetchall()
scope = "recent-admin-log-window"
confidence = "partial"
else:
rows = connection.execute(
"""
SELECT id, target_key, external_server_id, event_timestamp, server_time,
parsed_payload_json
FROM rcon_admin_log_events
WHERE (target_key = ? OR external_server_id = ?)
AND event_type = 'kill'
AND server_time >= ?
ORDER BY server_time DESC, id DESC
LIMIT ?
""",
(server_key, server_key, open_start_time, limit),
).fetchall()
scope = "open-admin-log-match-window"
confidence = "admin-log-boundary"
return {
"scope": scope,
"confidence": confidence,
"items": [_serialize_kill_feed_row(row) for row in rows],
}
def get_latest_rcon_player_profile_summaries(
*,
target_key: str,
player_ids: list[str],
db_path: Path | None = None,
) -> dict[str, dict[str, object]]:
"""Return safe latest profile summaries keyed by player id."""
requested_ids = [str(player_id).strip() for player_id in player_ids if str(player_id).strip()]
if not target_key or not requested_ids:
return {}
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
from .postgres_rcon_storage import connect_postgres_compat
placeholders = ",".join("?" for _ in requested_ids)
with connect_postgres_compat() as connection:
rows = connection.execute(
f"""
SELECT snapshots.*
FROM rcon_player_profile_snapshots AS snapshots
INNER JOIN (
SELECT player_id, MAX(source_server_time) AS latest_source_server_time
FROM rcon_player_profile_snapshots
WHERE target_key = ?
AND player_id IN ({placeholders})
GROUP BY player_id
) AS latest
ON latest.player_id = snapshots.player_id
AND latest.latest_source_server_time = snapshots.source_server_time
WHERE snapshots.target_key = ?
""",
[target_key, *requested_ids, target_key],
).fetchall()
return {str(row["player_id"]): _build_safe_profile_summary(row) for row in rows}
resolved_path = db_path or get_storage_path()
initialize_rcon_admin_log_storage(db_path=resolved_path)
placeholders = ",".join("?" for _ in requested_ids)
with sqlite3.connect(resolved_path) as connection:
connection.row_factory = sqlite3.Row
rows = connection.execute(
f"""
SELECT snapshots.*
FROM rcon_player_profile_snapshots AS snapshots
INNER JOIN (
SELECT player_id, MAX(source_server_time) AS latest_source_server_time
FROM rcon_player_profile_snapshots
WHERE target_key = ?
AND player_id IN ({placeholders})
GROUP BY player_id
) AS latest
ON latest.player_id = snapshots.player_id
AND latest.latest_source_server_time = snapshots.source_server_time
WHERE snapshots.target_key = ?
""",
[target_key, *requested_ids, target_key],
).fetchall()
return {str(row["player_id"]): _build_safe_profile_summary(row) for row in rows}
def _build_safe_profile_summary(row: sqlite3.Row) -> dict[str, object]:
return {
"player_name": row["player_name"],
"source_server_time": row["source_server_time"],
"event_timestamp": row["event_timestamp"],
"first_seen": row["first_seen"],
"sessions": row["sessions"],
"matches_played": row["matches_played"],
"play_time": row["play_time"],
"totals": {
"kills": row["total_kills"],
"deaths": row["total_deaths"],
"teamkills_done": row["teamkills_done"],
"teamkills_received": row["teamkills_received"],
"kd_ratio": row["kd_ratio"],
},
"favorite_weapons": _json_mapping(row["favorite_weapons_json"]),
"victims": _json_mapping(row["victims_json"]),
"nemesis": _json_mapping(row["nemesis_json"]),
"averages": _json_mapping(row["averages_json"]),
"sanctions": _json_mapping(row["sanctions_json"]),
}
def _json_mapping(raw_value: object) -> dict[str, object]:
if not isinstance(raw_value, str) or not raw_value.strip():
return {}
try:
parsed = json.loads(raw_value)
except json.JSONDecodeError:
return {}
return parsed if isinstance(parsed, dict) else {}
def _serialize_kill_feed_row(row: Mapping[str, object]) -> dict[str, object]:
payload = _json_mapping(row["parsed_payload_json"])
target_key = str(row["external_server_id"] or row["target_key"] or "unknown")
killer_team = _safe_event_field(payload.get("killer_team"))
victim_team = _safe_event_field(payload.get("victim_team"))
return {
"event_id": f"rcon-admin-log:{target_key}:{row['id']}",
"event_timestamp": row["event_timestamp"],
"server_time": row["server_time"],
"killer_name": _safe_event_field(payload.get("killer_name")),
"killer_team": killer_team,
"victim_name": _safe_event_field(payload.get("victim_name")),
"victim_team": victim_team,
"weapon": _safe_event_field(payload.get("weapon")),
"is_teamkill": bool(
killer_team
and killer_team != "None"
and killer_team == victim_team
),
}
def _safe_event_field(value: object) -> str | None:
normalized = str(value or "").strip()
return normalized or None
def _persist_rcon_admin_log_entries_postgres(
*,
target: Mapping[str, object],
entries: list[dict[str, object]],
) -> dict[str, int]:
from .postgres_rcon_storage import connect_postgres_compat
target_key = str(target.get("target_key") or target.get("external_server_id") or "")
if not target_key:
raise ValueError("target must include target_key or external_server_id")
external_server_id = target.get("external_server_id")
inserted = 0
duplicates = 0
with connect_postgres_compat() as connection:
for entry in entries:
parsed = parse_rcon_admin_log_entry(entry)
raw_message = str(parsed.get("raw_message") or "")
canonical_message = _canonicalize_admin_log_message(raw_message)
cursor = connection.execute(
"""
INSERT INTO rcon_admin_log_events (
target_key,
external_server_id,
event_timestamp,
server_time,
relative_time,
event_type,
raw_message,
canonical_message,
parsed_payload_json,
raw_entry_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO NOTHING
""",
(
target_key,
external_server_id,
parsed.get("timestamp"),
parsed.get("server_time"),
parsed.get("relative_time"),
parsed.get("event_type") or "unknown",
raw_message,
canonical_message,
json.dumps(parsed, ensure_ascii=False, separators=(",", ":")),
json.dumps(entry, ensure_ascii=False, separators=(",", ":")),
),
)
if int(cursor.rowcount or 0):
inserted += 1
else:
duplicates += 1
_persist_profile_snapshot_if_present(
connection,
target_key=target_key,
external_server_id=external_server_id,
parsed=parsed,
)
return {
"events_seen": len(entries),
"events_inserted": inserted,
"duplicate_events": duplicates,
}