Skip to content
Open
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
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,22 @@ LOG_LEVEL=INFO
# Log file retention in days (stored in logs/ directory, rotated daily)
LOG_RETENTION_DAYS=7

# --- Starbase (Optional) ---
# Enable Bisect Starbase API integration for remote stats and player tracking.
# When both are set, the bot will:
# - Fetch HumanitZServer/PlayerConnectedLog.txt via Bisect (used for player online duration)
# - Show server resource usage from Bisect /resources instead of local psutil
#
# STARBASE_ID:
# Found in your Starbase panel URL:
# Example: https://games.bisecthosting.com/server/bae934e0 → STARBASE_ID=bae934e0
#
# STARBASE_TOKEN:
# In Starbase Panel → My Account → Account Settings → API Credentials
# Create a token and paste it here.
STARBASE_ID=
STARBASE_TOKEN=

# --- Server File Paths (Optional) ---

# Path to PlayerConnectedLog.txt (used to calculate player online duration)
Expand Down
15 changes: 15 additions & 0 deletions .env.example.zh-TW
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,21 @@ LOG_LEVEL=INFO
# 日誌檔案保留天數(存放於 logs/ 目錄,按日期分檔)
LOG_RETENTION_DAYS=7

# 啟用 Bisect Starbase API 整合(遠端資源與玩家追蹤)。
# 當同時設定以下兩者時,Bot 會:
# - 透過 Bisect 下載 HumanitZServer/PlayerConnectedLog.txt(用於玩家在線時長)
# - 使用 Bisect /resources 顯示主機資源(取代本機 psutil)
#
# STARBASE_ID:
# 從 Starbase 面板網址取得:
# 範例:https://games.bisecthosting.com/server/bae934e0 → STARBASE_ID=bae934e0
#
# STARBASE_TOKEN:
# Starbase 面板 → My Account → Account Settings → API Credentials
# 建立 API Token 並填入此處。
STARBASE_ID=
STARBASE_TOKEN=

# --- 伺服器檔案路徑(選填)---

# 玩家連線記錄檔路徑(用於計算玩家在線時長)
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ Edit `.env` and fill in your values:
| `SHOW_SYSTEM_STATS` | | Show host system stats in embed (default: `true`; set `false` for remote setups) |
| `LOCALE` | | `en` or `zh-TW` (default: `en`) |
| `PLAYER_LOG_PATH` | | Path to `PlayerConnectedLog.txt` |
| `STARBASE_ID` | | Bisect Starbase server ID from panel URL (e.g., https://games.bisecthosting.com/server/bae934e0 → `bae934e0`) |
| `STARBASE_TOKEN` | | Bisect Starbase API token (Panel → My Account → Account Settings → API Credentials) |

See [`.env.example`](.env.example) for all options with detailed descriptions. A [Traditional Chinese version](.env.example.zh-TW) is also available.

Expand Down Expand Up @@ -152,4 +154,4 @@ All runtime data is excluded from git via `.gitignore`.

## License

[MIT](LICENSE) © [Minidoracat](https://github.com/Minidoracat)
[MIT](LICENSE) © [Minidoracat](https://github.com/Minidoracat)
2 changes: 2 additions & 0 deletions README.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ cp .env.example .env
| `SHOW_SYSTEM_STATS` | | 顯示主機系統資源(預設:`true`;遠端架設建議設為 `false`) |
| `LOCALE` | | `en` 或 `zh-TW`(預設:`en`) |
| `PLAYER_LOG_PATH` | | `PlayerConnectedLog.txt` 檔案路徑 |
| `STARBASE_ID` | | Bisect Starbase 面板網址中的伺服器 ID(例如:https://games.bisecthosting.com/server/bae934e0 → `bae934e0`) |
| `STARBASE_TOKEN` | | Bisect Starbase API Token(面板 → My Account → Account Settings → API Credentials) |

完整選項請參考 [`.env.example`](.env.example),亦提供[繁體中文版](.env.example.zh-TW)。

Expand Down
130 changes: 97 additions & 33 deletions src/humanitz_bot/cogs/server_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,26 @@ def __init__(self, bot: commands.Bot) -> None:
self.rcon = RconService(
settings.rcon_host, settings.rcon_port, settings.rcon_password
)
self.player_tracker = PlayerTracker(settings.player_log_path)

# 若設有 STARBASE_TOKEN/STARBASE_ID,建立遠端檔案抓取器交給 PlayerTracker
starbase_fetcher = None
if getattr(settings, "starbase_token", None) and getattr(settings, "starbase_id", None):
token = settings.starbase_token
server_id = settings.starbase_id

def _fetch_remote_log() -> str:
url = f"https://games.bisecthosting.com/api/client/servers/{server_id}/files/contents"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The Starbase API URL is hardcoded here, and its base is used again on line 316. To improve maintainability and avoid "magic strings", consider defining the base URL https://games.bisecthosting.com/api/client/servers as a module-level constant.

headers = {"Authorization": f"Bearer {token}"}
params = {"file": "HumanitZServer/PlayerConnectedLog.txt"}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The remote log file path HumanitZServer/PlayerConnectedLog.txt is hardcoded. It would be better to define this as a module-level constant to improve maintainability and avoid magic strings.

import requests

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The requests library is imported locally within this function, and again on line 318. It's a best practice in Python to place all imports at the top of the file. This improves code readability, makes dependencies clear, and avoids repeated import overhead.


resp = requests.post(url, headers=headers, params=params, timeout=15)
resp.raise_for_status()
return resp.text

starbase_fetcher = _fetch_remote_log

self.player_tracker = PlayerTracker(settings.player_log_path, starbase_fetcher)
self.db = Database(
data_dir="data",
retention_days=settings.db_retention_days,
Expand All @@ -66,16 +85,20 @@ def __init__(self, bot: commands.Bot) -> None:
self._status_message: discord.Message | None = None
self._last_result: FetchAllResult | None = None
self._prune_counter: int = 0

# 啟用 Starbase /resources 模式旗標
self._use_starbase_stats: bool = bool(
getattr(settings, "starbase_token", None) and getattr(settings, "starbase_id", None)
)
Comment on lines +90 to +92

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This logic to check if Starbase is configured is also present on line 52. To avoid code duplication and improve maintainability, this check should be performed only once in the __init__ method, with the result stored in self._use_starbase_stats. This attribute can then be used in both places.


self._load_state()

@commands.Cog.listener()
async def on_ready(self) -> None:
if not self.update_status.is_running():
self.update_status.change_interval(seconds=self._update_interval)
self.update_status.start()
logger.info(
"Status update loop started (interval=%ds)", self._update_interval
)
logger.info("Status update loop started (interval=%ds)", self._update_interval)

async def cog_unload(self) -> None:
self.update_status.cancel()
Expand All @@ -90,24 +113,31 @@ async def update_status(self) -> None:
if result.server_info:
result.server_info.max_players = self._max_players

# 以 RCON 列表為準,從 PlayerConnectedLog 計算上線時長
online_times: dict[str, datetime] = {}
if result.server_info and result.server_info.player_names:
online_times = await asyncio.to_thread(
self.player_tracker.get_online_times,
result.server_info.player_names,
)

stats = (
await asyncio.to_thread(get_system_stats)
if self._show_system_stats
else None
)
# 系統資源:若啟用 Starbase 則改用 /resources,否則使用 psutil
starbase_stats: dict | None = None
stats: SystemStats | None = None
if self._show_system_stats:
if self._use_starbase_stats:
try:
starbase_stats = await asyncio.to_thread(self._fetch_starbase_resources)
except Exception:
starbase_stats = None
Comment on lines +131 to +132

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Catching a broad Exception can hide unexpected errors and make debugging difficult. It's better to catch more specific exceptions that you expect from the network request, such as requests.exceptions.RequestException. This will prevent the bot from silently ignoring other unrelated errors. This also applies to the try...except block in _format_starbase_resources on line 296.

else:
stats = await asyncio.to_thread(get_system_stats)

player_count = result.server_info.player_count if result.server_info else 0
await asyncio.to_thread(self.chart_service.add_data_point, player_count)
chart_path = await asyncio.to_thread(self.chart_service.generate_chart)

embed = self._build_embed(result, online_times, stats)
embed = self._build_embed(result, online_times, stats, starbase_stats)

await self._update_message(embed, chart_path)

Expand All @@ -129,6 +159,7 @@ def _build_embed(
result: FetchAllResult,
online_times: dict[str, datetime],
stats: SystemStats | None,
starbase_stats: dict | None = None,
) -> discord.Embed:
now = datetime.now()

Expand Down Expand Up @@ -186,12 +217,20 @@ def _build_embed(
color=_COLOR_OFFLINE,
)

if stats is not None:
embed.add_field(
name=t("status.system_status"),
value=self._format_system_stats(stats),
inline=False,
)
# 系統資源顯示:Starbase 優先,且僅顯示 /resources 提供之欄位
if self._show_system_stats:
if starbase_stats is not None:
embed.add_field(
name=t("status.system_status"),
value=self._format_starbase_resources(starbase_stats),
inline=False,
)
elif stats is not None:
embed.add_field(
name=t("status.system_status"),
value=self._format_system_stats(stats),
inline=False,
)

embed.set_image(url="attachment://player_chart.png")
embed.set_footer(
Expand Down Expand Up @@ -245,6 +284,43 @@ def _format_system_stats(stats: SystemStats) -> str:
f"⏰ {t('status.uptime')}: {uptime}"
)

def _format_starbase_resources(self, data: dict) -> str:
"""以 Bisect /resources 取代 psutil 顯示(僅顯示該 API 有的欄位)。"""
try:
res = data.get("attributes", {}).get("resources", {})
cpu = float(res.get("cpu_absolute", 0.0))
mem_bytes = int(res.get("memory_bytes", 0))
disk_bytes = int(res.get("disk_bytes", 0))
rx = float(res.get("network_rx_bytes", 0.0))
tx = float(res.get("network_tx_bytes", 0.0))
except Exception:
return "N/A"

mem_str = f"{mem_bytes/1024/1024/1024:.2f} GB"
disk_str = f"{disk_bytes/1024/1024/1024:.2f} GB"
Comment on lines +299 to +300

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The value for converting bytes to gigabytes is calculated inline using division. To improve readability and maintainability, it's better to use exponentiation (1024**3) and ideally define this as a constant (e.g., _BYTES_PER_GB = 1024**3) at the module level.

Suggested change
mem_str = f"{mem_bytes/1024/1024/1024:.2f} GB"
disk_str = f"{disk_bytes/1024/1024/1024:.2f} GB"
mem_str = f"{mem_bytes / (1024**3):.2f} GB"
disk_str = f"{disk_bytes / (1024**3):.2f} GB"

net_recv = format_bytes(rx)
net_sent = format_bytes(tx)

cpu_bar = make_progress_bar(cpu)
return (
f"💻 {t('status.cpu')}: {cpu_bar} {cpu:.1f}%\n"
f"🧠 {t('status.memory')}: {mem_str}\n"
f"💾 {t('status.disk')}: {disk_str}\n"
f"🌐 {t('status.network')}: ↓{net_recv} ↑{net_sent}"
)

def _fetch_starbase_resources(self) -> dict:
"""呼叫 Bisect /resources API 取得即時資源。"""
settings = self.bot.settings # type: ignore[attr-defined]
assert settings.starbase_token and settings.starbase_id
url = f"https://games.bisecthosting.com/api/client/servers/{settings.starbase_id}/resources"
headers = {"Authorization": f"Bearer {settings.starbase_token}"}
import requests

resp = requests.get(url, headers=headers, timeout=15)
resp.raise_for_status()
return resp.json()

def _load_state(self) -> None:
"""從 data/status_state.json 載入持久化的 message ID。"""
if self.status_message_id is not None:
Expand All @@ -258,9 +334,7 @@ def _load_state(self) -> None:
saved_msg = data.get("message_id")
if saved_channel == self.status_channel_id and saved_msg:
self.status_message_id = int(saved_msg)
logger.info(
"Loaded saved status message ID: %d", self.status_message_id
)
logger.info("Loaded saved status message ID: %d", self.status_message_id)
except Exception:
logger.warning("Failed to load status state, will create new message")

Expand All @@ -269,18 +343,14 @@ def _save_state(self, message_id: int) -> None:
try:
_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
_STATE_FILE.write_text(
json.dumps(
{"channel_id": self.status_channel_id, "message_id": message_id}
),
json.dumps({"channel_id": self.status_channel_id, "message_id": message_id}),
encoding="utf-8",
)
logger.debug("Saved status message ID: %d", message_id)
except Exception:
logger.warning("Failed to save status state")

async def _update_message(
self, embed: discord.Embed, chart_path: str | None
) -> None:
async def _update_message(self, embed: discord.Embed, chart_path: str | None) -> None:
raw_channel = self.bot.get_channel(self.status_channel_id)
if not isinstance(raw_channel, discord.TextChannel):
logger.error(
Expand All @@ -290,11 +360,7 @@ async def _update_message(
return
channel: discord.TextChannel = raw_channel

file = (
discord.File(chart_path, filename="player_chart.png")
if chart_path
else None
)
file = discord.File(chart_path, filename="player_chart.png") if chart_path else None

if self._status_message is not None:
try:
Expand All @@ -308,9 +374,7 @@ async def _update_message(

if self.status_message_id:
try:
self._status_message = await channel.fetch_message(
self.status_message_id
)
self._status_message = await channel.fetch_message(self.status_message_id)
if file:
await self._status_message.edit(embed=embed, attachments=[file])
else:
Expand Down
12 changes: 10 additions & 2 deletions src/humanitz_bot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@
import os
import logging
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv

logger = logging.getLogger("humanitz_bot.config")

_PLACEHOLDER_PATTERNS = ("YOUR_", "PLACEHOLDER", "CHANGEME", "TODO", "REPLACE")
Expand Down Expand Up @@ -48,6 +46,10 @@ class Settings:
log_retention_days: int = 7
player_log_path: str = "PlayerConnectedLog.txt"

# Starbase (optional)
starbase_token: str | None = None
starbase_id: str | None = None

@classmethod
def from_env(cls, env_path: str | None = None) -> Settings:
"""
Expand Down Expand Up @@ -126,6 +128,10 @@ def from_env(cls, env_path: str | None = None) -> Settings:
"PlayerConnectedLog.txt",
).strip()

# Optional Starbase credentials for Remote functionality
starbase_token = os.getenv("STARBASE_TOKEN", "").strip() or None
starbase_id = os.getenv("STARBASE_ID", "").strip() or None

# 類型轉換
try:
rcon_port = int(rcon_port_str)
Expand Down Expand Up @@ -162,4 +168,6 @@ def from_env(cls, env_path: str | None = None) -> Settings:
log_level=log_level,
log_retention_days=log_retention_days,
player_log_path=player_log_path,
starbase_token=starbase_token,
starbase_id=starbase_id,
)
Loading