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: 1 addition & 1 deletion discordgsm/games.csv
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ enshrouded,Enshrouded (2024),source,port=27015;port_query_offset=1
esf,Half Life: Earths Special Forces(2013),source,port=27015
etqw,Enemy Territory: Quake Wars (2007),doom3,port=3074;port_query=27733
ets2,Euro Truck Simulator 2,source,port_query=27016
exfil,EXFIL (2024),source,port_query=27015
exfil,EXFIL (2024),exfil,port_query=27015

f12002,Formula One 2002 (2002),gamespy1,port_query=3297
f1c9902,F1 Challenge '99-'02 (2002),gamespy1,port_query=34397
Expand Down
1 change: 1 addition & 0 deletions discordgsm/protocols/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .discord import Discord
from .doom3 import Doom3
from .eco import Eco
from .exfil import Exfil
from .factorio import Factorio
from .fivem import FiveM
from .front import Front
Expand Down
149 changes: 149 additions & 0 deletions discordgsm/protocols/exfil.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import asyncio
import time
from typing import TYPE_CHECKING

import aiohttp
import opengsq
from opengsq.responses.source import SourceInfo, GoldSourceInfo, Visibility

from discordgsm.protocols.protocol import Protocol

if TYPE_CHECKING:
from discordgsm.gamedig import GamedigResult


class Exfil(Protocol):
name = "exfil"

async def query(self):
host, port = str(self.kv["host"]), int(str(self.kv["port"]))
start = time.time()

# Stage 1: Try HTTP API first
try:
async with aiohttp.ClientSession() as session:
api_url = f"http://{host}:{port}/status"
async with session.get(api_url, timeout=aiohttp.ClientTimeout(total=3)) as response:
if response.status == 200:
data = await response.json()
if isinstance(data, dict):
result = await self._build_result_from_api(data, host, port, start, time.time())
return result
else:
pass
except asyncio.TimeoutError:
pass
except Exception:
pass

# Stage 2: Fallback to Source protocol (A2S query)
try:
source = opengsq.Source(host, port, self.timeout)

async def get_players():
try:
return await source.get_players()
except Exception:
return []

# Query info and players from Source protocol
info, players = await asyncio.gather(source.get_info(), get_players())

if isinstance(info, SourceInfo):
info: SourceInfo = info
connect = f"{host}:{info.port}"
elif isinstance(info, GoldSourceInfo):
info: GoldSourceInfo = info
connect = info.address
else:
raise Exception("Unknown SourceInfo type")

ping = int((time.time() - start) * 1000)
players.sort(key=lambda x: x.duration, reverse=True)
players, bots = players[info.bots :], players[: info.bots]

result: GamedigResult = {
"name": info.name,
"map": info.map,
"password": info.visibility == Visibility.Private,
"numplayers": info.players,
"numbots": info.bots,
"maxplayers": info.max_players,
"players": [
{
"name": player.name,
"raw": {"score": player.score, "time": player.duration},
}
for player in players
],
"bots": [
{"name": bot.name, "raw": {"score": bot.score, "time": bot.duration}}
for bot in bots
],
"connect": connect,
"ping": ping,
"raw": info.__dict__,
}
return result

except Exception as e:
raise Exception(f"Both HTTP API and Source protocol failed for {host}:{port}: {str(e)}")

async def _build_result_from_api(self, api_data: dict, host: str, port: int, start_time: float, end_time: float) -> "GamedigResult":
"""
Build standardized GamedigResult from HTTP API response.
Maps API fields to match expected format.
Handles both Exfil API format and Steam A2S format.
"""
ping = int((end_time - start_time) * 1000)

# Extract and map fields from API response
# Try multiple possible field names for server name
name = (api_data.get("serverName") or
api_data.get("name") or
api_data.get("SteamServerName_s") or
"Unknown")
map_name = api_data.get("map", "Unknown")
password = api_data.get("password", False)

# Parse player count - handle both formats:
# Format 1: Players_s = "X/Y" (Steam A2S format)
# Format 2: players = X, maxPlayers = Y (Exfil API format)
numplayers = 0
maxplayers = 0
if "Players_s" in api_data:
players_str = str(api_data["Players_s"])
if "/" in players_str:
try:
numplayers, maxplayers = map(int, players_str.split("/"))
except (ValueError, IndexError):
numplayers = 0
maxplayers = 0
else:
# Use direct integer fields (Exfil API format)
numplayers = int(api_data.get("players", api_data.get("current", 0)))
maxplayers = int(api_data.get("maxPlayers", api_data.get("max", 0)))

# Extract player list if available
player_list = []
if isinstance(api_data.get("playerList"), list):
player_list = [
{"name": player if isinstance(player, str) else player.get("name", "Unknown"), "raw": {}}
for player in api_data["playerList"]
]

result: GamedigResult = {
"name": name,
"map": map_name,
"password": bool(password),
"numplayers": numplayers,
"numbots": 0,
"maxplayers": maxplayers,
"players": player_list if player_list else None,
"bots": None,
"connect": f"{host}:{port}",
"ping": ping,
"raw": api_data,
}

return result