feat: add rcon admin log parser
This commit is contained in:
248
backend/app/rcon_admin_log_parser.py
Normal file
248
backend/app/rcon_admin_log_parser.py
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
"""Parser for Hell Let Loose RCON admin log messages."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
RconAdminLogEventType = Literal[
|
||||||
|
"match_start",
|
||||||
|
"match_end",
|
||||||
|
"kill",
|
||||||
|
"team_switch",
|
||||||
|
"connected",
|
||||||
|
"disconnected",
|
||||||
|
"chat",
|
||||||
|
"kick",
|
||||||
|
"ban",
|
||||||
|
"message",
|
||||||
|
"unknown",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
_PREFIX_RE = re.compile(
|
||||||
|
r"^\[(?P<relative>.+?)\s+\((?P<server_time>\d+)\)\]\s+(?P<body>.*)$",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
MATCH_START_RE = re.compile(
|
||||||
|
r"^MATCH START\s+(?P<map_name>.+?)\s+(?P<game_mode>[A-Za-z]+)\s*$",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
MATCH_END_RE = re.compile(
|
||||||
|
r"^MATCH ENDED\s+`(?P<map_name>.+?)`\s+ALLIED\s+\((?P<allied_score>\d+)\s*-\s*(?P<axis_score>\d+)\)\s+AXIS\s*$",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
KILL_RE = re.compile(
|
||||||
|
r"^KILL:\s+"
|
||||||
|
r"(?P<killer_name>.+?)"
|
||||||
|
r"\((?P<killer_team>Allies|Axis|None)/(?P<killer_id>[^)]*)\)"
|
||||||
|
r"\s+->\s+"
|
||||||
|
r"(?P<victim_name>.+?)"
|
||||||
|
r"\((?P<victim_team>Allies|Axis|None)/(?P<victim_id>[^)]*)\)"
|
||||||
|
r"\s+with\s+(?P<weapon>.+?)\s*$",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
TEAM_SWITCH_RE = re.compile(
|
||||||
|
r"^TEAMSWITCH\s+(?P<player_name>.+?)\s+\((?P<from_team>[^>]*)\s+>\s+(?P<to_team>[^)]*)\)\s*$",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
CONNECTED_RE = re.compile(
|
||||||
|
r"^CONNECTED\s+(?P<player_name>.+?)\s+\((?P<player_id>[^)]*)\)\s*$",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
DISCONNECTED_RE = re.compile(
|
||||||
|
r"^DISCONNECTED\s+(?P<player_name>.+?)\s+\((?P<player_id>[^)]*)\)\s*$",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
CHAT_RE = re.compile(
|
||||||
|
r"^CHAT\[(?P<scope>[^\]]+)\]\[(?P<player_name>.+?)\((?P<team>Allies|Axis|None)/(?P<player_id>[^)]*)\)\]:\s*(?P<content>.*)$",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
KICK_RE = re.compile(
|
||||||
|
r"^KICK:\s+\[(?P<player_name>.+?)\]\s+has been kicked\.\s+\[(?P<reason>.*)\]\s*$",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
MESSAGE_RE = re.compile(
|
||||||
|
r"^MESSAGE:\s+player\s+\[(?P<player_name>.+?)\((?P<player_id>[^)]*)\)\],\s+content\s+\[(?P<content>.*)\]\s*$",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ParsedRconAdminLogEvent:
|
||||||
|
event_type: RconAdminLogEventType
|
||||||
|
raw_message: str
|
||||||
|
relative_time: str | None = None
|
||||||
|
server_time: int | None = None
|
||||||
|
map_name: str | None = None
|
||||||
|
game_mode: str | None = None
|
||||||
|
allied_score: int | None = None
|
||||||
|
axis_score: int | None = None
|
||||||
|
winner: str | None = None
|
||||||
|
killer_name: str | None = None
|
||||||
|
killer_team: str | None = None
|
||||||
|
killer_id: str | None = None
|
||||||
|
victim_name: str | None = None
|
||||||
|
victim_team: str | None = None
|
||||||
|
victim_id: str | None = None
|
||||||
|
weapon: str | None = None
|
||||||
|
player_name: str | None = None
|
||||||
|
player_id: str | None = None
|
||||||
|
from_team: str | None = None
|
||||||
|
to_team: str | None = None
|
||||||
|
chat_scope: str | None = None
|
||||||
|
chat_team: str | None = None
|
||||||
|
content: str | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rcon_admin_log_message(message: str) -> ParsedRconAdminLogEvent:
|
||||||
|
raw_message = str(message or "")
|
||||||
|
prefix_match = _PREFIX_RE.match(raw_message)
|
||||||
|
relative_time = None
|
||||||
|
server_time = None
|
||||||
|
body = raw_message
|
||||||
|
|
||||||
|
if prefix_match:
|
||||||
|
relative_time = prefix_match.group("relative")
|
||||||
|
server_time = _coerce_int(prefix_match.group("server_time"))
|
||||||
|
body = prefix_match.group("body")
|
||||||
|
|
||||||
|
parser_payload = {
|
||||||
|
"raw_message": raw_message,
|
||||||
|
"relative_time": relative_time,
|
||||||
|
"server_time": server_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
if match := MATCH_START_RE.match(body):
|
||||||
|
return ParsedRconAdminLogEvent(
|
||||||
|
event_type="match_start",
|
||||||
|
map_name=_clean(match.group("map_name")),
|
||||||
|
game_mode=_clean(match.group("game_mode")),
|
||||||
|
**parser_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if match := MATCH_END_RE.match(body):
|
||||||
|
allied_score = _coerce_int(match.group("allied_score"))
|
||||||
|
axis_score = _coerce_int(match.group("axis_score"))
|
||||||
|
return ParsedRconAdminLogEvent(
|
||||||
|
event_type="match_end",
|
||||||
|
map_name=_clean(match.group("map_name")),
|
||||||
|
allied_score=allied_score,
|
||||||
|
axis_score=axis_score,
|
||||||
|
winner=_resolve_winner(allied_score, axis_score),
|
||||||
|
**parser_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if match := KILL_RE.match(body):
|
||||||
|
return ParsedRconAdminLogEvent(
|
||||||
|
event_type="kill",
|
||||||
|
killer_name=_clean(match.group("killer_name")),
|
||||||
|
killer_team=_clean(match.group("killer_team")),
|
||||||
|
killer_id=_clean(match.group("killer_id")),
|
||||||
|
victim_name=_clean(match.group("victim_name")),
|
||||||
|
victim_team=_clean(match.group("victim_team")),
|
||||||
|
victim_id=_clean(match.group("victim_id")),
|
||||||
|
weapon=_clean(match.group("weapon")),
|
||||||
|
**parser_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if match := TEAM_SWITCH_RE.match(body):
|
||||||
|
return ParsedRconAdminLogEvent(
|
||||||
|
event_type="team_switch",
|
||||||
|
player_name=_clean(match.group("player_name")),
|
||||||
|
from_team=_clean(match.group("from_team")),
|
||||||
|
to_team=_clean(match.group("to_team")),
|
||||||
|
**parser_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if match := CONNECTED_RE.match(body):
|
||||||
|
return ParsedRconAdminLogEvent(
|
||||||
|
event_type="connected",
|
||||||
|
player_name=_clean(match.group("player_name")),
|
||||||
|
player_id=_clean(match.group("player_id")),
|
||||||
|
**parser_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if match := DISCONNECTED_RE.match(body):
|
||||||
|
return ParsedRconAdminLogEvent(
|
||||||
|
event_type="disconnected",
|
||||||
|
player_name=_clean(match.group("player_name")),
|
||||||
|
player_id=_clean(match.group("player_id")),
|
||||||
|
**parser_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if match := CHAT_RE.match(body):
|
||||||
|
return ParsedRconAdminLogEvent(
|
||||||
|
event_type="chat",
|
||||||
|
player_name=_clean(match.group("player_name")),
|
||||||
|
player_id=_clean(match.group("player_id")),
|
||||||
|
chat_scope=_clean(match.group("scope")),
|
||||||
|
chat_team=_clean(match.group("team")),
|
||||||
|
content=_clean(match.group("content")),
|
||||||
|
**parser_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if match := KICK_RE.match(body):
|
||||||
|
return ParsedRconAdminLogEvent(
|
||||||
|
event_type="kick",
|
||||||
|
player_name=_clean(match.group("player_name")),
|
||||||
|
reason=_clean(match.group("reason")),
|
||||||
|
**parser_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if body.upper().startswith("BAN"):
|
||||||
|
return ParsedRconAdminLogEvent(event_type="ban", content=_clean(body), **parser_payload)
|
||||||
|
|
||||||
|
if match := MESSAGE_RE.match(body):
|
||||||
|
return ParsedRconAdminLogEvent(
|
||||||
|
event_type="message",
|
||||||
|
player_name=_clean(match.group("player_name")),
|
||||||
|
player_id=_clean(match.group("player_id")),
|
||||||
|
content=_clean(match.group("content")),
|
||||||
|
**parser_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ParsedRconAdminLogEvent(event_type="unknown", content=_clean(body), **parser_payload)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rcon_admin_log_entry(entry: dict[str, object]) -> dict[str, object]:
|
||||||
|
parsed = parse_rcon_admin_log_message(str(entry.get("message") or ""))
|
||||||
|
payload = asdict(parsed)
|
||||||
|
payload["timestamp"] = entry.get("timestamp")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
normalized = value.strip()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_int(value: object) -> int | None:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_winner(allied_score: int | None, axis_score: int | None) -> str | None:
|
||||||
|
if allied_score is None or axis_score is None:
|
||||||
|
return None
|
||||||
|
if allied_score > axis_score:
|
||||||
|
return "allied"
|
||||||
|
if axis_score > allied_score:
|
||||||
|
return "axis"
|
||||||
|
return "draw"
|
||||||
107
backend/tests/test_rcon_admin_log_parser.py
Normal file
107
backend/tests/test_rcon_admin_log_parser.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
from app.rcon_admin_log_parser import parse_rcon_admin_log_message
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_match_start():
|
||||||
|
parsed = parse_rcon_admin_log_message(
|
||||||
|
"[2:09:15 hours (1779178245)] MATCH START UTAH BEACH Warfare"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert parsed.event_type == "match_start"
|
||||||
|
assert parsed.server_time == 1779178245
|
||||||
|
assert parsed.map_name == "UTAH BEACH"
|
||||||
|
assert parsed.game_mode == "Warfare"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_match_end():
|
||||||
|
parsed = parse_rcon_admin_log_message(
|
||||||
|
"[20:36:53 hours (1779111786)] MATCH ENDED `ST MARIE DU MONT Warfare` ALLIED (5 - 0) AXIS "
|
||||||
|
)
|
||||||
|
|
||||||
|
assert parsed.event_type == "match_end"
|
||||||
|
assert parsed.map_name == "ST MARIE DU MONT Warfare"
|
||||||
|
assert parsed.allied_score == 5
|
||||||
|
assert parsed.axis_score == 0
|
||||||
|
assert parsed.winner == "allied"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_kill():
|
||||||
|
parsed = parse_rcon_admin_log_message(
|
||||||
|
"[1:20:19 hours (1779181181)] KILL: AntonioPruna(Allies/76561198000000000) -> "
|
||||||
|
"[7DV] NEⓇA TACTICAL FEMB✡Y(Axis/76561199000000000) with M1 GARAND"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert parsed.event_type == "kill"
|
||||||
|
assert parsed.killer_name == "AntonioPruna"
|
||||||
|
assert parsed.killer_team == "Allies"
|
||||||
|
assert parsed.killer_id == "76561198000000000"
|
||||||
|
assert parsed.victim_name == "[7DV] NEⓇA TACTICAL FEMB✡Y"
|
||||||
|
assert parsed.victim_team == "Axis"
|
||||||
|
assert parsed.victim_id == "76561199000000000"
|
||||||
|
assert parsed.weapon == "M1 GARAND"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_team_switch():
|
||||||
|
parsed = parse_rcon_admin_log_message(
|
||||||
|
"[21:34:19 hours (1779108340)] TEAMSWITCH Ekenef (None > Allies)"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert parsed.event_type == "team_switch"
|
||||||
|
assert parsed.player_name == "Ekenef"
|
||||||
|
assert parsed.from_team == "None"
|
||||||
|
assert parsed.to_team == "Allies"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_connected():
|
||||||
|
parsed = parse_rcon_admin_log_message(
|
||||||
|
"[21:34:22 hours (1779108337)] CONNECTED Ekenef (76561198109813520)"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert parsed.event_type == "connected"
|
||||||
|
assert parsed.player_name == "Ekenef"
|
||||||
|
assert parsed.player_id == "76561198109813520"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_disconnected():
|
||||||
|
parsed = parse_rcon_admin_log_message(
|
||||||
|
"[21:10:53 hours (1779109746)] DISCONNECTED [BxB] Rab◯l◯k◯ (76561198111111111)"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert parsed.event_type == "disconnected"
|
||||||
|
assert parsed.player_name == "[BxB] Rab◯l◯k◯"
|
||||||
|
assert parsed.player_id == "76561198111111111"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_chat():
|
||||||
|
parsed = parse_rcon_admin_log_message(
|
||||||
|
"[18:38:35 hours (1779118884)] CHAT[Team][BXB Ivanxu(Axis/6215e24a1f05c5815ed9e8bf185f94fd)]: !vip"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert parsed.event_type == "chat"
|
||||||
|
assert parsed.chat_scope == "Team"
|
||||||
|
assert parsed.player_name == "BXB Ivanxu"
|
||||||
|
assert parsed.chat_team == "Axis"
|
||||||
|
assert parsed.player_id == "6215e24a1f05c5815ed9e8bf185f94fd"
|
||||||
|
assert parsed.content == "!vip"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_kick():
|
||||||
|
parsed = parse_rcon_admin_log_message(
|
||||||
|
"[2:09:10 hours (1779178249)] KICK: [[7DV] NEⓇA TACTICAL FEMB✡Y] has been kicked. "
|
||||||
|
"[Making free spaces for members of the Spanish Discord community.]"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert parsed.event_type == "kick"
|
||||||
|
assert parsed.player_name == "[7DV] NEⓇA TACTICAL FEMB✡Y"
|
||||||
|
assert "Making free spaces" in parsed.reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_message_profile():
|
||||||
|
parsed = parse_rcon_admin_log_message(
|
||||||
|
"[21:34:19 hours (1779108340)] MESSAGE: player [Ekenef(76561198109813520)], "
|
||||||
|
"content [─ Ekenef ─\\n▒ Totales ▒\\nbajas : 141 (6 TKs)\\nmuertes : 268 (5 TKs)]"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert parsed.event_type == "message"
|
||||||
|
assert parsed.player_name == "Ekenef"
|
||||||
|
assert parsed.player_id == "76561198109813520"
|
||||||
|
assert "bajas : 141" in parsed.content
|
||||||
Reference in New Issue
Block a user