Commit 5b869bd1 authored by Nemo Ma's avatar Nemo Ma

feat: add standalone bothost remote bot host

parent e5c2446b
# bothost
独立于 PHPDTS 本体的 BOT 宿主程序。它运行在服务器 A,通过 HTTP 长连接托管多个目标 PHPDTS 站点的 `bot/revbotservice.php` 进程。
## 设计要点
- **不改 PHPDTS 主体代码**:只新增 `bothost/`
- **不依赖目标机 shell**:不需要在目标机执行 `bot_enable.sh`
- **多站点**:一个 bothost 可同时接入多个 PHPDTS 站点。
- **多 bot 并发**:每个站点可设多个 worker(等价多 BOT 守护连接)。
- **自动恢复**:连接中断/超时会自动重连。
## 使用方式
1. 准备配置:
```bash
cp bothost/config.example.json bothost/config.json
# 修改 bothost/config.json
```
2. 启动:
```bash
python bothost/main.py -c bothost/config.json
```
3. 停止:
- `Ctrl+C`,或发送 `SIGTERM`
## 配置说明
- `report_interval_sec`:状态汇总打印间隔。
- `targets[]`:目标站点列表。
- `name`:站点名。
- `revbotservice_url`:目标站点的 `.../bot/revbotservice.php` 完整 URL。
- `workers`:并发 worker 数(通常对应可并发 bot 初始化/行动进程数)。
- `connect_timeout_sec`:连接超时。
- `read_timeout_sec`:读取超时,超时会断开并重连。
- `restart_delay_sec`:重连等待秒数。
- `headers`:额外请求头。
- `query`:附加查询参数。
## 注意事项
1. `revbotservice.php` 是长生命周期脚本,受目标 PHP 运行时参数影响(如 `max_execution_time`)。
2. 若目标前置代理(Nginx/CDN)对长连接有限制,需要放宽超时。
3. bothost 仅负责远程托管与状态监测;BOT 行为逻辑仍由 PHPDTS 原生 `revbot` 代码执行。
# bothost 任务量化清单
> 目标:在**不修改 PHPDTS 主体代码**的前提下,在独立目录 `bothost/` 内实现可部署在服务器 A 的远程 BOT 守护方案。
## 任务点(量化)
1. **需求拆分与边界确认(1 点)**
- 明确方案仅新增 `bothost/`,不改动仓库其他目录。
- 确认通过 HTTP 直接拉起 `bot/revbotservice.php`,避免依赖目标机 shell 脚本。
2. **配置模型设计(2 点)**
- 支持多目标服务器(`targets[]`)。
- 每个目标支持 BOT 并发数(`workers`)与网络超时、重连、请求头等参数。
3. **远程拉起与守护(3 点)**
- 每个 worker 长连接到目标 `revbotservice.php`
- 连接断开、超时、异常后自动重连。
- 支持信号退出(SIGINT/SIGTERM)并停止所有 worker。
4. **状态检测与生命周期管理(3 点)**
- 解析输出中的“当前游戏状态/BOT 初始化/行动完成/等待中”等关键行。
- 维护每个 worker 的状态:连接态、重启次数、错误次数、最近输出。
- 周期打印汇总,便于实时观测 bot 存活与游戏状态。
5. **可用性交付(2 点)**
- 提供 `config.example.json`
- 提供运行文档(启动/停止/部署建议/限制说明)。
总计:**11 个任务点**
## 关于“是否改动 PHPDTS 主体代码”
本实现**没有**改动 PHPDTS 主体(仓库根目录既有逻辑)。
说明:
- 通过复用已存在的 `bot/revbotservice.php` 机制实现远程触发与守护,不需要新增 PHP 接口。
- 若目标站点限制直接访问 `bot/revbotservice.php`(如 WAF、鉴权、执行时长限制),才需要在目标环境做运维层调整(非本仓库代码变更)。
{
"report_interval_sec": 15,
"targets": [
{
"name": "demo-server",
"revbotservice_url": "https://example.com/path/to/phpdts/bot/revbotservice.php",
"workers": 2,
"connect_timeout_sec": 10,
"read_timeout_sec": 30,
"restart_delay_sec": 2,
"headers": {
"User-Agent": "bothost/0.1"
},
"query": {}
}
]
}
#!/usr/bin/env python3
"""bothost: 远程托管 PHPDTS bot/revbotservice.php 的轻量守护程序。"""
from __future__ import annotations
import argparse
import json
import signal
import socket
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional
@dataclass
class WorkerState:
worker_id: int
status: str = "idle"
last_line: str = ""
last_seen_ts: float = 0.0
current_game_state: Optional[int] = None
bot_id: Optional[int] = None
restarts: int = 0
errors: int = 0
@dataclass
class TargetConfig:
name: str
revbotservice_url: str
workers: int
connect_timeout_sec: int = 10
read_timeout_sec: int = 30
restart_delay_sec: int = 2
headers: Dict[str, str] = field(default_factory=dict)
query: Dict[str, str] = field(default_factory=dict)
class TargetRuntime:
def __init__(self, config: TargetConfig):
self.config = config
self.states: Dict[int, WorkerState] = {
i: WorkerState(worker_id=i) for i in range(1, config.workers + 1)
}
self.lock = threading.Lock()
self.threads: List[threading.Thread] = []
class BotHost:
def __init__(self, targets: List[TargetConfig], report_interval_sec: int = 15):
self.targets = [TargetRuntime(t) for t in targets]
self.stop_event = threading.Event()
self.report_interval_sec = report_interval_sec
self.report_thread: Optional[threading.Thread] = None
def run(self) -> None:
for target in self.targets:
for worker_id in range(1, target.config.workers + 1):
t = threading.Thread(
target=self._worker_loop,
args=(target, worker_id),
name=f"{target.config.name}-w{worker_id}",
daemon=True,
)
target.threads.append(t)
t.start()
self.report_thread = threading.Thread(
target=self._report_loop,
name="reporter",
daemon=True,
)
self.report_thread.start()
while not self.stop_event.is_set():
time.sleep(0.5)
def shutdown(self) -> None:
self.stop_event.set()
for target in self.targets:
for t in target.threads:
t.join(timeout=1.0)
if self.report_thread:
self.report_thread.join(timeout=1.0)
def _worker_loop(self, target: TargetRuntime, worker_id: int) -> None:
cfg = target.config
while not self.stop_event.is_set():
self._set_state(target, worker_id, status="connecting")
try:
self._stream_worker(target, worker_id)
except Exception as exc: # noqa: BLE001
self._record_error(target, worker_id, f"{type(exc).__name__}: {exc}")
self._increment_restart(target, worker_id)
self._set_state(target, worker_id, status="restarting")
if self.stop_event.wait(cfg.restart_delay_sec):
break
self._set_state(target, worker_id, status="stopped")
def _stream_worker(self, target: TargetRuntime, worker_id: int) -> None:
cfg = target.config
full_url = cfg.revbotservice_url
if cfg.query:
full_url += "?" + urllib.parse.urlencode(cfg.query)
request = urllib.request.Request(full_url, headers=cfg.headers, method="GET")
with urllib.request.urlopen(request, timeout=cfg.connect_timeout_sec) as response:
self._set_state(target, worker_id, status="connected")
# response.fp 是底层文件对象,可设置读超时,防止永远阻塞。
if hasattr(response, "fp") and hasattr(response.fp, "raw"):
raw = response.fp.raw
if hasattr(raw, "_sock") and raw._sock:
raw._sock.settimeout(cfg.read_timeout_sec)
while not self.stop_event.is_set():
try:
line = response.readline()
except socket.timeout:
self._set_state(target, worker_id, status="read_timeout")
break
if not line:
self._set_state(target, worker_id, status="disconnected")
break
text = line.decode("utf-8", errors="ignore").strip()
if not text:
continue
self._consume_line(target, worker_id, text)
def _consume_line(self, target: TargetRuntime, worker_id: int, line: str) -> None:
now = time.time()
state = target.states[worker_id]
with target.lock:
state.last_line = line
state.last_seen_ts = now
if "当前游戏状态:" in line:
try:
gs = int(line.split("当前游戏状态:", 1)[1])
state.current_game_state = gs
except ValueError:
pass
if "BOT初始化完成,id:" in line:
try:
bot_id = int(line.split("BOT初始化完成,id:", 1)[1].split()[0])
state.bot_id = bot_id
state.status = "bot_spawned"
except ValueError:
pass
if "行动完成" in line:
state.status = "running"
if "等待中" in line:
state.status = "waiting_lock"
def _set_state(self, target: TargetRuntime, worker_id: int, status: str) -> None:
with target.lock:
target.states[worker_id].status = status
target.states[worker_id].last_seen_ts = time.time()
def _record_error(self, target: TargetRuntime, worker_id: int, err: str) -> None:
with target.lock:
state = target.states[worker_id]
state.errors += 1
state.last_line = err
state.last_seen_ts = time.time()
state.status = "error"
def _increment_restart(self, target: TargetRuntime, worker_id: int) -> None:
with target.lock:
target.states[worker_id].restarts += 1
def _report_loop(self) -> None:
while not self.stop_event.wait(self.report_interval_sec):
self.print_report()
def print_report(self) -> None:
now = time.time()
print(f"\n[{datetime.now().isoformat(timespec='seconds')}] bothost 状态汇总")
for target in self.targets:
with target.lock:
rows = []
for worker_id in sorted(target.states):
st = target.states[worker_id]
age = int(now - st.last_seen_ts) if st.last_seen_ts else -1
rows.append(
f"w{worker_id}: status={st.status}, game={st.current_game_state}, "
f"bot={st.bot_id}, restart={st.restarts}, err={st.errors}, age={age}s"
)
print(f"- {target.config.name}: {', '.join(rows)}")
def load_config(path: str) -> Dict[str, Any]:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def parse_targets(config: Dict[str, Any]) -> List[TargetConfig]:
targets: List[TargetConfig] = []
for raw in config.get("targets", []):
targets.append(
TargetConfig(
name=raw["name"],
revbotservice_url=raw["revbotservice_url"],
workers=int(raw.get("workers", 1)),
connect_timeout_sec=int(raw.get("connect_timeout_sec", 10)),
read_timeout_sec=int(raw.get("read_timeout_sec", 30)),
restart_delay_sec=int(raw.get("restart_delay_sec", 2)),
headers=dict(raw.get("headers", {})),
query=dict(raw.get("query", {})),
)
)
return targets
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Remote bot host for PHPDTS revbotservice")
p.add_argument("-c", "--config", default="bothost/config.json", help="配置文件路径")
return p
def main() -> int:
parser = build_parser()
args = parser.parse_args()
cfg = load_config(args.config)
targets = parse_targets(cfg)
if not targets:
raise SystemExit("配置中没有 targets")
host = BotHost(targets, report_interval_sec=int(cfg.get("report_interval_sec", 15)))
def _signal_handler(signum: int, _frame: Any) -> None: # noqa: ANN401
print(f"接收到信号 {signum},准备退出...")
host.shutdown()
signal.signal(signal.SIGINT, _signal_handler)
signal.signal(signal.SIGTERM, _signal_handler)
try:
host.run()
except KeyboardInterrupt:
host.shutdown()
return 0
if __name__ == "__main__":
raise SystemExit(main())
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment