commit 06ff69e945e455fe96a32c31c3f067fe4753f9b4
Author: ddaodan <731882332@qq.com>
Date: Sat Aug 15 16:49:17 2026 +0800
Update Live-streaming code (auto-daily features)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..dd6b915
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,32 @@
+# Python
+__pycache__/
+*.py[cod]
+.venv/
+runtime/
+config/.venv_python_path.txt
+
+# Node / frontend build
+frontend/admin/node_modules/
+frontend/admin/.vite/
+
+# Build output
+build/
+dist/
+*.spec
+
+# Runtime data
+logs/
+data/music_state.json
+data/queue_state.json
+data/song_requests.json
+data/tts_state.json
+data/bilibili_credentials.json
+web/music_cover.jpg
+
+# IDE
+.idea/
+.workbuddy/
+
+# Large binaries (kept local only)
+vendor/mpv/mpv.exe
+vendor/mpv/mpv.7z
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a04767c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,226 @@
+# BetterGI 直播联动
+
+监听 B 站直播间弹幕,管理观众排队、积分、扫码上号、BetterGI 配置组与自动每日一条龙、队伍管理、点歌和 TTS 播报。
+
+## 环境
+
+- **Python**: 项目虚拟环境 `.venv`(Python 3.11)
+- **Node.js**: 构建前端用
+- **BetterGI**: `0.63.0+`,路径在 `config/config.json` 中配置
+
+```powershell
+# 安装 Python 依赖
+py -3.11 -m venv .venv
+.venv\Scripts\python.exe -m pip install -r requirements.txt
+
+# 下载 TTS 模型(约 1.2GB,仅需一次)
+.venv\Scripts\python.exe -c "from huggingface_hub import snapshot_download; snapshot_download('Qwen/Qwen3-TTS-12Hz-0.6B-Base')"
+
+# 安装前端依赖并构建
+cd frontend\admin
+npm install
+npm run build
+```
+
+## 启动
+
+```powershell
+.venv\Scripts\python.exe app\main.py --role all --host 0.0.0.0 --port 5191
+```
+
+| 参数 | 默认值 | 说明 |
+|------|--------|------|
+| `--role` | `all` | `queue` 仅排队/Web / `music` 仅音乐监听 / `tts` TTS 状态窗口 / `all` 同时启动全部(推荐,否则音乐页面不会随 SMTC 更新) |
+| `--host` | `0.0.0.0` | 绑定地址 |
+| `--port` | `8086` | 端口 |
+
+启动后日志会打印本机和局域网访问地址,例如 `http://192.168.31.80:5191/admin`。
+
+## 配置文件
+
+配置文件统一为 `config/config.json`,支持热加载,保存后无需重启。
+
+### bilibili — 直播间连接
+
+| 字段 | 说明 |
+|------|------|
+| `room_id` | 直播间号(短号也可) |
+| `sessdata` | B 站 Cookie SESSDATA,用于监听弹幕和发送回复 |
+| `bili_jct` | CSRF token,发弹幕必须。和 SESSDATA 配套获取 |
+
+获取方式:浏览器登录 B 站 → F12 → Application → Cookies → bilibili.com → 复制 `SESSDATA` 和 `bili_jct`。
+
+> **注意**:`sessdata` 和 `bili_jct` 属于敏感登录凭证,不要分享本文件。缺少 `bili_jct` 时只能收弹幕不能回复。
+
+### queue — 排队积分
+
+| 字段 | 默认值 | 说明 |
+|------|--------|------|
+| `initial_points` | 10 | 新用户初始积分 |
+| `signin_points` | 10 | 每日签到获得积分 |
+| `max_points` | 30 | 积分上限 |
+| `points_per_minute` | 1 | 队首每分钟扣除积分 |
+| `admin_window_seconds` | 180 | 队首上号窗口(超时过号) |
+| `default_group` | 薄荷 | 队列空时默认运行的 BetterGI 配置组 |
+
+### global — 全局
+
+| 字段 | 说明 |
+|------|------|
+| `admin_uids` | 管理员 UID 列表(一级用户,可用重置等特权指令) |
+| `log_level` | 日志级别:`DEBUG` / `INFO` / `WARNING` |
+
+### daily — 自动每日
+
+| 字段 | 默认值 | 说明 |
+|------|--------|------|
+| `one_dragon_template` | 默认配置 | BGI 一条龙模板名称 |
+| `managed_one_dragon_name` | 直播系统自动每日 | 每次覆盖生成并启动的托管配置名称 |
+| `ley_line_craft_resin_before` | true | 地脉模式执行前是否合成树脂 |
+| `commission_use_current_party` | true | 委托前读取游戏当前队伍名并写入 AutoCommissionNova 的战斗与元素采集队伍配置 |
+| `current_party_read_timeout_sec` | 45 | 当前队伍 OCR 读取超时秒数 |
+
+自动每日流程为“领取邮件 → 合成树脂 → 可选其他任务 → 领取尘歌壶奖励 → 领取每日奖励”。秘境俗称维护在 `config/domain_aliases.json`,修改后无需重启。
+
+完整的 BGI 模板、战斗策略和三个配置组配置方法见 [自动每日与队伍管理](docs/自动每日.md)。
+
+### broadcast — 弹幕播报与 TTS
+
+| 字段 | 默认值 | 说明 |
+|------|--------|------|
+| `enable_danmu_reply` | true | 指令回复发到直播间 |
+| `enable_system_danmu` | true | 系统通知发到直播间 |
+| `enable_tts` | false | TTS 语音播报(需先下载模型) |
+| `danmu_interval_sec` | 5 | 弹幕发送间隔 |
+| `tts_provider` | none | `none` / `faster-qwen3-tts` / `dots-tts` |
+
+### commands — 内置指令别名
+
+14 个内置指令,每个可单独启用/禁用、自定义别名和允许角色:
+
+| key | 默认别名 | 功能 |
+|-----|----------|------|
+| `queue` | 排队 | 加入排队队列 |
+| `signin` | 签到 | 每日签到 |
+| `login` | 上号 | 队首触发扫码上号 |
+| `confirm_yes` | 是 | 确认账号正确 |
+| `confirm_no` | 不是 | 确认账号不正确,重新扫码 |
+| `run` | 执行, 跑, 开始 | 执行配置组(需带参数) |
+| `daily` | 自动每日 | 启动托管的一条龙每日任务,可选秘境、地脉或委托模式 |
+| `switch_party` | 切换队伍, 更换队伍 | 修改并执行配置组“切换队伍” |
+| `edit_party` | 修改队员, 更换队员 | 校验四名角色后修改并执行配置组“修改队员” |
+| `leave` | 退出 | 退出队列 |
+| `reset` | 重置 | 一级用户重启原神和 BGI |
+| `points` | 积分 | 查询积分 |
+| `queue_list` | 队列 | 查看排队情况 |
+| `help` | 帮助 | 显示帮助 |
+
+### rules — 弹幕触发规则
+
+```json
+{
+ "keyword": "开始",
+ "match_type": "exact",
+ "groups": ["子探测单元"],
+ "cooldown": 60,
+ "admin_only": false,
+ "reply": "收到,开始执行子探测单元任务"
+}
+```
+
+- `match_type`:`contains` / `exact` / `startswith` / `regex`
+- 匹配后直接启动 `groups` 指定的 BetterGI 配置组
+
+### music_monitor — 音乐监听与点歌
+
+监听 Windows 系统媒体会话(SMTC),观众可通过弹幕点歌。
+
+`request_player.commands`:点歌触发词,默认 `["点歌", "dg"]`。
+
+### system — 系统定时任务
+
+| 字段 | 说明 |
+|------|------|
+| `enable_startup_shortcut` | 开机自启 |
+| `auto_reboot_time` | 每日自动重启时间 |
+| `launch_bilibili_live_time` | 定时启动直播姬 |
+| `launch_genshin_time` | 定时启动原神 |
+
+## 后台管理
+
+```
+http://127.0.0.1:5191/admin
+```
+
+| 页面 | 功能 |
+|------|------|
+| 总览 | 运行状态、排队人数、服务健康、快捷操作 |
+| 配置 | B 站连接、BetterGI 路径、队列参数、前台视觉 |
+| 规则 | 弹幕触发规则 + 内置指令别名管理 |
+| 队列 | 当前排队列表,支持移出、加减分、清空 |
+| 用户 | 所有用户积分管理,支持加减分、踢出、删除 |
+| 媒体 | 弹幕播报开关、TTS 配置与测试、音乐监听 |
+| 日志 | 系统日志 + BetterGI 日志 |
+| JSON | 原始 JSON 编辑 |
+
+后台每 3 秒自动刷新,数据实时同步。
+
+## 开发
+
+### 前端
+
+```powershell
+cd frontend\admin
+npm run build # 构建到 web/admin/
+```
+
+源码:`frontend/admin/src/main.js`(Vue3 单文件组件),`styles.css`。
+
+### 后端
+
+```powershell
+# 编译检查
+.venv\Scripts\python.exe -m py_compile app/danmu_queue.py
+```
+
+核心文件:
+| 文件 | 功能 |
+|------|------|
+| `app/main.py` | 入口,启动 Web 服务和子模块 |
+| `app/danmu_queue.py` | 弹幕监听、指令处理、队列管理、BGI 控制、TTS、Web API |
+| `app/bettergi_daily.py` | 自动每日配置生成、秘境别名和队伍参数更新 |
+| `app/bettergi_current_party.py` | 当前队伍读取托管配置组、状态协议和防串读校验 |
+| `app/music_monitor.py` | SMTC 音乐监听与点歌调度 |
+| `app/core/runtime_paths.py` | 运行时路径解析 |
+
+## 打包
+
+```powershell
+# 轻量包(不含 TTS)
+.\build.bat
+
+# 完整包(含 TTS/GPU 依赖)
+.\build.bat -FullTts
+```
+
+输出:`dist/LiveStreaming/LiveStreaming.exe`。
+
+## 数据文件
+
+| 路径 | 内容 |
+|------|------|
+| `data/users.json` | 用户账号与积分 |
+| `data/queue_state.json` | 排队状态(启动时清空) |
+| `data/ref_audio.wav` | TTS 参考音频 |
+| `data/music_state.json` | 音乐播放状态 |
+| `data/song_requests.json` | 点歌队列 |
+| `logs/danmu_queue.log` | 主程序日志 |
+
+## 弹幕链路排查
+
+1. 确认 `config.json` 中 `room_id` 正确(浏览器打开直播间,URL 中数字)
+2. 确认 `sessdata` 有效:看日志是否有 `SESSDATA有效, 账号=xxx`
+3. 确认 WebSocket 连接:看日志是否有 `认证成功,开始监听弹幕`
+4. 发弹幕测试:在直播间发 `帮助`,日志应出现 `[弹幕] 用户(uid): 帮助`
+5. 如连接断线(`WinError 10013`),用管理员权限启动程序
+6. DEBUG 统计:日志每 60 秒输出 `[弹幕统计] 收到N个raw | 解析N个包 | OP_MESSAGE=N | 提取弹幕=N`
diff --git a/app/admin_auth.py b/app/admin_auth.py
new file mode 100644
index 0000000..1fd9309
--- /dev/null
+++ b/app/admin_auth.py
@@ -0,0 +1,154 @@
+from __future__ import annotations
+
+import base64
+import hashlib
+import hmac
+import json
+import secrets
+import time
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+
+SESSION_COOKIE_NAME = "live_admin_session"
+
+
+def _utc_now_iso() -> str:
+ return datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
+
+
+def _is_local_ip(ip: str) -> bool:
+ ip = (ip or "").strip()
+ return ip in {"127.0.0.1", "::1", "::ffff:127.0.0.1", "localhost"}
+
+
+class AdminAuthManager:
+ def __init__(self, data_dir: str | Path, logger, *, session_ttl_sec: int = 12 * 60 * 60):
+ self.data_dir = Path(data_dir)
+ self.data_dir.mkdir(parents=True, exist_ok=True)
+ self.path = self.data_dir / "admin_auth.json"
+ self.audit_path = self.data_dir / "admin_audit.log"
+ self.logger = logger
+ self.session_ttl_sec = int(session_ttl_sec)
+ self._sessions: dict[str, dict[str, Any]] = {}
+ self._data = self._default_data()
+ self._load()
+
+ def _default_data(self) -> dict[str, Any]:
+ return {
+ "version": 1,
+ "created_at": "",
+ "password_hash": "",
+ "password_salt": "",
+ "password_updated_at": "",
+ }
+
+ def _load(self) -> None:
+ if not self.path.exists():
+ return
+ try:
+ data = json.loads(self.path.read_text(encoding="utf-8"))
+ if isinstance(data, dict):
+ self._data.update(data)
+ except Exception as exc:
+ self.logger.warning(f"[后台认证] 读取认证文件失败: {exc}")
+
+ def _save(self) -> None:
+ tmp = self.path.with_suffix(".tmp")
+ tmp.write_text(json.dumps(self._data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ tmp.replace(self.path)
+
+ def _hash_password(self, password: str, salt: bytes) -> str:
+ digest = hashlib.scrypt(
+ password.encode("utf-8"),
+ salt=salt,
+ n=2 ** 14,
+ r=8,
+ p=1,
+ dklen=64,
+ )
+ return base64.b64encode(digest).decode("ascii")
+
+ def is_bootstrapped(self) -> bool:
+ return bool(self._data.get("password_hash") and self._data.get("password_salt"))
+
+ def can_bootstrap_ip(self, ip: str) -> bool:
+ return _is_local_ip(ip)
+
+ def bootstrap(self, password: str) -> None:
+ password = str(password or "")
+ if len(password) < 8:
+ raise ValueError("后台密码至少 8 位")
+ salt = secrets.token_bytes(16)
+ now = _utc_now_iso()
+ self._data = {
+ "version": 1,
+ "created_at": self._data.get("created_at") or now,
+ "password_hash": self._hash_password(password, salt),
+ "password_salt": base64.b64encode(salt).decode("ascii"),
+ "password_updated_at": now,
+ }
+ self._sessions.clear()
+ self._save()
+
+ def verify_password(self, password: str) -> bool:
+ if not self.is_bootstrapped():
+ return False
+ try:
+ salt = base64.b64decode(self._data["password_salt"])
+ except Exception:
+ return False
+ current = self._hash_password(str(password or ""), salt)
+ return hmac.compare_digest(current, self._data.get("password_hash", ""))
+
+ def create_session(self, client_ip: str, user_agent: str = "") -> tuple[str, int]:
+ self.cleanup_sessions()
+ token = secrets.token_urlsafe(32)
+ now = int(time.time())
+ expires_at = now + self.session_ttl_sec
+ self._sessions[token] = {
+ "created_at": now,
+ "expires_at": expires_at,
+ "client_ip": client_ip or "",
+ "user_agent": (user_agent or "")[:240],
+ }
+ return token, expires_at
+
+ def cleanup_sessions(self) -> None:
+ now = int(time.time())
+ expired = [token for token, session in self._sessions.items() if int(session.get("expires_at", 0)) <= now]
+ for token in expired:
+ self._sessions.pop(token, None)
+
+ def get_session(self, token: str) -> dict[str, Any] | None:
+ self.cleanup_sessions()
+ if not token:
+ return None
+ session = self._sessions.get(token)
+ if not session:
+ return None
+ session["expires_at"] = int(time.time()) + self.session_ttl_sec
+ return session
+
+ def destroy_session(self, token: str) -> None:
+ if token:
+ self._sessions.pop(token, None)
+
+ def write_audit(self, *, action: str, target: str = "", client_ip: str = "", session_id: str = "", detail: str = "") -> None:
+ entry = {
+ "at": _utc_now_iso(),
+ "action": action,
+ "target": target,
+ "client_ip": client_ip,
+ "session_id": session_id[:12],
+ "detail": detail,
+ }
+ line = json.dumps(entry, ensure_ascii=False)
+ try:
+ with open(self.audit_path, "a", encoding="utf-8") as handle:
+ handle.write(line + "\n")
+ except Exception as exc:
+ self.logger.warning(f"[后台认证] 写审计日志失败: {exc}")
+ self.logger.info(f"[审计] action={action} target={target or '-'} ip={client_ip or '-'} detail={detail or '-'}")
+
diff --git a/app/admin_events.py b/app/admin_events.py
new file mode 100644
index 0000000..9554d07
--- /dev/null
+++ b/app/admin_events.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+import asyncio
+import time
+
+
+class AdminEventBus:
+ def __init__(self):
+ self._subscribers: set[asyncio.Queue] = set()
+ self._counter = 0
+ self._lock = asyncio.Lock()
+
+ async def subscribe(self) -> asyncio.Queue:
+ queue: asyncio.Queue = asyncio.Queue(maxsize=64)
+ async with self._lock:
+ self._subscribers.add(queue)
+ return queue
+
+ async def unsubscribe(self, queue: asyncio.Queue) -> None:
+ async with self._lock:
+ self._subscribers.discard(queue)
+
+ def subscriber_count(self) -> int:
+ return len(self._subscribers)
+
+ async def publish(self, event_type: str, payload):
+ self._counter += 1
+ event = {
+ "id": self._counter,
+ "type": event_type,
+ "ts": time.time(),
+ "payload": payload,
+ }
+ for queue in list(self._subscribers):
+ if queue.full():
+ try:
+ queue.get_nowait()
+ except asyncio.QueueEmpty:
+ pass
+ try:
+ queue.put_nowait(event)
+ except asyncio.QueueFull:
+ pass
diff --git a/app/bettergi_current_party.py b/app/bettergi_current_party.py
new file mode 100644
index 0000000..4c6d887
--- /dev/null
+++ b/app/bettergi_current_party.py
@@ -0,0 +1,181 @@
+"""BetterGI adapter helpers for reading the active party preset name."""
+
+from __future__ import annotations
+
+import copy
+import json
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+try:
+ from .bettergi_daily import DailyAutomationError, JsonUpdate
+except ImportError:
+ from bettergi_daily import DailyAutomationError, JsonUpdate
+
+
+CURRENT_PARTY_SCRIPT_NAME = "LiveCurrentParty"
+CURRENT_PARTY_GROUP_NAME = "直播系统读取当前队伍"
+CURRENT_PARTY_STATUS_FILE = "status.json"
+_REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
+_INVALID_BGI_NAME = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
+
+
+class CurrentPartyReadError(DailyAutomationError):
+ """Raised when the managed current-party reader cannot complete safely."""
+
+
+@dataclass(frozen=True)
+class PreparedCurrentPartyRead:
+ request_id: str
+ group_name: str
+ status_path: Path
+ updates: tuple[JsonUpdate, ...]
+
+
+@dataclass(frozen=True)
+class CurrentPartyReadResult:
+ party_name: str
+ candidates: tuple[str, ...] = ()
+
+
+def _read_json_object(path: Path, label: str) -> dict[str, Any]:
+ if not path.is_file():
+ raise CurrentPartyReadError(f"未找到{label}: {path}")
+ try:
+ data = json.loads(path.read_text(encoding="utf-8-sig"))
+ except (OSError, json.JSONDecodeError) as exc:
+ raise CurrentPartyReadError(f"读取{label}失败: {exc}") from exc
+ if not isinstance(data, dict):
+ raise CurrentPartyReadError(f"{label}必须是 JSON 对象: {path}")
+ return data
+
+
+def _safe_group_name(value: str) -> str:
+ name = str(value or "").strip()
+ if not name:
+ raise CurrentPartyReadError("当前队伍读取配置组名称不能为空")
+ if name in {".", ".."} or _INVALID_BGI_NAME.search(name):
+ raise CurrentPartyReadError(f"当前队伍读取配置组名称包含非法字符: {name}")
+ return name
+
+
+def _next_group_index(group_dir: Path) -> int:
+ indexes: list[int] = []
+ for path in group_dir.glob("*.json"):
+ try:
+ data = json.loads(path.read_text(encoding="utf-8-sig"))
+ value = data.get("index") if isinstance(data, dict) else None
+ if isinstance(value, int):
+ indexes.append(value)
+ except (OSError, json.JSONDecodeError):
+ continue
+ return max(indexes, default=0) + 1
+
+
+def _load_group_template(group_dir: Path, managed_path: Path) -> dict[str, Any]:
+ if managed_path.is_file():
+ return _read_json_object(managed_path, "托管当前队伍读取配置组")
+
+ for name in ("切换队伍", "修改队员", "每日委托"):
+ candidate = group_dir / f"{name}.json"
+ if candidate.is_file():
+ template = _read_json_object(candidate, f"配置组“{name}”")
+ template["index"] = _next_group_index(group_dir)
+ return template
+ raise CurrentPartyReadError(
+ "无法生成当前队伍读取配置组:请先在 BGI 创建“切换队伍”“修改队员”或“每日委托”中的任意一个配置组"
+ )
+
+
+def prepare_current_party_read(
+ work_dir: str | Path,
+ request_id: str,
+ *,
+ group_name: str = CURRENT_PARTY_GROUP_NAME,
+) -> PreparedCurrentPartyRead:
+ work_path = Path(work_dir)
+ if not work_path.is_dir():
+ raise CurrentPartyReadError(f"BetterGI 工作目录不存在: {work_path}")
+ request = str(request_id or "").strip()
+ if not _REQUEST_ID_PATTERN.fullmatch(request):
+ raise CurrentPartyReadError("当前队伍读取请求 ID 格式无效")
+ managed_name = _safe_group_name(group_name)
+
+ group_dir = work_path / "User" / "ScriptGroup"
+ if not group_dir.is_dir():
+ raise CurrentPartyReadError(f"BGI 配置组目录不存在: {group_dir}")
+ managed_path = group_dir / f"{managed_name}.json"
+ managed = copy.deepcopy(_load_group_template(group_dir, managed_path))
+ managed["name"] = managed_name
+ managed["projects"] = [
+ {
+ "name": "读取当前队伍名称",
+ "folderName": CURRENT_PARTY_SCRIPT_NAME,
+ "jsScriptSettingsObject": {"requestId": request},
+ "index": 1,
+ "type": "Javascript",
+ "status": "Enabled",
+ "schedule": "Daily",
+ "runNum": 1,
+ "allowJsNotification": True,
+ "allowJsHTTPHash": "",
+ }
+ ]
+
+ status_path = (
+ work_path
+ / "User"
+ / "JsScript"
+ / CURRENT_PARTY_SCRIPT_NAME
+ / CURRENT_PARTY_STATUS_FILE
+ )
+ update = JsonUpdate(managed_path, managed, "生成当前队伍读取配置组")
+ return PreparedCurrentPartyRead(
+ request_id=request,
+ group_name=managed_name,
+ status_path=status_path,
+ updates=(update,),
+ )
+
+
+def clear_current_party_status(status_path: str | Path) -> None:
+ path = Path(status_path)
+ try:
+ path.unlink(missing_ok=True)
+ except OSError as exc:
+ raise CurrentPartyReadError(f"清理当前队伍读取状态失败: {exc}") from exc
+
+
+def read_current_party_status(
+ status_path: str | Path,
+ request_id: str,
+) -> CurrentPartyReadResult | None:
+ path = Path(status_path)
+ if not path.is_file():
+ return None
+ try:
+ data = json.loads(path.read_text(encoding="utf-8-sig"))
+ except (OSError, json.JSONDecodeError):
+ return None
+ if not isinstance(data, dict) or str(data.get("request_id") or "") != request_id:
+ return None
+
+ state = str(data.get("state") or "").strip().casefold()
+ if state in {"", "running"}:
+ return None
+ candidates = tuple(
+ str(value).strip()
+ for value in data.get("candidates", [])
+ if str(value).strip()
+ ) if isinstance(data.get("candidates"), list) else ()
+ if state == "success":
+ party_name = str(data.get("party_name") or "").strip()
+ if not party_name:
+ raise CurrentPartyReadError("当前队伍读取脚本返回成功,但队伍名称为空")
+ return CurrentPartyReadResult(party_name=party_name, candidates=candidates)
+ if state == "error":
+ message = str(data.get("message") or "读取当前队伍失败").strip()
+ raise CurrentPartyReadError(message)
+ raise CurrentPartyReadError(f"当前队伍读取脚本返回未知状态: {state}")
diff --git a/app/bettergi_daily.py b/app/bettergi_daily.py
new file mode 100644
index 0000000..ae62008
--- /dev/null
+++ b/app/bettergi_daily.py
@@ -0,0 +1,828 @@
+"""BetterGI managed configuration helpers for daily and party commands."""
+
+from __future__ import annotations
+
+import copy
+import difflib
+import json
+import os
+import re
+import tempfile
+import uuid
+from dataclasses import dataclass, replace
+from functools import lru_cache
+from pathlib import Path
+from typing import Any, Iterable
+
+
+class DailyAutomationError(ValueError):
+ """Raised when a managed BetterGI command cannot be prepared safely."""
+
+
+DAILY_MODE_NONE = "none"
+DAILY_MODE_DOMAIN = "domain"
+DAILY_MODE_LEY_LINE = "ley_line"
+DAILY_MODE_COMMISSION = "commission"
+
+LEY_LINE_COUNTRIES = ("蒙德", "璃月", "稻妻", "须弥", "枫丹", "纳塔", "挪德卡莱")
+LEY_LINE_TYPE_ALIASES = {
+ "经验": "启示之花",
+ "经验花": "启示之花",
+ "蓝花": "启示之花",
+ "启示": "启示之花",
+ "启示之花": "启示之花",
+ "摩拉": "藏金之花",
+ "摩拉花": "藏金之花",
+ "金币": "藏金之花",
+ "金币花": "藏金之花",
+ "黄花": "藏金之花",
+ "藏金": "藏金之花",
+ "藏金之花": "藏金之花",
+}
+
+DAILY_TASK_NAMES = {
+ "mail": "领取邮件",
+ "craft_resin": "合成树脂",
+ "domain": "自动秘境",
+ "ley_line": "自动地脉花",
+ "commission": "每日委托",
+ "serenitea": "领取尘歌壶奖励",
+ "daily_reward": "领取每日奖励",
+}
+
+_INVALID_BGI_NAME = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
+_NAME_NORMALIZE = re.compile(r"[\s\-_—-·.。,::,、/|]+")
+_MEMBER_SEPARATOR = re.compile(r"[\s,,、/|]+")
+
+
+@dataclass(frozen=True)
+class DailyRequest:
+ mode: str = DAILY_MODE_NONE
+ domain_name: str = ""
+ ley_line_type: str = ""
+ ley_line_country: str = ""
+
+ @property
+ def task_name(self) -> str:
+ if self.mode == DAILY_MODE_DOMAIN:
+ return f"自动每日(秘境:{self.domain_name})"
+ if self.mode == DAILY_MODE_LEY_LINE:
+ return f"自动每日(地脉:{self.ley_line_type}/{self.ley_line_country})"
+ if self.mode == DAILY_MODE_COMMISSION:
+ return "自动每日(委托)"
+ return "自动每日"
+
+ @property
+ def summary(self) -> str:
+ if self.mode == DAILY_MODE_DOMAIN:
+ return f"秘境 {self.domain_name}"
+ if self.mode == DAILY_MODE_LEY_LINE:
+ return f"地脉 {self.ley_line_type} {self.ley_line_country}"
+ if self.mode == DAILY_MODE_COMMISSION:
+ return "每日委托"
+ return "跳过其他任务"
+
+
+@dataclass(frozen=True)
+class JsonUpdate:
+ path: Path
+ data: dict[str, Any]
+ reason: str
+
+
+@dataclass(frozen=True)
+class PreparedDailyRun:
+ request: DailyRequest
+ config_name: str
+ updates: tuple[JsonUpdate, ...]
+ requires_current_party: bool = False
+
+ @property
+ def task_name(self) -> str:
+ return self.request.task_name
+
+
+def _normalize_name(value: str) -> str:
+ return _NAME_NORMALIZE.sub("", str(value or "").strip().casefold())
+
+
+def _safe_bgi_name(value: str, label: str) -> str:
+ name = str(value or "").strip()
+ if not name:
+ raise DailyAutomationError(f"{label}不能为空")
+ if name in {".", ".."} or _INVALID_BGI_NAME.search(name):
+ raise DailyAutomationError(f"{label}包含非法文件名字符: {name}")
+ return name
+
+
+def _read_json_object(path: Path, label: str) -> dict[str, Any]:
+ if not path.exists():
+ raise DailyAutomationError(f"未找到{label}: {path}")
+ try:
+ data = json.loads(path.read_text(encoding="utf-8-sig"))
+ except (OSError, json.JSONDecodeError) as exc:
+ raise DailyAutomationError(f"读取{label}失败: {exc}") from exc
+ if not isinstance(data, dict):
+ raise DailyAutomationError(f"{label}必须是 JSON 对象: {path}")
+ return data
+
+
+def parse_daily_request(argument: str) -> DailyRequest:
+ text = str(argument or "").strip()
+ if not text:
+ return DailyRequest()
+
+ parts = text.split()
+ mode = parts[0]
+ if mode == "秘境":
+ domain_name = " ".join(parts[1:]).strip()
+ if not domain_name:
+ raise DailyAutomationError("秘境模式需要指定秘境,例如:自动每日 秘境 风本")
+ return DailyRequest(mode=DAILY_MODE_DOMAIN, domain_name=domain_name)
+
+ if mode in {"地脉", "地脉花"}:
+ if len(parts) != 3:
+ raise DailyAutomationError("地脉模式格式:自动每日 地脉 <经验|摩拉> <国家>")
+ type_name = LEY_LINE_TYPE_ALIASES.get(_normalize_name(parts[1]))
+ if not type_name:
+ raise DailyAutomationError("地脉花类型仅支持经验或摩拉")
+ country = parts[2].strip()
+ if country not in LEY_LINE_COUNTRIES:
+ raise DailyAutomationError(
+ f"不支持的地脉国家'{country}',可用:{'、'.join(LEY_LINE_COUNTRIES)}"
+ )
+ return DailyRequest(
+ mode=DAILY_MODE_LEY_LINE,
+ ley_line_type=type_name,
+ ley_line_country=country,
+ )
+
+ if mode in {"委托", "每日委托"}:
+ if len(parts) != 1:
+ raise DailyAutomationError("委托模式不接受额外参数,格式:自动每日 委托")
+ return DailyRequest(mode=DAILY_MODE_COMMISSION)
+
+ raise DailyAutomationError(
+ "每日模式仅支持:秘境、地脉、委托;不指定模式时直接发送“自动每日”"
+ )
+
+
+class DomainAliasResolver:
+ def __init__(self, alias_path: Path, bettergi_work_dir: Path):
+ self.alias_path = Path(alias_path)
+ self.bettergi_work_dir = Path(bettergi_work_dir)
+
+ def _available_domains(self) -> list[str]:
+ settings_path = (
+ self.bettergi_work_dir
+ / "User"
+ / "JsScript"
+ / "AutoDomain"
+ / "settings.json"
+ )
+ if not settings_path.exists():
+ raise DailyAutomationError(
+ f"未找到 BGI 自动秘境设置文件,请安装或更新 AutoDomain 脚本: {settings_path}"
+ )
+ try:
+ settings = json.loads(settings_path.read_text(encoding="utf-8-sig"))
+ except (OSError, json.JSONDecodeError) as exc:
+ raise DailyAutomationError(f"读取 BGI 秘境列表失败: {exc}") from exc
+ if not isinstance(settings, list):
+ raise DailyAutomationError("BGI AutoDomain/settings.json 格式无效")
+ for item in settings:
+ if isinstance(item, dict) and item.get("name") == "domainName":
+ options = item.get("options")
+ if isinstance(options, list):
+ domains = [str(value).strip() for value in options if str(value).strip()]
+ if domains:
+ return domains
+ raise DailyAutomationError("BGI AutoDomain/settings.json 中没有秘境名称列表")
+
+ def resolve(self, raw_name: str) -> str:
+ available = self._available_domains()
+ available_set = set(available)
+ alias_data = _read_json_object(self.alias_path, "秘境俗称文件")
+ lookup: dict[str, str] = {}
+ display_terms: dict[str, str] = {}
+
+ def add(term: str, canonical: str) -> None:
+ normalized = _normalize_name(term)
+ if not normalized:
+ return
+ previous = lookup.get(normalized)
+ if previous and previous != canonical:
+ raise DailyAutomationError(
+ f"秘境俗称'{term}'同时指向'{previous}'和'{canonical}'"
+ )
+ lookup[normalized] = canonical
+ display_terms[normalized] = str(term).strip()
+
+ for canonical in available:
+ add(canonical, canonical)
+
+ for canonical, aliases in alias_data.items():
+ if str(canonical).startswith("_"):
+ continue
+ canonical_name = str(canonical).strip()
+ if canonical_name not in available_set:
+ raise DailyAutomationError(
+ f"秘境俗称文件中的正式名称不受当前 BGI 支持: {canonical_name}"
+ )
+ if not isinstance(aliases, list):
+ raise DailyAutomationError(f"秘境'{canonical_name}'的俗称必须是数组")
+ add(canonical_name, canonical_name)
+ for alias in aliases:
+ add(str(alias), canonical_name)
+
+ normalized_input = _normalize_name(raw_name)
+ resolved = lookup.get(normalized_input)
+ if resolved:
+ return resolved
+
+ matches = difflib.get_close_matches(normalized_input, list(lookup), n=3, cutoff=0.45)
+ if matches:
+ suggestions = []
+ for match in matches:
+ canonical = lookup[match]
+ display = display_terms.get(match, canonical)
+ suggestion = canonical if display == canonical else f"{display}({canonical})"
+ if suggestion not in suggestions:
+ suggestions.append(suggestion)
+ raise DailyAutomationError(
+ f"未知秘境'{raw_name}',可能是:{'、'.join(suggestions)}"
+ )
+ raise DailyAutomationError(f"未知秘境'{raw_name}',请检查 config/domain_aliases.json")
+
+
+def _validate_strategy(work_dir: Path, strategy_name: str, label: str) -> None:
+ strategy = str(strategy_name or "").strip()
+ if not strategy:
+ raise DailyAutomationError(f"{label}未配置战斗策略")
+ auto_fight_dir = work_dir / "User" / "AutoFight"
+ if strategy == "根据队伍自动选择":
+ if not auto_fight_dir.is_dir():
+ raise DailyAutomationError(f"{label}战斗策略目录不存在: {auto_fight_dir}")
+ return
+ json_path = auto_fight_dir / f"{strategy}.json"
+ txt_path = auto_fight_dir / f"{strategy}.txt"
+ if not json_path.is_file() and not txt_path.is_file():
+ raise DailyAutomationError(f"{label}战斗策略文件不存在: {strategy}")
+
+
+def _template_task_id_candidates(template: dict[str, Any]) -> dict[str, list[str]]:
+ enabled = template.get("TaskEnabledList")
+ order = template.get("TaskOrder")
+ definitions = template.get("TaskDefinitions")
+ if not isinstance(enabled, dict):
+ raise DailyAutomationError("一条龙模板缺少 TaskEnabledList 对象")
+ if order is None:
+ order = []
+ if not isinstance(order, list):
+ raise DailyAutomationError("一条龙模板缺少 TaskOrder 数组")
+ if definitions is None:
+ definitions = {}
+ if not isinstance(definitions, dict):
+ # BetterGI 为每个一条龙配置独立生成任务 ID,只能按任务名复用模板 ID。
+ raise DailyAutomationError("一条龙模板的 TaskDefinitions 必须是对象")
+
+ ordered_ids: list[str] = []
+ for raw_id in [*order, *definitions.keys(), *enabled.keys()]:
+ task_id = str(raw_id or "").strip()
+ if task_id and task_id not in ordered_ids:
+ ordered_ids.append(task_id)
+
+ candidates: dict[str, list[str]] = {}
+ old_format = not definitions
+ for task_id in ordered_ids:
+ raw_name = task_id if old_format else definitions.get(task_id)
+ task_name = str(raw_name or "").strip()
+ if not task_name:
+ continue
+ candidates.setdefault(task_name, []).append(task_id)
+ return candidates
+
+
+def _build_task_entries(
+ template: dict[str, Any],
+ task_keys: list[str],
+) -> list[tuple[str, str]]:
+ candidates = _template_task_id_candidates(template)
+ reserved_ids = {
+ task_id
+ for ids in candidates.values()
+ for task_id in ids
+ }
+ used_ids: set[str] = set()
+ entries: list[tuple[str, str]] = []
+ for task_key in task_keys:
+ task_name = DAILY_TASK_NAMES[task_key]
+ task_id = next(
+ (candidate for candidate in candidates.get(task_name, []) if candidate not in used_ids),
+ "",
+ )
+ while not task_id:
+ candidate = str(uuid.uuid4())
+ if candidate not in reserved_ids and candidate not in used_ids:
+ task_id = candidate
+ used_ids.add(task_id)
+ reserved_ids.add(task_id)
+ entries.append((task_id, task_name))
+ return entries
+
+
+def _build_one_dragon_config(
+ template: dict[str, Any],
+ request: DailyRequest,
+ managed_name: str,
+ ley_line_craft_resin_before: bool,
+) -> dict[str, Any]:
+ config = copy.deepcopy(template)
+ task_keys = ["mail"]
+ if request.mode != DAILY_MODE_LEY_LINE or ley_line_craft_resin_before:
+ task_keys.append("craft_resin")
+ if request.mode == DAILY_MODE_DOMAIN:
+ task_keys.append("domain")
+ elif request.mode == DAILY_MODE_LEY_LINE:
+ task_keys.append("ley_line")
+ elif request.mode == DAILY_MODE_COMMISSION:
+ task_keys.append("commission")
+ task_keys.extend(["serenitea", "daily_reward"])
+
+ task_entries = _build_task_entries(template, task_keys)
+ task_order = [task_id for task_id, _ in task_entries]
+ config["TaskEnabledList"] = {task_id: True for task_id in task_order}
+ config["TaskOrder"] = task_order
+ config["TaskDefinitions"] = dict(task_entries)
+ config["Name"] = managed_name
+ config["NextTaskId"] = ""
+ config["CompletionAction"] = "无"
+
+ if request.mode == DAILY_MODE_DOMAIN:
+ config["WeeklyDomainEnabled"] = False
+ config["DomainName"] = request.domain_name
+
+ if request.mode == DAILY_MODE_LEY_LINE:
+ config["LeyLineOneDragonMode"] = True
+ config["LeyLineResinExhaustionMode"] = True
+ config["LeyLineOpenModeCountMin"] = False
+ config["LeyLineRunCount"] = 1
+ for day in (
+ "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"
+ ):
+ config[f"LeyLineRun{day}"] = True
+ config[f"LeyLine{day}Type"] = request.ley_line_type
+ config[f"LeyLine{day}Country"] = request.ley_line_country
+
+ return config
+
+
+def _validate_commission_group(
+ work_path: Path,
+ *,
+ use_current_party: bool,
+) -> bool:
+ group_path = work_path / "User" / "ScriptGroup" / "每日委托.json"
+ group = _read_json_object(group_path, "每日委托配置组")
+ projects = group.get("projects")
+ if not isinstance(projects, list):
+ raise DailyAutomationError("BGI 配置组“每日委托”缺少 projects 数组")
+ enabled_projects = [
+ project
+ for project in projects
+ if isinstance(project, dict)
+ and str(project.get("status", "Enabled")).casefold() != "disabled"
+ ]
+ if not enabled_projects:
+ raise DailyAutomationError("BGI 配置组“每日委托”没有启用的可执行项目")
+
+ uses_auto_commission_nova = any(
+ str(project.get("folderName") or "") == "AutoCommissionNova"
+ for project in enabled_projects
+ )
+ if not uses_auto_commission_nova:
+ return False
+
+ user_config_path = (
+ work_path
+ / "User"
+ / "JsScript"
+ / "AutoCommissionNova"
+ / "Data"
+ / "user-config.json"
+ )
+ if not user_config_path.is_file():
+ raise DailyAutomationError(
+ "AutoCommissionNova 尚未完成首次配置:缺少 Data/user-config.json;"
+ "请先在 BGI 中手动运行脚本并保存用户配置和战斗策略"
+ )
+
+ user_config = _read_json_object(user_config_path, "AutoCommissionNova 用户配置")
+ party = user_config.get("party")
+ global_party = party.get("global") if isinstance(party, dict) else None
+ if not isinstance(global_party, dict):
+ raise DailyAutomationError("AutoCommissionNova 用户配置缺少 party.global 对象")
+
+ missing = []
+ if not use_current_party:
+ if not str(global_party.get("battleTeamName") or "").strip():
+ missing.append("战斗队伍")
+ if not str(global_party.get("elementTeamName") or "").strip():
+ missing.append("元素采集队伍")
+ if missing:
+ raise DailyAutomationError(
+ f"AutoCommissionNova 首次配置不完整,缺少:{'、'.join(missing)}"
+ )
+ strategy_name = str(
+ global_party.get("battleStrategy") or "根据队伍自动选择"
+ ).strip()
+ _validate_strategy(work_path, strategy_name, "每日委托")
+ return bool(use_current_party)
+
+
+def prepare_daily_run(
+ work_dir: str | Path,
+ alias_path: str | Path,
+ argument: str,
+ *,
+ template_name: str,
+ managed_name: str,
+ ley_line_craft_resin_before: bool,
+ commission_use_current_party: bool = False,
+) -> PreparedDailyRun:
+ work_path = Path(work_dir)
+ if not work_path.is_dir():
+ raise DailyAutomationError(f"BetterGI 工作目录不存在: {work_path}")
+ template = _safe_bgi_name(template_name, "一条龙模板名称")
+ managed = _safe_bgi_name(managed_name, "托管一条龙名称")
+ if template == managed:
+ raise DailyAutomationError("一条龙模板名称不能与托管配置名称相同")
+
+ request = parse_daily_request(argument)
+ updates: list[JsonUpdate] = []
+ requires_current_party = False
+ user_config_path = work_path / "User" / "config.json"
+
+ if request.mode == DAILY_MODE_DOMAIN:
+ resolved = DomainAliasResolver(Path(alias_path), work_path).resolve(request.domain_name)
+ request = replace(request, domain_name=resolved)
+ user_config = _read_json_object(user_config_path, "BGI User/config.json")
+ auto_fight = user_config.get("autoFightConfig")
+ auto_domain = user_config.get("autoDomainConfig")
+ if not isinstance(auto_fight, dict) or not isinstance(auto_domain, dict):
+ raise DailyAutomationError("当前 BGI 缺少自动战斗或自动秘境配置,请升级到 0.63.0+")
+ _validate_strategy(work_path, auto_fight.get("strategyName", ""), "自动秘境")
+ corrected = copy.deepcopy(user_config)
+ corrected["autoDomainConfig"]["specifyResinUse"] = False
+ if corrected != user_config:
+ updates.append(JsonUpdate(user_config_path, corrected, "关闭自动秘境指定树脂次数"))
+
+ elif request.mode == DAILY_MODE_LEY_LINE:
+ user_config = _read_json_object(user_config_path, "BGI User/config.json")
+ auto_fight = user_config.get("autoFightConfig")
+ ley_line = user_config.get("autoLeyLineOutcropConfig")
+ if not isinstance(auto_fight, dict) or not isinstance(ley_line, dict):
+ raise DailyAutomationError("当前 BGI 缺少自动战斗或自动地脉花配置,请升级到 0.63.0+")
+ fight_config = ley_line.get("fightConfig")
+ strategy_name = ""
+ if isinstance(fight_config, dict):
+ strategy_name = str(fight_config.get("strategyName") or "").strip()
+ if not strategy_name:
+ strategy_name = str(auto_fight.get("strategyName") or "").strip()
+ _validate_strategy(work_path, strategy_name, "自动地脉花")
+ if ley_line.get("friendshipTeam") and not ley_line.get("team"):
+ raise DailyAutomationError("BGI 自动地脉花配置了好感队,但未配置战斗队伍")
+ corrected = copy.deepcopy(user_config)
+ corrected["autoLeyLineOutcropConfig"]["isGoToSynthesizer"] = False
+ if corrected != user_config:
+ updates.append(JsonUpdate(user_config_path, corrected, "关闭地脉花内部合成树脂"))
+
+ elif request.mode == DAILY_MODE_COMMISSION:
+ requires_current_party = _validate_commission_group(
+ work_path,
+ use_current_party=bool(commission_use_current_party),
+ )
+
+ template_path = work_path / "User" / "OneDragon" / f"{template}.json"
+ template_config = _read_json_object(template_path, "一条龙模板")
+ managed_config = _build_one_dragon_config(
+ template_config,
+ request,
+ managed,
+ bool(ley_line_craft_resin_before),
+ )
+ managed_path = work_path / "User" / "OneDragon" / f"{managed}.json"
+ updates.append(JsonUpdate(managed_path, managed_config, "生成直播自动每日一条龙"))
+ return PreparedDailyRun(
+ request=request,
+ config_name=managed,
+ updates=tuple(updates),
+ requires_current_party=requires_current_party,
+ )
+
+
+def prepare_commission_current_party_update(
+ work_dir: str | Path,
+ party_name: str,
+) -> JsonUpdate:
+ work_path = Path(work_dir)
+ name = str(party_name or "").strip()
+ if not name:
+ raise DailyAutomationError("当前队伍名称为空")
+ if len(name) > 20 or any(ord(char) < 32 for char in name):
+ raise DailyAutomationError(f"当前队伍名称格式无效: {name}")
+
+ user_config_path = (
+ work_path
+ / "User"
+ / "JsScript"
+ / "AutoCommissionNova"
+ / "Data"
+ / "user-config.json"
+ )
+ user_config = _read_json_object(user_config_path, "AutoCommissionNova 用户配置")
+ corrected = copy.deepcopy(user_config)
+ party = corrected.get("party")
+ global_party = party.get("global") if isinstance(party, dict) else None
+ if not isinstance(global_party, dict):
+ raise DailyAutomationError("AutoCommissionNova 用户配置缺少 party.global 对象")
+ global_party["battleTeamName"] = name
+ global_party["elementTeamName"] = name
+ return JsonUpdate(
+ user_config_path,
+ corrected,
+ f"将 AutoCommissionNova 战斗及元素采集队伍更新为当前队伍“{name}”",
+ )
+
+
+def _prepare_script_group_update(
+ work_dir: str | Path,
+ group_name: str,
+ folder_name: str,
+ settings_patch: dict[str, Any],
+) -> JsonUpdate:
+ work_path = Path(work_dir)
+ safe_group = _safe_bgi_name(group_name, "配置组名称")
+ group_path = work_path / "User" / "ScriptGroup" / f"{safe_group}.json"
+ group = _read_json_object(group_path, f"配置组“{safe_group}”")
+ projects = group.get("projects")
+ if not isinstance(projects, list):
+ raise DailyAutomationError(f"配置组“{safe_group}”缺少 projects 数组")
+ matches = [
+ project
+ for project in projects
+ if isinstance(project, dict)
+ and str(project.get("folderName") or "") == folder_name
+ and str(project.get("status", "Enabled")).casefold() != "disabled"
+ ]
+ if not matches:
+ raise DailyAutomationError(
+ f"配置组“{safe_group}”中没有启用的 {folder_name} JavaScript 项目"
+ )
+ if len(matches) > 1:
+ raise DailyAutomationError(
+ f"配置组“{safe_group}”包含多个启用的 {folder_name} 项目,请只保留一个"
+ )
+ settings = matches[0].get("jsScriptSettingsObject")
+ if not isinstance(settings, dict):
+ settings = {}
+ matches[0]["jsScriptSettingsObject"] = settings
+ settings.update(settings_patch)
+ return JsonUpdate(group_path, group, f"更新配置组“{safe_group}”参数")
+
+
+def prepare_switch_party_update(work_dir: str | Path, party_name: str) -> JsonUpdate:
+ party = str(party_name or "").strip()
+ if not party:
+ raise DailyAutomationError("队伍名称不能为空")
+ return _prepare_script_group_update(
+ work_dir,
+ "切换队伍",
+ "AcceleratedEditionSwitchParty",
+ {"partyName": party},
+ )
+
+
+def _add_character_lookup(
+ lookup: dict[str, str],
+ ambiguous: set[str],
+ raw_name: str,
+ canonical: str,
+) -> None:
+ key = _normalize_name(raw_name)
+ if not key or key in ambiguous:
+ return
+ previous = lookup.get(key)
+ if previous and previous != canonical:
+ lookup.pop(key, None)
+ ambiguous.add(key)
+ return
+ lookup[key] = canonical
+
+
+def _character_lookups_from_settings(
+ data: Any,
+) -> tuple[dict[str, str], dict[str, str]] | None:
+ if not isinstance(data, list):
+ return None
+
+ position_options: dict[str, list[str]] = {}
+ for item in data:
+ if not isinstance(item, dict):
+ continue
+ name = str(item.get("name") or "")
+ if name not in {"position1", "position2", "position3", "position4"}:
+ continue
+ options = item.get("options")
+ if isinstance(options, list):
+ position_options[name] = [
+ str(option).strip()
+ for option in options
+ if str(option).strip()
+ ]
+ if len(position_options) != 4 or not position_options.get("position1"):
+ return None
+
+ full_lookup: dict[str, str] = {}
+ simple_lookup: dict[str, str] = {}
+ ambiguous_full: set[str] = set()
+ ambiguous_simple: set[str] = set()
+ for option in position_options["position1"]:
+ _add_character_lookup(full_lookup, ambiguous_full, option, option)
+ simple_name = option.rsplit("-", 1)[-1].strip()
+ _add_character_lookup(simple_lookup, ambiguous_simple, simple_name, option)
+ if not simple_lookup:
+ return None
+ return full_lookup, simple_lookup
+
+
+def _character_lookups_from_combat_avatar(
+ data: Any,
+) -> tuple[dict[str, str], dict[str, str]] | None:
+ if not isinstance(data, list):
+ return None
+
+ full_lookup: dict[str, str] = {}
+ simple_lookup: dict[str, str] = {}
+ ambiguous_full: set[str] = set()
+ ambiguous_simple: set[str] = set()
+ for item in data:
+ if not isinstance(item, dict):
+ continue
+ canonical = str(item.get("name") or "").strip()
+ if not canonical:
+ continue
+ terms = [canonical]
+ aliases = item.get("alias")
+ if isinstance(aliases, list):
+ terms.extend(str(alias).strip() for alias in aliases if str(alias).strip())
+ for term in terms:
+ _add_character_lookup(full_lookup, ambiguous_full, term, canonical)
+ _add_character_lookup(simple_lookup, ambiguous_simple, term, canonical)
+ if not simple_lookup:
+ return None
+ return full_lookup, simple_lookup
+
+
+def _load_character_options(work_dir: Path) -> tuple[dict[str, str], dict[str, str]]:
+ script_dir = work_dir / "User" / "JsScript" / "AutoSwitchRoles"
+ settings_path = script_dir / "settings.json"
+ avatar_path = script_dir / "combat_avatar.json"
+ failures: list[str] = []
+
+ if settings_path.exists():
+ try:
+ settings_data = json.loads(settings_path.read_text(encoding="utf-8-sig"))
+ except (OSError, json.JSONDecodeError) as exc:
+ failures.append(f"settings.json 读取失败: {exc}")
+ else:
+ lookups = _character_lookups_from_settings(settings_data)
+ if lookups:
+ return lookups
+ failures.append("settings.json 未提供四个队员位置的 options")
+ else:
+ failures.append("缺少 settings.json")
+
+ if avatar_path.exists():
+ try:
+ avatar_data = json.loads(avatar_path.read_text(encoding="utf-8-sig"))
+ except (OSError, json.JSONDecodeError) as exc:
+ failures.append(f"combat_avatar.json 读取失败: {exc}")
+ else:
+ lookups = _character_lookups_from_combat_avatar(avatar_data)
+ if lookups:
+ return lookups
+ failures.append("combat_avatar.json 中没有可用角色")
+ else:
+ failures.append("缺少 combat_avatar.json")
+
+ raise DailyAutomationError(
+ "AutoSwitchRoles 角色数据不可用,请安装或更新“配对界面切换角色”脚本: "
+ + ";".join(failures)
+ )
+
+
+def _resolve_member_token(
+ token: str,
+ full_lookup: dict[str, str],
+ simple_lookup: dict[str, str],
+) -> str:
+ key = _normalize_name(token)
+ resolved = full_lookup.get(key) or simple_lookup.get(key)
+ if not resolved:
+ raise DailyAutomationError(f"未知或有歧义的角色名称: {token}")
+ return resolved
+
+
+def _split_contiguous_members(text: str, simple_lookup: dict[str, str]) -> list[str]:
+ normalized = _normalize_name(text)
+ candidates = sorted(simple_lookup, key=len, reverse=True)
+
+ @lru_cache(maxsize=None)
+ def walk(offset: int, slots: int) -> tuple[tuple[str, ...], ...]:
+ if slots == 4:
+ return ((),) if offset == len(normalized) else ()
+ if offset >= len(normalized):
+ return ()
+ results: list[tuple[str, ...]] = []
+ for candidate in candidates:
+ if not normalized.startswith(candidate, offset):
+ continue
+ for remainder in walk(offset + len(candidate), slots + 1):
+ results.append((candidate, *remainder))
+ if len(results) >= 2:
+ return tuple(results)
+ return tuple(results)
+
+ segmentations = walk(0, 0)
+ if not segmentations:
+ raise DailyAutomationError("队员必须是4人,请使用空格、逗号、顿号或斜杠分隔")
+ if len(segmentations) > 1:
+ raise DailyAutomationError("连续角色名存在多种拆分方式,请使用空格分隔四名角色")
+ return [simple_lookup[key] for key in segmentations[0]]
+
+
+def resolve_party_members(work_dir: str | Path, argument: str) -> tuple[list[str], list[str]]:
+ text = str(argument or "").strip()
+ if not text:
+ raise DailyAutomationError("队员必须是4人")
+ full_lookup, simple_lookup = _load_character_options(Path(work_dir))
+ parts = [part for part in _MEMBER_SEPARATOR.split(text) if part]
+ if len(parts) == 4:
+ resolved = [
+ _resolve_member_token(part, full_lookup, simple_lookup)
+ for part in parts
+ ]
+ else:
+ resolved = _split_contiguous_members(text, simple_lookup)
+ if len(resolved) != 4:
+ raise DailyAutomationError("队员必须是4人")
+ if len(set(resolved)) != 4:
+ raise DailyAutomationError("四名队员不能重复")
+ display_names = [value.rsplit("-", 1)[-1] for value in resolved]
+ return resolved, display_names
+
+
+def prepare_edit_party_update(
+ work_dir: str | Path,
+ argument: str,
+) -> tuple[JsonUpdate, tuple[str, ...]]:
+ resolved, display_names = resolve_party_members(work_dir, argument)
+ update = _prepare_script_group_update(
+ work_dir,
+ "修改队员",
+ "AutoSwitchRoles",
+ {f"position{index + 1}": value for index, value in enumerate(resolved)},
+ )
+ return update, tuple(display_names)
+
+
+def _write_json_atomic(path: Path, data: dict[str, Any]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fd, temp_name = tempfile.mkstemp(
+ prefix=f".{path.name}.",
+ suffix=".tmp",
+ dir=str(path.parent),
+ )
+ temp_path = Path(temp_name)
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
+ json.dump(data, handle, ensure_ascii=False, indent=2)
+ handle.write("\n")
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.replace(temp_path, path)
+ finally:
+ if temp_path.exists():
+ temp_path.unlink()
+
+
+def apply_json_updates(updates: Iterable[JsonUpdate]) -> None:
+ seen: set[Path] = set()
+ for update in updates:
+ path = Path(update.path)
+ resolved = path.resolve()
+ if resolved in seen:
+ raise DailyAutomationError(f"同一配置文件被重复更新: {path}")
+ seen.add(resolved)
+ _write_json_atomic(path, update.data)
diff --git a/app/bilibili_cookie_refresh.py b/app/bilibili_cookie_refresh.py
new file mode 100644
index 0000000..7fdd1cb
--- /dev/null
+++ b/app/bilibili_cookie_refresh.py
@@ -0,0 +1,601 @@
+from __future__ import annotations
+
+import asyncio
+import base64
+import ctypes
+import ctypes.wintypes
+import hashlib
+import http.cookiejar
+import http.cookies
+import io
+import json
+import logging
+import os
+import secrets
+import time
+import urllib.parse
+import urllib.error
+import urllib.request
+from pathlib import Path
+from typing import Any, Callable
+
+
+_PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
+MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDLgd2OAkcGVtoE3ThUREbio0Eg
+Uc/prcajMKXvkCKFCWhJYJcLkcM2DKKcSeFpD/j6Boy538YXnR6VhcuUJOhH2x71
+nzPjfdTcqMz7djHum0qSZA0AyCBDABUqCrfNgCiJ00Ra7GmRj+YCK1NJEuewlb40
+JNrRuoEUXpabUzGB8QIDAQAB
+-----END PUBLIC KEY-----"""
+_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
+_QR_GENERATE_URL = "https://passport.bilibili.com/x/passport-login/web/qrcode/generate"
+_QR_POLL_URL = "https://passport.bilibili.com/x/passport-login/web/qrcode/poll"
+_QR_HEADERS = {
+ "Referer": "https://www.bilibili.com/",
+ "Origin": "https://www.bilibili.com",
+}
+
+
+class _DataBlob(ctypes.Structure):
+ _fields_ = [("cbData", ctypes.wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_byte))]
+
+
+def _blob(data: bytes) -> tuple[_DataBlob, Any]:
+ buffer = ctypes.create_string_buffer(data)
+ return _DataBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_byte))), buffer
+
+
+def _dpapi_encrypt(value: str) -> str:
+ if os.name != "nt":
+ raise RuntimeError("B站刷新令牌安全存储仅支持 Windows DPAPI")
+ source, source_buffer = _blob(value.encode("utf-8"))
+ entropy, entropy_buffer = _blob(b"Live-streaming:bilibili-refresh-token:v1")
+ output = _DataBlob()
+ ok = ctypes.windll.crypt32.CryptProtectData(
+ ctypes.byref(source), None, ctypes.byref(entropy), None, None, 0,
+ ctypes.byref(output),
+ )
+ _ = source_buffer, entropy_buffer
+ if not ok:
+ raise ctypes.WinError()
+ try:
+ encrypted = ctypes.string_at(output.pbData, output.cbData)
+ return base64.b64encode(encrypted).decode("ascii")
+ finally:
+ ctypes.windll.kernel32.LocalFree(output.pbData)
+
+
+def _dpapi_decrypt(value: str) -> str:
+ if os.name != "nt":
+ raise RuntimeError("B站刷新令牌安全存储仅支持 Windows DPAPI")
+ source, source_buffer = _blob(base64.b64decode(value))
+ entropy, entropy_buffer = _blob(b"Live-streaming:bilibili-refresh-token:v1")
+ output = _DataBlob()
+ ok = ctypes.windll.crypt32.CryptUnprotectData(
+ ctypes.byref(source), None, ctypes.byref(entropy), None, None, 0,
+ ctypes.byref(output),
+ )
+ _ = source_buffer, entropy_buffer
+ if not ok:
+ raise ctypes.WinError()
+ try:
+ return ctypes.string_at(output.pbData, output.cbData).decode("utf-8")
+ finally:
+ ctypes.windll.kernel32.LocalFree(output.pbData)
+
+
+class BilibiliCredentialStore:
+ def __init__(self, path: str | Path):
+ self.path = Path(path)
+
+ def save_refresh_token(self, refresh_token: str) -> None:
+ token = str(refresh_token or "").strip()
+ if not token:
+ raise ValueError("refresh_token 不能为空")
+ payload = {
+ "version": 1,
+ "provider": "windows_dpapi_current_user",
+ "refresh_token_protected": _dpapi_encrypt(token),
+ }
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = self.path.with_suffix(self.path.suffix + ".tmp")
+ with open(tmp, "w", encoding="utf-8", newline="\n") as handle:
+ json.dump(payload, handle, ensure_ascii=False, indent=2)
+ handle.write("\n")
+ tmp.replace(self.path)
+
+ def load_refresh_token(self) -> str:
+ if not self.path.exists():
+ return ""
+ with open(self.path, "r", encoding="utf-8") as handle:
+ payload = json.load(handle)
+ protected = str(payload.get("refresh_token_protected") or "")
+ return _dpapi_decrypt(protected) if protected else ""
+
+ def is_configured(self) -> bool:
+ if not self.path.exists():
+ return False
+ try:
+ with open(self.path, "r", encoding="utf-8") as handle:
+ payload = json.load(handle)
+ return bool(str(payload.get("refresh_token_protected") or ""))
+ except (OSError, ValueError, TypeError):
+ return False
+
+
+def _read_der_length(data: bytes, offset: int) -> tuple[int, int]:
+ first = data[offset]
+ offset += 1
+ if first < 0x80:
+ return first, offset
+ count = first & 0x7F
+ return int.from_bytes(data[offset:offset + count], "big"), offset + count
+
+
+def _read_der_tlv(data: bytes, offset: int, expected_tag: int | None = None) -> tuple[int, bytes, int]:
+ tag = data[offset]
+ if expected_tag is not None and tag != expected_tag:
+ raise ValueError(f"DER tag 不匹配: expected={expected_tag:#x}, actual={tag:#x}")
+ length, content_offset = _read_der_length(data, offset + 1)
+ end = content_offset + length
+ return tag, data[content_offset:end], end
+
+
+def _public_numbers() -> tuple[int, int]:
+ body = "".join(line for line in _PUBLIC_KEY_PEM.splitlines() if not line.startswith("-----"))
+ der = base64.b64decode(body)
+ _, spki, _ = _read_der_tlv(der, 0, 0x30)
+ _, _, offset = _read_der_tlv(spki, 0, 0x30)
+ _, bit_string, _ = _read_der_tlv(spki, offset, 0x03)
+ _, rsa_key, _ = _read_der_tlv(bit_string[1:], 0, 0x30)
+ _, modulus_bytes, rsa_offset = _read_der_tlv(rsa_key, 0, 0x02)
+ _, exponent_bytes, _ = _read_der_tlv(rsa_key, rsa_offset, 0x02)
+ return int.from_bytes(modulus_bytes, "big"), int.from_bytes(exponent_bytes, "big")
+
+
+def _mgf1(seed: bytes, length: int) -> bytes:
+ result = bytearray()
+ counter = 0
+ while len(result) < length:
+ result.extend(hashlib.sha256(seed + counter.to_bytes(4, "big")).digest())
+ counter += 1
+ return bytes(result[:length])
+
+
+def _rsa_oaep_sha256_encrypt(message: bytes) -> str:
+ modulus, exponent = _public_numbers()
+ key_size = (modulus.bit_length() + 7) // 8
+ digest_size = hashlib.sha256().digest_size
+ if len(message) > key_size - 2 * digest_size - 2:
+ raise ValueError("待加密内容过长")
+ label_hash = hashlib.sha256(b"").digest()
+ padding = b"\x00" * (key_size - len(message) - 2 * digest_size - 2)
+ data_block = label_hash + padding + b"\x01" + message
+ seed = secrets.token_bytes(digest_size)
+ data_mask = _mgf1(seed, key_size - digest_size - 1)
+ masked_data = bytes(left ^ right for left, right in zip(data_block, data_mask))
+ seed_mask = _mgf1(masked_data, digest_size)
+ masked_seed = bytes(left ^ right for left, right in zip(seed, seed_mask))
+ encoded = b"\x00" + masked_seed + masked_data
+ encrypted = pow(int.from_bytes(encoded, "big"), exponent, modulus)
+ return encrypted.to_bytes(key_size, "big").hex()
+
+
+def _parse_cookie(cookie_text: str) -> dict[str, str]:
+ parsed = http.cookies.SimpleCookie()
+ parsed.load(str(cookie_text or "").replace("; ", ";"))
+ return {name: morsel.value for name, morsel in parsed.items()}
+
+
+def _cookie_header(values: dict[str, str]) -> str:
+ return "; ".join(f"{name}={value}" for name, value in values.items() if value)
+
+
+def _request_json(url: str, *, cookie: str = "", data: dict[str, str] | None = None,
+ opener: urllib.request.OpenerDirector | None = None,
+ headers: dict[str, str] | None = None,
+ retries: int = 0) -> tuple[dict, Any]:
+ body = urllib.parse.urlencode(data).encode("utf-8") if data is not None else None
+ request = urllib.request.Request(url, data=body, method="POST" if body is not None else "GET")
+ request.add_header("User-Agent", _USER_AGENT)
+ for name, value in (headers or {}).items():
+ request.add_header(name, value)
+ if cookie:
+ request.add_header("Cookie", cookie)
+ if body is not None:
+ request.add_header("Content-Type", "application/x-www-form-urlencoded")
+ retry_count = max(0, int(retries))
+ for attempt in range(retry_count + 1):
+ try:
+ response = (opener or urllib.request.build_opener()).open(request, timeout=15)
+ return json.loads(response.read().decode("utf-8")), response
+ except urllib.error.HTTPError:
+ raise
+ except (urllib.error.URLError, ConnectionError, TimeoutError, OSError) as exc:
+ if attempt >= retry_count:
+ raise RuntimeError("连接B站登录服务失败,请稍后重试") from exc
+ time.sleep(0.4 * (attempt + 1))
+
+ raise AssertionError("unreachable")
+
+
+def _seed_cookie_jar(jar: http.cookiejar.CookieJar, values: dict[str, str]) -> None:
+ for name, value in values.items():
+ jar.set_cookie(http.cookiejar.Cookie(
+ version=0, name=name, value=value, port=None, port_specified=False,
+ domain=".bilibili.com", domain_specified=True, domain_initial_dot=True,
+ path="/", path_specified=True, secure=False, expires=None, discard=True,
+ comment=None, comment_url=None, rest={}, rfc2109=False,
+ ))
+
+
+def _jar_values(jar: http.cookiejar.CookieJar) -> dict[str, str]:
+ return {cookie.name: cookie.value for cookie in jar}
+
+
+def _login_url_cookie_values(url: str) -> dict[str, str]:
+ values: dict[str, str] = {}
+ query = urllib.parse.urlparse(str(url or "")).query
+ for item in query.split("&"):
+ raw_name, separator, raw_value = item.partition("=")
+ if not separator:
+ continue
+ name = urllib.parse.unquote_plus(raw_name)
+ if name in {"SESSDATA", "bili_jct", "DedeUserID", "DedeUserID__ckMd5", "sid", "buvid3"}:
+ values[name] = raw_value
+ return values
+
+
+def _render_qr_png(content: str) -> bytes:
+ try:
+ import qrcode
+ from qrcode.constants import ERROR_CORRECT_M
+ except ImportError as exc:
+ raise RuntimeError("缺少 qrcode 依赖,请重新安装 requirements.txt") from exc
+
+ qr = qrcode.QRCode(
+ version=None,
+ error_correction=ERROR_CORRECT_M,
+ box_size=8,
+ border=3,
+ )
+ qr.add_data(content)
+ qr.make(fit=True)
+ image = qr.make_image(fill_color="black", back_color="white")
+ output = io.BytesIO()
+ image.save(output, format="PNG")
+ return output.getvalue()
+
+
+class BilibiliQrLogin:
+ """服务端持有二维码密钥和 CookieJar,前端只获取二维码图片与状态。"""
+
+ _STATUS_MESSAGES = {
+ "idle": "尚未开始扫码登录",
+ "awaiting_scan": "请使用哔哩哔哩客户端扫码",
+ "awaiting_confirm": "已扫码,请在手机上确认登录",
+ "completed": "登录成功,Cookie 与刷新凭据已更新",
+ "expired": "二维码已过期,请重新生成",
+ "failed": "扫码登录失败",
+ }
+
+ def __init__(
+ self,
+ *,
+ credential_store: BilibiliCredentialStore,
+ update_cookie: Callable[[dict[str, str]], None],
+ logger: logging.Logger,
+ on_logged_in: Callable[[], Any] | None = None,
+ ttl_seconds: int = 180,
+ ):
+ self.credential_store = credential_store
+ self.update_cookie = update_cookie
+ self.logger = logger
+ self.on_logged_in = on_logged_in
+ self.ttl_seconds = max(60, int(ttl_seconds))
+ self._lock = asyncio.Lock()
+ self._session: dict[str, Any] | None = None
+
+ def _snapshot(self) -> dict[str, Any]:
+ session = self._session or {}
+ state = str(session.get("state") or "idle")
+ expires_at = float(session.get("expires_at") or 0)
+ expires_in = max(0, int(expires_at - time.time())) if expires_at else 0
+ return {
+ "success": True,
+ "state": state,
+ "message": str(session.get("message") or self._STATUS_MESSAGES.get(state, "")),
+ "expires_in": expires_in,
+ "has_qr_image": state in {"awaiting_scan", "awaiting_confirm"} and expires_in > 0,
+ "credential_configured": self.credential_store.is_configured(),
+ "account": session.get("account"),
+ }
+
+ def snapshot(self) -> dict[str, Any]:
+ return self._snapshot()
+
+ def _start_sync(self) -> dict[str, Any]:
+ jar = http.cookiejar.CookieJar()
+ opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
+ payload, _ = _request_json(
+ _QR_GENERATE_URL,
+ opener=opener,
+ headers=_QR_HEADERS,
+ retries=2,
+ )
+ if payload.get("code") != 0:
+ raise RuntimeError(f"B站二维码申请失败 code={payload.get('code')}")
+ data = payload.get("data") or {}
+ qr_url = str(data.get("url") or "").strip()
+ qr_key = str(data.get("qrcode_key") or "").strip()
+ if not qr_url or not qr_key:
+ raise RuntimeError("B站二维码响应缺少必要字段")
+ now = time.time()
+ return {
+ "state": "awaiting_scan",
+ "message": self._STATUS_MESSAGES["awaiting_scan"],
+ "created_at": now,
+ "expires_at": now + self.ttl_seconds,
+ "qr_url": qr_url,
+ "qr_key": qr_key,
+ "jar": jar,
+ "opener": opener,
+ "account": None,
+ }
+
+ async def start(self) -> dict[str, Any]:
+ async with self._lock:
+ try:
+ self._session = await asyncio.to_thread(self._start_sync)
+ except Exception as exc:
+ self._session = {
+ "state": "failed",
+ "message": str(exc) or type(exc).__name__,
+ "expires_at": 0,
+ }
+ self.logger.warning("[B站扫码登录] 二维码申请失败: %s", type(exc).__name__)
+ return self._snapshot()
+ self.logger.info("[B站扫码登录] 二维码已生成,等待扫码")
+ return self._snapshot()
+
+ def _poll_sync(self, session: dict[str, Any]) -> dict[str, Any]:
+ url = _QR_POLL_URL + "?" + urllib.parse.urlencode({"qrcode_key": session["qr_key"]})
+ payload, _ = _request_json(
+ url,
+ opener=session["opener"],
+ headers=_QR_HEADERS,
+ retries=2,
+ )
+ if payload.get("code") != 0:
+ return {"state": "failed", "message": f"B站扫码状态查询失败 code={payload.get('code')}"}
+
+ data = payload.get("data") or {}
+ status_code = int(data.get("code") or 0)
+ if status_code == 86101:
+ return {"state": "awaiting_scan", "message": self._STATUS_MESSAGES["awaiting_scan"]}
+ if status_code == 86090:
+ return {"state": "awaiting_confirm", "message": self._STATUS_MESSAGES["awaiting_confirm"]}
+ if status_code == 86038:
+ return {"state": "expired", "message": self._STATUS_MESSAGES["expired"]}
+ if status_code != 0:
+ return {"state": "failed", "message": str(data.get("message") or f"扫码失败 code={status_code}")}
+
+ refresh_token = str(data.get("refresh_token") or "").strip()
+ if not refresh_token:
+ return {"state": "failed", "message": "扫码成功响应缺少 refresh_token"}
+ cookie_values = _jar_values(session["jar"])
+ for name, value in _login_url_cookie_values(str(data.get("url") or "")).items():
+ cookie_values.setdefault(name, value)
+ if not cookie_values.get("SESSDATA") or not cookie_values.get("bili_jct"):
+ return {"state": "failed", "message": "扫码成功但登录 Cookie 不完整"}
+
+ cookie = _cookie_header(cookie_values)
+ nav, _ = _request_json(
+ "https://api.bilibili.com/x/web-interface/nav",
+ cookie=cookie,
+ headers={"Referer": "https://www.bilibili.com/"},
+ retries=2,
+ )
+ nav_data = nav.get("data") or {}
+ if nav.get("code") != 0 or not bool(nav_data.get("isLogin")):
+ return {"state": "failed", "message": "扫码 Cookie 登录验证失败"}
+
+ self.credential_store.save_refresh_token(refresh_token)
+ self.update_cookie(cookie_values)
+ return {
+ "state": "completed",
+ "message": self._STATUS_MESSAGES["completed"],
+ "account": {
+ "mid": str(nav_data.get("mid") or ""),
+ "uname": str(nav_data.get("uname") or "B站账号"),
+ },
+ }
+
+ async def poll(self) -> dict[str, Any]:
+ callback_needed = False
+ async with self._lock:
+ if not self._session:
+ return self._snapshot()
+ state = str(self._session.get("state") or "idle")
+ if state in {"completed", "expired", "failed"}:
+ return self._snapshot()
+ if time.time() >= float(self._session.get("expires_at") or 0):
+ self._session.update(state="expired", message=self._STATUS_MESSAGES["expired"])
+ return self._snapshot()
+ try:
+ result = await asyncio.to_thread(self._poll_sync, self._session)
+ except Exception as exc:
+ self.logger.warning("[B站扫码登录] 状态查询异常: %s", type(exc).__name__)
+ self._session.update(
+ state="failed",
+ message=str(exc) or f"扫码状态查询异常: {type(exc).__name__}",
+ )
+ return self._snapshot()
+ previous_state = state
+ self._session.update(result)
+ callback_needed = previous_state != "completed" and result.get("state") == "completed"
+ snapshot = self._snapshot()
+
+ if callback_needed:
+ self.logger.info("[B站扫码登录] 登录成功,Cookie 与刷新凭据已更新")
+ if self.on_logged_in:
+ callback_result = self.on_logged_in()
+ if asyncio.iscoroutine(callback_result):
+ await callback_result
+ return snapshot
+
+ async def qr_png(self) -> bytes:
+ async with self._lock:
+ if not self._session or self._session.get("state") not in {"awaiting_scan", "awaiting_confirm"}:
+ raise RuntimeError("当前没有可用的登录二维码")
+ if time.time() >= float(self._session.get("expires_at") or 0):
+ self._session.update(state="expired", message=self._STATUS_MESSAGES["expired"])
+ raise RuntimeError("登录二维码已过期")
+ content = str(self._session.get("qr_url") or "")
+ return await asyncio.to_thread(_render_qr_png, content)
+
+
+class BilibiliCookieRefresher:
+ def __init__(
+ self,
+ *,
+ credential_store: BilibiliCredentialStore,
+ get_cookie: Callable[[], str],
+ update_cookie: Callable[[dict[str, str]], None],
+ logger: logging.Logger,
+ check_interval_seconds: int = 6 * 60 * 60,
+ on_refreshed: Callable[[], Any] | None = None,
+ is_enabled: Callable[[], bool] | None = None,
+ get_check_interval_seconds: Callable[[], int] | None = None,
+ ):
+ self.credential_store = credential_store
+ self.get_cookie = get_cookie
+ self.update_cookie = update_cookie
+ self.logger = logger
+ self.check_interval_seconds = max(3600, int(check_interval_seconds))
+ self.on_refreshed = on_refreshed
+ self.is_enabled = is_enabled or (lambda: True)
+ self.get_check_interval_seconds = get_check_interval_seconds
+ self._stop = False
+ self._wake = asyncio.Event()
+
+ def stop(self) -> None:
+ self._stop = True
+ self._wake.set()
+
+ def wake(self) -> None:
+ self._wake.set()
+
+ def _current_interval(self) -> int:
+ if not self.get_check_interval_seconds:
+ return self.check_interval_seconds
+ try:
+ return max(3600, int(self.get_check_interval_seconds()))
+ except (TypeError, ValueError):
+ return self.check_interval_seconds
+
+ def _check_and_refresh_sync(self) -> dict[str, Any]:
+ refresh_token = self.credential_store.load_refresh_token()
+ if not refresh_token:
+ return {"status": "disabled", "message": "未配置刷新令牌"}
+ current_cookie = self.get_cookie()
+ current_values = _parse_cookie(current_cookie)
+ csrf = current_values.get("bili_jct", "")
+ if not current_values.get("SESSDATA") or not csrf:
+ return {"status": "failed", "message": "当前 Cookie 缺少 SESSDATA 或 bili_jct"}
+
+ info, _ = _request_json(
+ "https://passport.bilibili.com/x/passport-login/web/cookie/info?" +
+ urllib.parse.urlencode({"csrf": csrf}),
+ cookie=current_cookie,
+ retries=2,
+ )
+ if info.get("code") != 0:
+ return {"status": "failed", "message": f"登录状态检查失败 code={info.get('code')}"}
+ if not bool((info.get("data") or {}).get("refresh")):
+ return {"status": "valid", "message": "Cookie 当前无需刷新"}
+
+ timestamp = str((info.get("data") or {}).get("timestamp") or "")
+ correspond_path = _rsa_oaep_sha256_encrypt(f"refresh_{timestamp}".encode("utf-8"))
+ request = urllib.request.Request(
+ f"https://www.bilibili.com/correspond/1/{correspond_path}",
+ headers={"User-Agent": _USER_AGENT, "Cookie": current_cookie},
+ )
+ html = urllib.request.urlopen(request, timeout=15).read().decode("utf-8", errors="replace")
+ match = __import__("re").search(r'
([^<]+)
', html)
+ if not match:
+ return {"status": "failed", "message": "未获取到 refresh_csrf"}
+ refresh_csrf = match.group(1).strip()
+
+ old_refresh_token = refresh_token
+ jar = http.cookiejar.CookieJar()
+ _seed_cookie_jar(jar, current_values)
+ opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
+ refreshed, _ = _request_json(
+ "https://passport.bilibili.com/x/passport-login/web/cookie/refresh",
+ data={
+ "csrf": csrf,
+ "refresh_csrf": refresh_csrf,
+ "source": "main_web",
+ "refresh_token": old_refresh_token,
+ },
+ opener=opener,
+ )
+ if refreshed.get("code") != 0:
+ return {"status": "failed", "message": f"Cookie 刷新失败 code={refreshed.get('code')}"}
+ new_refresh_token = str((refreshed.get("data") or {}).get("refresh_token") or "")
+ if not new_refresh_token:
+ return {"status": "failed", "message": "刷新响应缺少新 refresh_token"}
+ new_values = dict(current_values)
+ new_values.update(_jar_values(jar))
+ new_csrf = new_values.get("bili_jct", "")
+ new_cookie = _cookie_header(new_values)
+
+ confirmed, _ = _request_json(
+ "https://passport.bilibili.com/x/passport-login/web/confirm/refresh",
+ cookie=new_cookie,
+ data={"csrf": new_csrf, "refresh_token": old_refresh_token},
+ )
+ if confirmed.get("code") != 0:
+ return {"status": "failed", "message": f"刷新确认失败 code={confirmed.get('code')}"}
+
+ nav, _ = _request_json(
+ "https://api.bilibili.com/x/web-interface/nav",
+ cookie=new_cookie,
+ retries=2,
+ )
+ if nav.get("code") != 0 or not bool((nav.get("data") or {}).get("isLogin")):
+ return {"status": "failed", "message": "新 Cookie 登录验证失败"}
+ self.credential_store.save_refresh_token(new_refresh_token)
+ self.update_cookie(new_values)
+ return {"status": "refreshed", "message": "Cookie 已刷新并验证"}
+
+ async def check_once(self) -> dict[str, Any]:
+ try:
+ result = await asyncio.to_thread(self._check_and_refresh_sync)
+ except Exception as exc:
+ self.logger.warning("[B站凭据] 自动检查异常: %s", type(exc).__name__)
+ return {"status": "failed", "message": type(exc).__name__}
+ status = result.get("status")
+ if status == "refreshed":
+ self.logger.info("[B站凭据] Cookie 已自动续期并完成登录验证")
+ if self.on_refreshed:
+ callback_result = self.on_refreshed()
+ if asyncio.iscoroutine(callback_result):
+ await callback_result
+ elif status == "valid":
+ self.logger.info("[B站凭据] Cookie 有效,当前无需续期")
+ elif status == "disabled":
+ self.logger.warning("[B站凭据] 自动续期未启用:未配置刷新令牌")
+ else:
+ self.logger.warning("[B站凭据] 自动续期失败:%s", result.get("message", "未知错误"))
+ return result
+
+ async def run(self) -> None:
+ while not self._stop:
+ if self.is_enabled():
+ await self.check_once()
+ try:
+ await asyncio.wait_for(self._wake.wait(), timeout=self._current_interval())
+ self._wake.clear()
+ except asyncio.TimeoutError:
+ continue
diff --git a/app/core/__init__.py b/app/core/__init__.py
new file mode 100644
index 0000000..4f8f11a
--- /dev/null
+++ b/app/core/__init__.py
@@ -0,0 +1 @@
+"""Core helpers for the live streaming app."""
diff --git a/app/core/runtime_paths.py b/app/core/runtime_paths.py
new file mode 100644
index 0000000..ebfee30
--- /dev/null
+++ b/app/core/runtime_paths.py
@@ -0,0 +1,39 @@
+"""Runtime path helpers.
+
+All mutable runtime data is resolved from the executable directory when the
+program is frozen, and from the project root while running from source.
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+from pathlib import Path
+
+
+def app_root() -> Path:
+ if getattr(sys, "frozen", False):
+ return Path(sys.executable).resolve().parent
+ return Path(__file__).resolve().parents[2]
+
+
+APP_ROOT = app_root()
+CONFIG_DIR = APP_ROOT / "config"
+DATA_DIR = APP_ROOT / "data"
+WEB_DIR = APP_ROOT / "web"
+LOG_DIR = APP_ROOT / "logs"
+INTEGRATIONS_DIR = APP_ROOT / "integrations"
+VENDOR_DIR = APP_ROOT / "vendor"
+DOTS_TTS_SRC = VENDOR_DIR / "dots.tts-main" / "src"
+
+
+def project_path(value: str | os.PathLike, *, base: Path | None = None) -> Path:
+ path = Path(value)
+ if path.is_absolute():
+ return path
+ return (base or APP_ROOT) / path
+
+
+def ensure_runtime_dirs() -> None:
+ for path in (CONFIG_DIR, DATA_DIR, WEB_DIR, LOG_DIR):
+ path.mkdir(parents=True, exist_ok=True)
diff --git a/app/danmu_bettergi.py b/app/danmu_bettergi.py
new file mode 100644
index 0000000..86e99a6
--- /dev/null
+++ b/app/danmu_bettergi.py
@@ -0,0 +1,654 @@
+"""
+BetterGI 弹幕联动主程序
+========================
+监听 B 站直播间弹幕 -> 关键词匹配 -> 调用 BetterGI.exe --startGroups 执行配置组
+
+依赖: pip install websockets brotli
+运行: python danmu_bettergi.py
+"""
+
+import asyncio
+import json
+import logging
+import os
+import struct
+import subprocess
+import sys
+import time
+import urllib.request
+import zlib
+from pathlib import Path
+
+APP_DIR = Path(__file__).resolve().parent
+if str(APP_DIR) not in sys.path:
+ sys.path.insert(0, str(APP_DIR))
+
+from core.runtime_paths import APP_ROOT as PROJECT_ROOT, CONFIG_DIR, LOG_DIR, ensure_runtime_dirs, project_path
+
+try:
+ import websockets
+ import brotli
+except ImportError:
+ print("缺少依赖,请先运行: pip install websockets brotli")
+ sys.exit(1)
+
+# ============== B站直播弹幕协议常量 ==============
+HEADER_LEN = 16
+OP_HEARTBEAT = 2 # 心跳请求
+OP_HEARTBEAT_REPLY = 3 # 心跳响应(人气值)
+OP_MESSAGE = 5 # 业务消息
+OP_AUTH = 7 # 认证请求
+OP_AUTH_REPLY = 8 # 认证响应
+PROTO_JSON = 0 # 明文JSON
+PROTO_ZLIB = 2 # zlib压缩
+PROTO_BROTLI = 3 # brotli压缩
+
+
+# ============== 弹幕服务器发现 ==============
+def get_danmu_server(room_id: int) -> dict:
+ """通过B站API获取弹幕服务器地址和token。优先使用host_server_list中的新服务器。"""
+ url = f"https://api.live.bilibili.com/room/v1/Danmu/getConf?room_id={room_id}"
+ req = urllib.request.Request(url)
+ req.add_header("User-Agent", "Mozilla/5.0")
+ resp = urllib.request.urlopen(req, timeout=10)
+ data = json.loads(resp.read())["data"]
+ token = data["token"]
+ host_list = data.get("host_server_list", [])
+ if host_list:
+ entry = host_list[0]
+ host = entry["host"]
+ port = entry.get("wss_port", 443)
+ else:
+ host = data["host"]
+ port = data.get("wss_port", 443)
+ return {
+ "ws_url": f"wss://{host}:{port}/sub",
+ "token": token,
+ "host": host,
+ "port": port,
+ }
+
+
+def get_buvid3(sessdata: str = "") -> str:
+ """通过B站finger/spi接口获取buvid3 (2024+协议认证必需)。"""
+ url = "https://api.bilibili.com/x/frontend/finger/spi"
+ req = urllib.request.Request(url)
+ req.add_header("User-Agent", "Mozilla/5.0")
+ if sessdata:
+ req.add_header("Cookie", f"SESSDATA={sessdata}")
+ try:
+ resp = urllib.request.urlopen(req, timeout=10)
+ data = json.loads(resp.read())
+ if data.get("code") == 0:
+ return data["data"]["b_3"]
+ except Exception:
+ pass
+ return ""
+
+
+# ============== 配置管理 ==============
+class Config:
+ def __init__(self, path: str):
+ self.path = Path(path)
+ self.data = {}
+ self.reload()
+
+ def reload(self):
+ with open(self.path, "r", encoding="utf-8") as f:
+ self.data = json.load(f)
+
+ @property
+ def room_id(self) -> int:
+ return int(self.data["bilibili"]["room_id"])
+
+ @property
+ def sessdata(self) -> str:
+ return self.data["bilibili"].get("sessdata", "")
+
+ @property
+ def bettergi_exe(self) -> str:
+ return self.data["bettergi"]["exe_path"]
+
+ @property
+ def bettergi_work_dir(self) -> str:
+ wd = self.data["bettergi"].get("work_dir", "")
+ return wd if wd else str(Path(self.bettergi_exe).parent)
+
+ @property
+ def default_cooldown(self) -> int:
+ return int(self.data["global"].get("default_cooldown", 30))
+
+ @property
+ def admin_uids(self) -> set:
+ return set(int(x) for x in self.data["global"].get("admin_uids", []))
+
+ @property
+ def rules(self) -> list:
+ return self.data.get("rules", [])
+
+ @property
+ def restart_mode(self) -> str:
+ """重启模式: gentle(温和,跳过) / aggressive(激进,先杀再启)"""
+ return self.data["global"].get("restart_mode", "gentle")
+
+
+# ============== 冷却管理 ==============
+class CooldownManager:
+ """按规则记录最后触发时间,防止同一指令被弹幕刷屏重复触发。"""
+
+ def __init__(self):
+ self._last_fire: dict = {} # keyword -> timestamp
+
+ def can_fire(self, keyword: str, cooldown: int) -> bool:
+ now = time.time()
+ last = self._last_fire.get(keyword, 0)
+ return (now - last) >= cooldown
+
+ def mark_fired(self, keyword: str):
+ self._last_fire[keyword] = time.time()
+
+
+# ============== BetterGI 调用 ==============
+class BetterGIRunner:
+ """封装 BetterGI.exe --startGroups 调用。"""
+
+ def __init__(self, exe_path: str, work_dir: str, logger: logging.Logger,
+ restart_mode: str = "gentle"):
+ self.exe_path = exe_path
+ self.work_dir = work_dir
+ self.logger = logger
+ self.restart_mode = restart_mode # gentle / aggressive
+ self._busy = False # 标记是否正在执行任务
+ self._lock = asyncio.Lock()
+ # 缓存 cancelTaskHotkey (从 BetterGI Config.json 读)
+ self._cancel_hotkey = None
+ self._load_cancel_hotkey()
+
+ def _load_cancel_hotkey(self):
+ """从 BetterGI 的 Config.json 读取取消任务热键。"""
+ try:
+ import os
+ cfg_path = os.path.join(self.work_dir, "User", "Config.json")
+ if os.path.exists(cfg_path):
+ with open(cfg_path, "r", encoding="utf-8") as f:
+ bgi_cfg = json.load(f)
+ hk = bgi_cfg.get("hotKeyConfig", {}).get("cancelTaskHotkey", "")
+ if hk:
+ self._cancel_hotkey = hk
+ self.logger.info(f"已读取 BetterGI 取消任务热键: {hk}")
+ except Exception as e:
+ self.logger.debug(f"读取 cancelTaskHotkey 失败(不影响使用): {e}")
+
+ async def _stop_bgi_gracefully(self):
+ """优雅停止 BetterGI: 先按取消热键,再 taskkill 兜底。"""
+ # ① 模拟按取消热键 (让 BGI 内部收尾,保存进度)
+ if self._cancel_hotkey:
+ self.logger.info(f"[激进] 按取消热键: {self._cancel_hotkey}")
+ try:
+ await self._send_key(self._cancel_hotkey)
+ except Exception as e:
+ self.logger.warning(f"[激进] 按热键失败: {e}")
+ # 给 BetterGI 5 秒收尾时间
+ await asyncio.sleep(5)
+
+ # ② taskkill 强杀兜底
+ self.logger.info("[激进] taskkill /F /IM BetterGI.exe")
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ "taskkill", "/F", "/IM", "BetterGI.exe",
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ # CREATE_NO_WINDOW = 0x08000000,避免弹黑窗
+ creationflags=0x08000000,
+ )
+ await proc.communicate()
+ except Exception as e:
+ self.logger.debug(f"[激进] taskkill 执行: {e}")
+ # 再等 1 秒让进程完全退出
+ await asyncio.sleep(1)
+
+ async def _send_key(self, key: str):
+ """模拟按键。优先用 pywin32,其次 keyboard 库,都没有则跳过。"""
+ # 简单映射: BetterGI 的热键名通常是 "F9" "Ctrl+P" 这种
+ # 优先尝试 pywin32 (最稳定,不弹窗)
+ try:
+ import win32api
+ import win32con
+ # 简单支持单键 (F1-F24, 字母, 数字)
+ vk_map = {
+ "F1": win32con.VK_F1, "F2": win32con.VK_F2, "F3": win32con.VK_F3,
+ "F4": win32con.VK_F4, "F5": win32con.VK_F5, "F6": win32con.VK_F6,
+ "F7": win32con.VK_F7, "F8": win32con.VK_F8, "F9": win32con.VK_F9,
+ "F10": win32con.VK_F10, "F11": win32con.VK_F11, "F12": win32con.VK_F12,
+ "ESC": win32con.VK_ESCAPE, "ESCAPE": win32con.VK_ESCAPE,
+ }
+ # 处理 Ctrl+X Shift+X 这种组合键
+ parts = key.replace("+", " ").split()
+ main_key = parts[-1].upper()
+ ctrl = "CTRL" in [p.upper() for p in parts[:-1]]
+ shift = "SHIFT" in [p.upper() for p in parts[:-1]]
+ alt = "ALT" in [p.upper() for p in parts[:-1]]
+
+ vk = vk_map.get(main_key)
+ if vk is None and len(main_key) == 1:
+ vk = ord(main_key.upper()) # 字母键
+
+ if vk is None:
+ self.logger.warning(f"[激进] 不支持的热键: {key}, 跳过热键直接 taskkill")
+ return
+
+ if ctrl:
+ win32api.keybd_event(win32con.VK_CONTROL, 0, 0, 0)
+ if shift:
+ win32api.keybd_event(win32con.VK_SHIFT, 0, 0, 0)
+ if alt:
+ win32api.keybd_event(win32con.VK_MENU, 0, 0, 0)
+
+ win32api.keybd_event(vk, 0, 0, 0) # 按下
+ win32api.keybd_event(vk, 0, win32con.KEYEVENTF_KEYUP, 0) # 抬起
+
+ if ctrl:
+ win32api.keybd_event(win32con.VK_CONTROL, 0, win32con.KEYEVENTF_KEYUP, 0)
+ if shift:
+ win32api.keybd_event(win32con.VK_SHIFT, 0, win32con.KEYEVENTF_KEYUP, 0)
+ if alt:
+ win32api.keybd_event(win32con.VK_MENU, 0, win32con.KEYEVENTF_KEYUP, 0)
+
+ self.logger.info(f"[激进] 已模拟按键: {key}")
+ except ImportError:
+ self.logger.warning(
+ "[激进] 未安装 pywin32, 无法模拟热键。"
+ "可运行: pip install pywin32 (可选,不装则直接 taskkill)"
+ )
+
+ async def run_groups(self, groups: list) -> bool:
+ """异步启动配置组。"""
+ async with self._lock:
+ if self._busy:
+ if self.restart_mode == "aggressive":
+ self.logger.warning(
+ f"[激进模式] BetterGI 正在执行任务,先杀再启: {groups}"
+ )
+ # 释放锁,执行停止 (停止可能耗时,不持有锁)
+ self._busy = False
+ else:
+ self.logger.warning(
+ f"[温和模式] BetterGI 正在执行任务,跳过本次触发: {groups}"
+ )
+ return False
+ else:
+ self._busy = True
+
+ # 激进模式: 先停止旧任务
+ if self.restart_mode == "aggressive":
+ await self._stop_bgi_gracefully()
+ async with self._lock:
+ self._busy = True
+
+ try:
+ cmd = [self.exe_path, "--startGroups"] + groups
+ self.logger.info(f"调用 BetterGI: {' '.join(cmd)}")
+ proc = await asyncio.create_subprocess_exec(
+ *cmd,
+ cwd=self.work_dir,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ stdout, stderr = await proc.communicate()
+ if proc.returncode in (0, 553):
+ self.logger.info(f"BetterGI 启动成功: {groups} (rc={proc.returncode})")
+ return True
+ else:
+ self.logger.error(
+ f"BetterGI 启动失败 rc={proc.returncode}: "
+ f"{stderr.decode('gbk', errors='replace')}"
+ )
+ return False
+ except Exception as e:
+ self.logger.exception(f"调用 BetterGI 异常: {e}")
+ return False
+ finally:
+ self._busy = False
+
+
+# ============== 弹幕匹配引擎 ==============
+class DanmuMatcher:
+ """把弹幕文本匹配到对应规则。"""
+
+ def __init__(self, config: Config, cooldown: CooldownManager,
+ runner: BetterGIRunner, logger: logging.Logger):
+ self.config = config
+ self.cooldown = cooldown
+ self.runner = runner
+ self.logger = logger
+
+ async def handle(self, text: str, uid: int, uname: str):
+ text = (text or "").strip()
+ if not text:
+ return
+ admins = self.config.admin_uids
+ is_admin = uid in admins
+
+ for rule in self.config.rules:
+ if not self._match(text, rule):
+ continue
+
+ keyword = rule["keyword"]
+ groups = rule["groups"]
+ cooldown_sec = rule.get("cooldown", self.config.default_cooldown)
+ admin_only = rule.get("admin_only", False)
+ reply = rule.get("reply", "")
+
+ # 权限检查
+ if admin_only and not is_admin:
+ self.logger.info(
+ f"[权限不足] {uname}({uid}) 弹幕'{text}' 命中'{keyword}' 但需管理员"
+ )
+ return # 注意: 这里return了,继续检查下一条规则(如果有)
+
+ # 冷却检查
+ if not self.cooldown.can_fire(keyword, cooldown_sec):
+ remain = int(cooldown_sec - (time.time() - self.cooldown._last_fire.get(keyword, 0)))
+ self.logger.info(
+ f"[冷却中] '{keyword}' 还需 {remain}s, 来自 {uname}({uid})"
+ )
+ return # 冷却不触发,但日志已打印
+
+ # 触发
+ self.cooldown.mark_fired(keyword)
+ self.logger.info(
+ f"[触发] {uname}({uid}) 弹幕'{text}' -> 配置组 {groups}"
+ )
+ if reply:
+ self.logger.info(f"[回复提示] {reply}")
+ await self.runner.run_groups(groups)
+ return # 一条弹幕只触发第一个命中的规则
+
+ @staticmethod
+ def _match(text: str, rule: dict) -> bool:
+ keyword = rule["keyword"]
+ mtype = rule.get("match_type", "contains")
+ if mtype == "exact":
+ return text == keyword
+ elif mtype == "contains":
+ return keyword in text
+ elif mtype == "startswith":
+ return text.startswith(keyword)
+ elif mtype == "regex":
+ import re
+ return re.search(keyword, text) is not None
+ return False
+
+
+# ============== B站弹幕协议 ==============
+def make_packet(op: int, body: bytes = b"") -> bytes:
+ """组装协议包。header 16字节 + body。"""
+ if isinstance(body, str):
+ body = body.encode("utf-8")
+ total = HEADER_LEN + len(body)
+ header = struct.pack(">IHHII", total, HEADER_LEN, 1, op, 1)
+ return header + body
+
+
+def parse_packets(data: bytes):
+ """一个 WebSocket 帧可能含多个协议包,循环切分。"""
+ offset = 0
+ packets = []
+ while offset < len(data):
+ if offset + HEADER_LEN > len(data):
+ break
+ total, header_len, proto_ver, op, seq = struct.unpack(
+ ">IHHII", data[offset:offset + HEADER_LEN]
+ )
+ body = data[offset + header_len:offset + total]
+ packets.append((proto_ver, op, body))
+ offset += total
+ return packets
+
+
+def decode_body(proto_ver: int, body: bytes) -> bytes:
+ """按协议版本解压 body。"""
+ if proto_ver == PROTO_JSON:
+ return body
+ if proto_ver == PROTO_ZLIB:
+ return zlib.decompress(body)
+ if proto_ver == PROTO_BROTLI:
+ return brotli.decompress(body)
+ return body
+
+
+def _clean_uid(value) -> int:
+ """只接受纯十进制正整数 UID,匿名或含星号的值返回 0。"""
+ if isinstance(value, bool):
+ return 0
+ if isinstance(value, int):
+ return value if value > 0 else 0
+ if isinstance(value, str):
+ value = value.strip()
+ return int(value) if value.isdecimal() and int(value) > 0 else 0
+ return 0
+
+
+def extract_danmu(body: bytes):
+ """从消息体中提取弹幕(DANMU_MSG)。返回 [(text, uid, uname), ...]。"""
+ results = []
+ try:
+ decoded = decode_body(0, body) # body 已是解压后的,proto_ver 此处忽略
+ except Exception:
+ decoded = body
+ try:
+ msg = json.loads(decoded.decode("utf-8", errors="replace"))
+ except Exception:
+ return results
+ cmd = msg.get("cmd", "")
+ if cmd.startswith("DANMU_MSG"):
+ info = msg.get("info", [])
+ if not isinstance(info, list) or len(info) <= 2:
+ return results
+ text = str(info[1] if len(info) > 1 else "")
+ member = info[2]
+ if isinstance(member, (list, tuple)):
+ uid = _clean_uid(member[0] if len(member) > 0 else 0)
+ uname = str(member[1] if len(member) > 1 and member[1] is not None else "")
+ elif isinstance(member, dict):
+ uid = _clean_uid(member.get("uid_str") or member.get("uid") or member.get("mid"))
+ uname = str(member.get("uname") or member.get("name") or "")
+ else:
+ uid, uname = 0, ""
+ results.append((text, uid, uname))
+ return results
+
+
+# ============== 弹幕客户端 ==============
+class BliveClient:
+ """B站直播弹幕 WebSocket 客户端,带自动重连。"""
+
+ def __init__(self, room_id: int, sessdata: str,
+ matcher: DanmuMatcher, logger: logging.Logger):
+ self.room_id = room_id
+ self.sessdata = sessdata
+ self.matcher = matcher
+ self.logger = logger
+ self._stop = False
+ self._buvid3 = ""
+
+ async def run(self):
+ """主循环:断线自动重连,间隔递增。"""
+ # 启动时获取buvid3 (2024+协议认证必需)
+ self._buvid3 = get_buvid3(self.sessdata)
+ if self._buvid3:
+ self.logger.info(f"获取buvid3成功: {self._buvid3[:20]}...")
+ else:
+ self.logger.warning("获取buvid3失败,弹幕可能收不到! 将尝试匿名连接")
+ retry = 0
+ while not self._stop:
+ try:
+ # 每次连接前重新获取服务器地址(避免IP变化)
+ server_info = get_danmu_server(self.room_id)
+ ws_url = server_info["ws_url"]
+ token = server_info["token"]
+ self.logger.info(f"弹幕服务器: {server_info['host']}:{server_info['port']}")
+ await self._connect_once(ws_url, token)
+ retry = 0 # 连上后重置
+ except asyncio.CancelledError:
+ break
+ except Exception as e:
+ self.logger.warning(f"连接断开: {e}")
+ if self._stop:
+ break
+ retry += 1
+ wait = min(2 ** retry, 60) # 指数退避,最多60秒
+ self.logger.info(f"{wait}秒后重连(第{retry}次)...")
+ await asyncio.sleep(wait)
+
+ def stop(self):
+ self._stop = True
+
+ async def _connect_once(self, ws_url: str, token: str):
+ self.logger.info(f"连接直播间 room_id={self.room_id} ...")
+ # 禁用 websockets 自带 ping/pong (B站有自己的心跳协议)
+ async with websockets.connect(
+ ws_url,
+ max_size=None,
+ ping_interval=None,
+ ping_timeout=None,
+ close_timeout=5,
+ ) as ws:
+ # 1. 发送认证包(使用API返回的token + buvid3)
+ auth = {
+ "uid": 0,
+ "roomid": self.room_id,
+ "protover": PROTO_BROTLI,
+ "platform": "web",
+ "type": 2,
+ "key": token or (self.sessdata or ""),
+ }
+ if self._buvid3:
+ auth["buvid"] = self._buvid3
+ await ws.send(make_packet(OP_AUTH, json.dumps(auth)))
+ self.logger.info("已发送认证包,等待响应...")
+ # 立即发送一个心跳,激活弹幕推送
+ await ws.send(make_packet(OP_HEARTBEAT))
+
+ # 2. 心跳任务
+ async def heartbeat():
+ while True:
+ await asyncio.sleep(15)
+ try:
+ await ws.send(make_packet(OP_HEARTBEAT))
+ except Exception:
+ break
+
+ hb_task = asyncio.create_task(heartbeat())
+
+ # 3. 接收循环
+ msg_counter = 0
+ async for raw in ws:
+ if isinstance(raw, str):
+ continue
+ for proto_ver, op, body in parse_packets(raw):
+ if op == OP_AUTH_REPLY:
+ # 检查认证是否真的成功
+ try:
+ auth_resp = json.loads(body.decode("utf-8", errors="replace"))
+ code = auth_resp.get("code", -1)
+ if code == 0:
+ self.logger.info(f"认证成功,开始监听弹幕 (响应: {auth_resp})")
+ else:
+ self.logger.error(f"认证失败! 响应: {auth_resp}")
+ except Exception:
+ self.logger.info(f"认证响应(原始): {body}")
+ elif op == OP_HEARTBEAT_REPLY:
+ # body 是 4 字节人气值(大端序int32)
+ if len(body) >= 4:
+ popularity = struct.unpack(">I", body[:4])[0]
+ self.logger.debug(f"人气值: {popularity}")
+ elif op == OP_MESSAGE:
+ # body 可能被压缩,按 proto_ver 解压后再切包
+ try:
+ decoded = decode_body(proto_ver, body)
+ except Exception as e:
+ self.logger.error(f"解压失败 proto={proto_ver} len={len(body)}: {e}")
+ continue
+ # 解压后可能内含多个子包
+ for sub_proto, sub_op, sub_body in parse_packets(decoded):
+ if sub_op == OP_MESSAGE:
+ msg_counter += 1
+ # 调试:打印每条消息的cmd类型
+ try:
+ msg_json = json.loads(sub_body.decode("utf-8", errors="replace"))
+ cmd = msg_json.get("cmd", "?")
+ self.logger.debug(f"#{msg_counter} cmd={cmd}")
+ except Exception:
+ self.logger.debug(f"#{msg_counter} 解析JSON失败")
+ continue
+ for text, uid, uname in extract_danmu(sub_body):
+ self.logger.info(f"[弹幕] {uname}({uid}): {text}")
+ await self.matcher.handle(text, uid, uname)
+
+ hb_task.cancel()
+
+
+# ============== 日志 ==============
+def setup_logger(config: Config) -> logging.Logger:
+ logger = logging.getLogger("danmu_bettergi")
+ logger.setLevel(getattr(logging, config.data["global"].get("log_level", "INFO")))
+ fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s",
+ datefmt="%H:%M:%S")
+ sh = logging.StreamHandler(sys.stdout)
+ sh.setFormatter(fmt)
+ logger.addHandler(sh)
+ log_file = config.data["global"].get("log_file")
+ if log_file:
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
+ fh = logging.FileHandler(project_path(log_file, base=LOG_DIR), encoding="utf-8")
+ fh.setFormatter(fmt)
+ logger.addHandler(fh)
+ return logger
+
+
+# ============== 入口 ==============
+async def main():
+ ensure_runtime_dirs()
+ config_path = CONFIG_DIR / "config.json"
+ if not config_path.exists():
+ legacy_config_path = PROJECT_ROOT / "config.json"
+ if legacy_config_path.exists():
+ config_path = legacy_config_path
+ if not config_path.exists():
+ print("找不到 config.json,请先配置!")
+ sys.exit(1)
+
+ config = Config(str(config_path))
+ logger = setup_logger(config)
+
+ logger.info("=" * 50)
+ logger.info("BetterGI 弹幕联动启动")
+ logger.info(f"直播间: {config.room_id}")
+ logger.info(f"BetterGI: {config.bettergi_exe}")
+ logger.info(f"规则数: {len(config.rules)}")
+ logger.info(f"管理员UID: {config.admin_uids or '无'}")
+ logger.info(f"默认冷却: {config.default_cooldown}s")
+ logger.info(f"重启模式: {config.restart_mode}")
+ logger.info("=" * 50)
+
+ cooldown = CooldownManager()
+ runner = BetterGIRunner(
+ config.bettergi_exe, config.bettergi_work_dir, logger,
+ restart_mode=config.restart_mode,
+ )
+ matcher = DanmuMatcher(config, cooldown, runner, logger)
+ client = BliveClient(config.room_id, config.sessdata, matcher, logger)
+
+ try:
+ await client.run()
+ except KeyboardInterrupt:
+ logger.info("收到退出信号,正在停止...")
+ client.stop()
+
+
+if __name__ == "__main__":
+ try:
+ asyncio.run(main())
+ except KeyboardInterrupt:
+ pass
diff --git a/app/danmu_queue.py b/app/danmu_queue.py
new file mode 100644
index 0000000..8c68e30
--- /dev/null
+++ b/app/danmu_queue.py
@@ -0,0 +1,10566 @@
+"""
+BetterGI 弹幕排队系统
+====================
+直播排队玩法:
+- 观众发弹幕自动创建账号(5积分),每日签到随机+5~10(上限30,凌晨4点重置)
+- 发"排队"加入队列,队首可发"上号"触发扫码上号配置组
+- 扫码状态变为已登录并确认账号后,队首可发"执行 组名"触发BGI
+- 队首确认账号后每分钟扣1积分,积分可扣到负分,不因耗尽中断BGI
+- 配置组跑完后按规则保留或出队 → 下一用户90秒上号窗口
+- 队列空时跑默认"薄荷"配置组(不扣积分)
+
+指令列表:
+ 排队 - 加入排队队列
+ 签到 - 每日签到(随机+5~10积分,凌晨4点重置)
+ 上号 - 队首触发扫码上号配置组
+ 执行 <组名> - 已确认账号的队首触发配置组(如: 执行 泡泡桔)
+ 跑 <组名> - 兼容旧指令,等同于执行
+ 自动每日 [模式] - 启动托管的一条龙每日任务
+ 切换队伍 <名称> - 执行“切换队伍”配置组
+ 修改队员 <四人> - 执行“修改队员”配置组
+ 退出 - 退出排队队列(三级队首不可用)
+ 重置 - 一级用户重启原神和BetterGI
+ 积分 - 查询自己的积分
+ 队列 - 查看当前排队情况
+ 帮助 - 显示帮助
+"""
+
+from __future__ import annotations
+
+import asyncio
+import argparse
+import base64
+import json
+import logging
+import os
+import struct
+import subprocess
+import sys
+import time
+import ctypes
+import ctypes.wintypes
+import contextvars
+import threading
+import shutil
+import urllib.request
+import urllib.parse
+import http.cookies
+import hashlib
+import heapq
+import queue as thread_queue
+import re
+import difflib
+import random
+import zlib
+import socket
+import tempfile
+import traceback
+import uuid
+from datetime import date, datetime, timedelta, timezone
+from pathlib import Path
+from typing import Any
+
+MIHOYO_SDK_REGISTRY_SUBKEY = r"Software\miHoYoSDK"
+
+
+def delete_mihoyo_sdk_registry(logger: logging.Logger, subkey: str = MIHOYO_SDK_REGISTRY_SUBKEY) -> dict[str, Any]:
+ """递归删除当前 Windows 用户的 miHoYoSDK 注册表键;键不存在时视为成功。"""
+ if os.name != "nt":
+ return {"success": False, "deleted": False, "error": "仅支持 Windows"}
+
+ try:
+ import winreg
+
+ def delete_tree(parent, key_path: str) -> None:
+ try:
+ with winreg.OpenKey(parent, key_path, 0, winreg.KEY_READ | winreg.KEY_WRITE) as key:
+ children = []
+ index = 0
+ while True:
+ try:
+ children.append(winreg.EnumKey(key, index))
+ index += 1
+ except OSError:
+ break
+ for child in children:
+ delete_tree(parent, f"{key_path}\\{child}")
+ winreg.DeleteKey(parent, key_path)
+ except FileNotFoundError:
+ return
+
+ try:
+ winreg.OpenKey(winreg.HKEY_CURRENT_USER, subkey, 0, winreg.KEY_READ).Close()
+ except FileNotFoundError:
+ logger.info(r"[登录] 注册表 HKCU\Software\miHoYoSDK 不存在,无需清理")
+ return {"success": True, "deleted": False, "message": "注册表键不存在,无需清理"}
+
+ delete_tree(winreg.HKEY_CURRENT_USER, subkey)
+ logger.info(r"[登录] 已删除注册表 HKCU\Software\miHoYoSDK")
+ return {"success": True, "deleted": True, "message": "miHoYoSDK 注册表已删除"}
+ except Exception as exc:
+ logger.exception(r"[登录] 删除注册表 HKCU\Software\miHoYoSDK 失败")
+ return {"success": False, "deleted": False, "error": str(exc)}
+
+
+APP_DIR = Path(__file__).resolve().parent
+if str(APP_DIR) not in sys.path:
+ sys.path.insert(0, str(APP_DIR))
+
+from admin_auth import AdminAuthManager, SESSION_COOKIE_NAME
+from admin_events import AdminEventBus
+if __package__:
+ from .bettergi_current_party import (
+ CURRENT_PARTY_SCRIPT_NAME,
+ CurrentPartyReadError,
+ PreparedCurrentPartyRead,
+ clear_current_party_status,
+ prepare_current_party_read,
+ read_current_party_status,
+ )
+ from .bettergi_daily import (
+ DailyAutomationError,
+ JsonUpdate,
+ apply_json_updates,
+ prepare_commission_current_party_update,
+ prepare_daily_run,
+ prepare_edit_party_update,
+ prepare_switch_party_update,
+ )
+else:
+ from bettergi_current_party import (
+ CURRENT_PARTY_SCRIPT_NAME,
+ CurrentPartyReadError,
+ PreparedCurrentPartyRead,
+ clear_current_party_status,
+ prepare_current_party_read,
+ read_current_party_status,
+ )
+ from bettergi_daily import (
+ DailyAutomationError,
+ JsonUpdate,
+ apply_json_updates,
+ prepare_commission_current_party_update,
+ prepare_daily_run,
+ prepare_edit_party_update,
+ prepare_switch_party_update,
+ )
+from bilibili_cookie_refresh import BilibiliCookieRefresher, BilibiliCredentialStore, BilibiliQrLogin
+from faster_qwen_worker import FasterQwenWorkerClient
+from mpv_player import MpvPlayer
+from netease_qr_login import NeteaseQrLogin
+from netease_resolver import NeteaseResolver
+from redemption_codes import RedemptionCodeStore
+from stats_store import StatsStore, business_date as statistics_business_date
+from core.runtime_paths import (
+ APP_ROOT as PROJECT_ROOT,
+ CONFIG_DIR,
+ DATA_DIR,
+ DOTS_TTS_SRC,
+ INTEGRATIONS_DIR,
+ LOG_DIR,
+ WEB_DIR,
+ ensure_runtime_dirs,
+ project_path,
+)
+
+if DOTS_TTS_SRC.exists() and str(DOTS_TTS_SRC) not in sys.path:
+ sys.path.insert(0, str(DOTS_TTS_SRC))
+
+
+def _add_ipv4(addresses: list[str], ip: str | None) -> None:
+ if not ip or ip in addresses:
+ return
+ if ip.startswith(("127.", "169.254.")) or ip == "0.0.0.0":
+ return
+ parts = ip.split(".")
+ if len(parts) != 4 or not all(part.isdigit() and 0 <= int(part) <= 255 for part in parts):
+ return
+ first, second = int(parts[0]), int(parts[1])
+ is_lan = first == 10 or (first == 172 and 16 <= second <= 31) or (first == 192 and second == 168)
+ if is_lan:
+ addresses.append(ip)
+
+
+def _ipv4_priority(ip: str) -> tuple[int, str]:
+ if ip.startswith("192.168."):
+ return (0, ip)
+ if ip.startswith("10."):
+ return (1, ip)
+ return (2, ip)
+
+
+def discover_lan_ipv4_addresses() -> list[str]:
+ addresses: list[str] = []
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
+ sock.connect(("8.8.8.8", 80))
+ _add_ipv4(addresses, sock.getsockname()[0])
+ except OSError:
+ pass
+ try:
+ for item in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
+ _add_ipv4(addresses, item[4][0])
+ except OSError:
+ pass
+ return sorted(addresses, key=_ipv4_priority)
+
+
+def web_access_urls(host: str, port: int) -> dict:
+ host = host or "0.0.0.0"
+ local_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host
+ local = {
+ "frontend": f"http://{local_host}:{port}/",
+ "admin": f"http://{local_host}:{port}/admin",
+ }
+ lan_ips = [] if host in {"127.0.0.1", "localhost"} else discover_lan_ipv4_addresses()
+ return {
+ "bind": f"{host}:{port}",
+ "local": local,
+ "lan_frontend": [f"http://{ip}:{port}/" for ip in lan_ips],
+ "lan_admin": [f"http://{ip}:{port}/admin" for ip in lan_ips],
+ }
+
+
+ROLE_SUPER_ADMIN = "super_admin"
+ROLE_ACTIVE_OPERATOR = "active_operator"
+ROLE_PENDING_OPERATOR = "pending_operator"
+ROLE_VIEWER = "viewer"
+ALL_DANMU_ROLES = [
+ ROLE_SUPER_ADMIN,
+ ROLE_ACTIVE_OPERATOR,
+ ROLE_PENDING_OPERATOR,
+ ROLE_VIEWER,
+]
+COMMAND_ALLOWED_ROLE_DEFAULTS = {
+ "queue": ALL_DANMU_ROLES,
+ "signin": ALL_DANMU_ROLES,
+ "login": [ROLE_SUPER_ADMIN, ROLE_PENDING_OPERATOR],
+ "confirm_yes": [ROLE_SUPER_ADMIN, ROLE_PENDING_OPERATOR],
+ "confirm_no": [ROLE_SUPER_ADMIN, ROLE_PENDING_OPERATOR],
+ "run": [ROLE_SUPER_ADMIN, ROLE_ACTIVE_OPERATOR],
+ "daily": [ROLE_SUPER_ADMIN, ROLE_ACTIVE_OPERATOR],
+ "switch_party": [ROLE_SUPER_ADMIN, ROLE_ACTIVE_OPERATOR],
+ "edit_party": [ROLE_SUPER_ADMIN, ROLE_ACTIVE_OPERATOR],
+ "leave": [ROLE_SUPER_ADMIN, ROLE_ACTIVE_OPERATOR, ROLE_VIEWER],
+ "reset": [ROLE_SUPER_ADMIN],
+ "points": ALL_DANMU_ROLES,
+ "queue_list": ALL_DANMU_ROLES,
+ "help": ALL_DANMU_ROLES,
+}
+
+
+def normalize_allowed_roles(value: Any, default: list[str]) -> list[str]:
+ if not isinstance(value, list):
+ return list(default)
+ roles = [str(item).strip() for item in value if str(item).strip() in ALL_DANMU_ROLES]
+ return roles or list(default)
+
+
+def default_rule_allowed_roles(rule: dict[str, Any]) -> list[str]:
+ if isinstance(rule.get("allowed_roles"), list):
+ return normalize_allowed_roles(rule.get("allowed_roles"), ALL_DANMU_ROLES)
+ if rule.get("admin_only"):
+ return [ROLE_SUPER_ADMIN]
+ return list(ALL_DANMU_ROLES)
+
+try:
+ import websockets
+ import brotli
+ import aiohttp
+ from aiohttp import web
+except ImportError:
+ print("缺少依赖,请运行: pip install websockets brotli aiohttp")
+ sys.exit(1)
+
+# ============== 常量 ==============
+HEADER_LEN = 16
+OP_HEARTBEAT = 2
+OP_HEARTBEAT_REPLY = 3
+OP_MESSAGE = 5
+OP_AUTH = 7
+OP_AUTH_REPLY = 8
+PROTO_JSON = 0
+PROTO_ZLIB = 2
+PROTO_BROTLI = 3
+
+# 积分相关
+INITIAL_POINTS = 10 # 新用户初始积分
+SIGNIN_POINTS_MIN = 5 # 每日签到随机积分下限
+SIGNIN_POINTS_MAX = 10 # 每日签到随机积分上限
+SIGNIN_RESET_HOUR = 4 # 北京时间凌晨4点切换签到业务日
+MAX_POINTS = 30 # 积分上限
+POINTS_PER_MINUTE = 1 # 每分钟扣除积分
+ADMIN_WINDOW_SECONDS = 90 # 90秒上号窗口
+LOGIN_TIMEOUT_SECONDS = 240 # 发送“上号”后240秒(4分钟)仍未完成登录则过号
+CONFIRM_TIMEOUT_SECONDS = 60 # 扫码成功后1分钟未确认账号则过号
+BILIBILI_GOLD_COIN_PER_CNY = 1000.0
+
+
+BEIJING_TZ = timezone(timedelta(hours=8))
+
+
+def bilibili_gift_cny_values(coin_type: str, total_coin: Any, quantity: Any) -> tuple[float, float]:
+ """将 B 站 SEND_GIFT 的金瓜子金额换算为人民币单价和总价。"""
+ if str(coin_type or "").strip().casefold() != "gold":
+ return 0.0, 0.0
+ try:
+ raw_total_coin = max(0.0, float(total_coin or 0))
+ except (TypeError, ValueError):
+ raw_total_coin = 0.0
+ try:
+ count = max(1, int(quantity or 1))
+ except (TypeError, ValueError):
+ count = 1
+ total_value = raw_total_coin / BILIBILI_GOLD_COIN_PER_CNY
+ return total_value / count, total_value
+
+
+def _parse_hhmm_minutes(value: str) -> int | None:
+ text = str(value or "").strip()
+ if not re.fullmatch(r"\d{2}:\d{2}", text):
+ return None
+ try:
+ parsed = datetime.strptime(text, "%H:%M")
+ except ValueError:
+ return None
+ return parsed.hour * 60 + parsed.minute
+
+
+def is_within_live_time(system_cfg: dict, now: datetime | None = None) -> bool:
+ """判断当前是否位于直播时段,支持例如20:00到次日02:00的跨日区间。"""
+ start = _parse_hhmm_minutes(system_cfg.get("live_start_time", ""))
+ end = _parse_hhmm_minutes(system_cfg.get("live_end_time", ""))
+ if start is None or end is None or start == end:
+ return False
+ current = now or datetime.now()
+ minute = current.hour * 60 + current.minute
+ if start < end:
+ return start <= minute < end
+ return minute >= start or minute < end
+
+
+def get_signin_business_date(
+ now: datetime | None = None,
+ reset_hour: int | None = None,
+) -> str:
+ """返回签到业务日期;北京时间凌晨 reset_hour 点才进入新的一天。"""
+ current = now or datetime.now(BEIJING_TZ)
+ if current.tzinfo is None:
+ current = current.replace(tzinfo=BEIJING_TZ)
+ current = current.astimezone(BEIJING_TZ)
+ hour = SIGNIN_RESET_HOUR if reset_hour is None else max(0, min(23, int(reset_hour)))
+ return (current - timedelta(hours=hour)).date().isoformat()
+
+
+# ============== 配置管理 ==============
+class Config:
+ def __init__(self, path: str):
+ self.path = Path(path)
+ self.data = {}
+ self.revision = 0
+ self.last_error = ""
+ self.loaded_at = 0.0
+ self._file_signature: tuple[int, int] = (0, 0)
+ self.reload()
+
+ def _read_file_signature(self) -> tuple[int, int]:
+ try:
+ stat = self.path.stat()
+ except OSError:
+ return (0, 0)
+ return (int(getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000))), int(stat.st_size))
+
+ def reload(self):
+ with open(self.path, "r", encoding="utf-8") as f:
+ loaded = json.load(f)
+ if not isinstance(loaded, dict):
+ raise ValueError("配置文件根节点必须是对象")
+ self.data = loaded
+ self._apply_defaults()
+ self.revision += 1
+ self.last_error = ""
+ self.loaded_at = time.time()
+ self._file_signature = self._read_file_signature()
+
+ def reload_if_changed(self) -> bool:
+ signature = self._read_file_signature()
+ if signature == (0, 0):
+ return False
+ if signature == self._file_signature:
+ return False
+ self.reload()
+ return True
+
+ def save(self):
+ self._apply_defaults()
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = self.path.with_suffix(self.path.suffix + ".tmp")
+ with open(tmp, "w", encoding="utf-8", newline="\n") as f:
+ json.dump(self.data, f, ensure_ascii=False, indent=2)
+ f.write("\n")
+ tmp.replace(self.path)
+ self.revision += 1
+ self.last_error = ""
+ self.loaded_at = time.time()
+ self._file_signature = self._read_file_signature()
+
+ def update_bilibili_cookies(self, values: dict[str, str]):
+ """原子更新 B 站登录 Cookie;不在配置或日志中保存 refresh_token。"""
+ bili = self.data.setdefault("bilibili", {})
+ current = http.cookies.SimpleCookie()
+ try:
+ current.load(str(bili.get("cookie") or "").replace("; ", ";"))
+ except Exception:
+ current = http.cookies.SimpleCookie()
+ merged = {name: morsel.value for name, morsel in current.items()}
+ for name, value in values.items():
+ if value:
+ merged[str(name)] = str(value)
+ bili["sessdata"] = merged.get("SESSDATA", str(bili.get("sessdata") or ""))
+ bili["bili_jct"] = merged.get("bili_jct", str(bili.get("bili_jct") or ""))
+ bili["buvid3"] = merged.get("buvid3", str(bili.get("buvid3") or ""))
+ bili["cookie"] = "; ".join(f"{name}={value}" for name, value in merged.items() if value)
+ self.save()
+
+ def apply_runtime_settings(self):
+ global INITIAL_POINTS, SIGNIN_POINTS_MIN, SIGNIN_POINTS_MAX, SIGNIN_RESET_HOUR
+ global MAX_POINTS, POINTS_PER_MINUTE, ADMIN_WINDOW_SECONDS
+ q_cfg = self.data.get("queue", {})
+ INITIAL_POINTS = int(q_cfg.get("initial_points", INITIAL_POINTS))
+ legacy_signin_points = int(q_cfg.get("signin_points", SIGNIN_POINTS_MAX))
+ SIGNIN_POINTS_MIN = int(q_cfg.get("signin_points_min", min(5, legacy_signin_points)))
+ SIGNIN_POINTS_MAX = int(q_cfg.get("signin_points_max", legacy_signin_points))
+ if SIGNIN_POINTS_MIN > SIGNIN_POINTS_MAX:
+ SIGNIN_POINTS_MIN, SIGNIN_POINTS_MAX = SIGNIN_POINTS_MAX, SIGNIN_POINTS_MIN
+ SIGNIN_RESET_HOUR = max(0, min(23, int(q_cfg.get("signin_reset_hour", SIGNIN_RESET_HOUR))))
+ MAX_POINTS = int(q_cfg.get("max_points", MAX_POINTS))
+ POINTS_PER_MINUTE = int(q_cfg.get("points_per_minute", POINTS_PER_MINUTE))
+ ADMIN_WINDOW_SECONDS = int(q_cfg.get("admin_window_seconds", ADMIN_WINDOW_SECONDS))
+
+ def _apply_defaults(self):
+ """补齐旧配置或被部分保存覆盖后的缺省节点,避免启动 KeyError。"""
+ legacy_frontend = {
+ key: self.data.pop(key)
+ for key in ["background_image", "background_opacity", "background_blur", "background_fit"]
+ if key in self.data
+ }
+ self.data.setdefault("bilibili", {})
+ self.data["bilibili"].setdefault("cookie_auto_refresh_enabled", True)
+ self.data["bilibili"].setdefault("cookie_check_interval_hours", 6)
+ self.data.setdefault("bettergi", {})
+ self.data.setdefault("daily", {})
+ daily_cfg = self.data["daily"]
+ daily_cfg.setdefault("one_dragon_template", "默认配置")
+ daily_cfg.setdefault("managed_one_dragon_name", "直播系统自动每日")
+ daily_cfg.setdefault("ley_line_craft_resin_before", True)
+ daily_cfg.setdefault("commission_use_current_party", True)
+ daily_cfg.setdefault("current_party_read_timeout_sec", 45)
+ self.data.setdefault("global", {})
+ self.data["global"].pop("admin_uidsText", None)
+ self.data.setdefault("queue", {})
+ self.data.setdefault("broadcast", {})
+ broadcast_cfg = self.data["broadcast"]
+ broadcast_cfg.setdefault("enable_danmu_reply", True)
+ broadcast_cfg.setdefault("enable_system_danmu", True)
+ broadcast_cfg.setdefault("enable_tts", True)
+ broadcast_cfg.setdefault("danmu_interval_sec", 5)
+ broadcast_cfg.setdefault("tts_provider", "faster-qwen3-tts")
+ broadcast_cfg.setdefault("tts", {})
+ broadcast_cfg.setdefault("tts_categories", {})
+ broadcast_cfg.setdefault("tts_queue", {})
+ tts_queue = broadcast_cfg["tts_queue"]
+ tts_queue.setdefault("max_pending", 8)
+ tts_queue.setdefault("playback_max_pending", 2)
+ tts_queue.setdefault("max_age_sec", 40.0)
+ tts_queue.setdefault("urgent_max_age_sec", 60.0)
+ tts_queue.setdefault("low_priority_max_age_sec", 30.0)
+ tts_queue.setdefault("warmup_on_start", True)
+ tts_queue.setdefault("rebuild_before_live", True)
+ tts_categories = broadcast_cfg["tts_categories"]
+ for category in (
+ "signin",
+ "queue",
+ "song_request",
+ "login",
+ "execution",
+ "points",
+ "help",
+ "reset",
+ "system",
+ "gift",
+ ):
+ tts_categories.setdefault(category, True)
+ broadcast_cfg.setdefault("gift_thanks", {})
+ gift_thanks = broadcast_cfg["gift_thanks"]
+ gift_thanks.setdefault("enabled", True)
+ gift_thanks.setdefault("tts", True)
+ gift_thanks.setdefault("danmu", False)
+ gift_thanks.setdefault("template", "感谢{uname}送出的{num}个{gift_name}")
+ gift_thanks.setdefault("merge_window_sec", 2.0)
+ gift_thanks.setdefault("dedupe_window_sec", 15.0)
+ gift_thanks.setdefault("max_pending", 50)
+ tts_all = broadcast_cfg["tts"]
+ tts_all.setdefault("faster-qwen3-tts", {})
+ tts_all["faster-qwen3-tts"].setdefault("device", "cuda")
+ tts_all["faster-qwen3-tts"].setdefault("model_name_or_path", str(project_path("vendor/tts-model")))
+ tts_all["faster-qwen3-tts"].setdefault("language", "Chinese")
+ tts_all["faster-qwen3-tts"].setdefault("ref_audio", str(project_path("data/ref_audio.wav")))
+ tts_all["faster-qwen3-tts"].setdefault("ref_text", "凯茨莱茵家族的迪奥娜小姐,货物我确实收下了,再次感谢您选择狛荷屋")
+ tts_all["faster-qwen3-tts"].setdefault("xvec_only", True)
+ tts_all["faster-qwen3-tts"].setdefault("non_streaming_mode", True)
+ tts_all["faster-qwen3-tts"].setdefault("chunk_size", 8)
+ tts_all["faster-qwen3-tts"].setdefault("append_silence", True)
+ tts_all["faster-qwen3-tts"].setdefault("streaming", True)
+ self.data.setdefault("frontend", {})
+ if legacy_frontend:
+ self.data["frontend"].update(legacy_frontend)
+ self.data.setdefault("music_monitor", {})
+ self.data["music_monitor"].pop("targetsText", None)
+ self.data.setdefault("system", {})
+ self.data.setdefault("rules", [])
+ self.data["global"].setdefault("admin_uids", [])
+ self.data["global"].setdefault("log_level", "INFO")
+ self.data["queue"].setdefault("default_group", "薄荷")
+ self.data["queue"].setdefault("data_dir", "data")
+ # 积分参数补缺省,与全局常量默认值保持一致
+ self.data["queue"].setdefault("initial_points", 10)
+ legacy_signin_points = int(self.data["queue"].get("signin_points", 10))
+ self.data["queue"].setdefault("signin_points_min", min(5, legacy_signin_points))
+ self.data["queue"].setdefault("signin_points_max", legacy_signin_points)
+ self.data["queue"].setdefault("signin_reset_hour", 4)
+ self.data["queue"].pop("signin_points", None)
+ self.data["queue"].setdefault("max_points", 30)
+ self.data["queue"].setdefault("points_per_minute", 1)
+ self.data["queue"].setdefault("admin_window_seconds", 180)
+ self.data["frontend"].setdefault("background_image", "")
+ self.data["frontend"].setdefault("background_opacity", 0.65)
+ self.data["frontend"].setdefault("background_blur", 0)
+ self.data["frontend"].setdefault("background_fit", "cover")
+ self.data["frontend"].setdefault("theme", "classic")
+ self.data["music_monitor"].setdefault("platform", "netease")
+ self.data["music_monitor"].setdefault("targets", ["网易云音乐", "Netease", "CloudMusic", "cloudmusic"])
+ self.data["music_monitor"].setdefault("allow_all", False)
+ self.data["music_monitor"].setdefault("interval_sec", 1.0)
+ self.data["music_monitor"].setdefault("holdover_ms", 1500)
+ self.data["music_monitor"].setdefault("prefer_playing", True)
+ self.data["music_monitor"].setdefault("keep_last_when_none", True)
+ self.data["music_monitor"].setdefault("cover_enabled", True)
+ self.data["music_monitor"].setdefault("auto_resume_enabled", False)
+ self.data["music_monitor"].setdefault("auto_resume_interval_sec", 3)
+ self.data["music_monitor"].setdefault("auto_resume_stall_sec", 10)
+ self.data["music_monitor"].setdefault("extra_filter", "")
+ self.data["music_monitor"].setdefault("request_player", {})
+ request_cfg = self.data["music_monitor"]["request_player"]
+ request_cfg.pop("commandsText", None)
+ request_cfg.setdefault("enabled", True)
+ request_cfg.setdefault("cost_points", 1)
+ request_cfg.setdefault("commands", ["点歌", "dg"])
+ request_cfg.setdefault("api_base", "https://music.163.com")
+ request_cfg.setdefault("play_url_template", "https://music.163.com/#/song?id={id}")
+ request_cfg.setdefault("auto_open", True)
+ request_cfg.setdefault("play_when_idle", True)
+ # 当前歌曲结束前稍微提前切入队首点歌,避免原列表下一首先播放数秒。
+ request_cfg.setdefault("handoff_lead_sec", 1.2)
+ request_cfg.setdefault("max_duration_sec", 600)
+ request_cfg.setdefault("dedupe_history", True)
+ # 历史冷却秒数: 同一首歌1小时内不能再点
+ request_cfg.setdefault("dedupe_cooldown_sec", 3600)
+ request_cfg.setdefault("clear_on_start", True)
+ if request_cfg.get("play_method") in {"netease_client_ui", "netease_cdp"}:
+ request_cfg["play_method"] = "mpv"
+ request_cfg.setdefault("play_method", "mpv")
+ request_cfg.setdefault("mpv_exe", "vendor/mpv/mpv.exe")
+ request_cfg.setdefault("mpv_stall_seconds", 12)
+ request_cfg.setdefault("fallback_to_netease", False)
+ request_cfg.setdefault("netease_music_u", "")
+ request_cfg.setdefault("background_playlist_enabled", False)
+ request_cfg.setdefault("background_playlist_url", "")
+ request_cfg.setdefault("background_playlist_refresh_sec", 3600)
+ request_cfg.setdefault("background_playlist_retry_sec", 30)
+ request_cfg.setdefault("cdp_port", 9222)
+ request_cfg.setdefault("auto_launch_cdp", True)
+ request_cfg.setdefault("client_process", "cloudmusic.exe")
+ request_cfg.setdefault("client_exe", "")
+ request_cfg.setdefault("search_hotkey", "ctrl+f")
+ request_cfg.setdefault("play_enter_count", 2)
+ request_cfg.setdefault("ui_wait_sec", 0.6)
+ request_cfg["allowed_roles"] = normalize_allowed_roles(
+ request_cfg.get("allowed_roles"),
+ ALL_DANMU_ROLES,
+ )
+ system_cfg = self.data["system"]
+ system_cfg.setdefault("enable_startup_shortcut", True)
+ system_cfg.setdefault("startup_bat", "run.bat")
+ # 直播时间是唯一调度入口;旧版独立动作时间仅用于首次迁移。
+ system_cfg.setdefault("live_start_time", system_cfg.get("bilibili_push_time", "09:00"))
+ system_cfg.setdefault("live_end_time", system_cfg.get("bilibili_stop_push_time", "23:00"))
+ system_cfg.setdefault("auto_reboot_enabled", True)
+ system_cfg.setdefault("auto_reboot_time", "03:00")
+ system_cfg.setdefault("reboot_after_stop_enabled", False)
+ system_cfg.setdefault("reboot_after_stop_delay_sec", 60)
+ system_cfg.setdefault("launch_bilibili_live_enabled", False)
+ system_cfg.setdefault("launch_bilibili_live_time", "19:30")
+ system_cfg.setdefault("bilibili_live_exe", "")
+ system_cfg.setdefault("launch_genshin_enabled", False)
+ system_cfg.setdefault("launch_genshin_time", "19:40")
+ system_cfg.setdefault("genshin_exe", "")
+ system_cfg.setdefault("bilibili_push_enabled", False)
+ system_cfg.setdefault("bilibili_push_time", "19:50")
+ system_cfg.setdefault("bilibili_push_window_keyword", "直播姬")
+ system_cfg.setdefault("bilibili_push_click_x_ratio", 0.787)
+ system_cfg.setdefault("bilibili_push_click_y_ratio", 0.927)
+ system_cfg.setdefault("bilibili_stop_push_enabled", False)
+ system_cfg.setdefault("bilibili_stop_push_time", "23:00")
+ system_cfg.setdefault("bilibili_stop_push_click_x_ratio", 0.787)
+ system_cfg.setdefault("bilibili_stop_push_click_y_ratio", 0.927)
+ system_cfg.setdefault("bilibili_stop_push_confirm_enter", True)
+ # 内置指令别名配置
+ self.data.setdefault("commands", {})
+ cmd_defaults = {
+ "queue": {"enabled": True, "aliases": ["排队"]},
+ "signin": {"enabled": True, "aliases": ["签到"]},
+ "login": {"enabled": True, "aliases": ["上号"]},
+ "confirm_yes": {"enabled": True, "aliases": ["是"]},
+ "confirm_no": {"enabled": True, "aliases": ["不是"]},
+ "run": {"enabled": True, "aliases": ["执行", "跑", "开始"]},
+ "daily": {"enabled": True, "aliases": ["自动每日"]},
+ "switch_party": {"enabled": True, "aliases": ["切换队伍", "更换队伍"]},
+ "edit_party": {"enabled": True, "aliases": ["修改队员", "更换队员"]},
+ "leave": {"enabled": True, "aliases": ["退出", "退出排队", "取消排队"]},
+ "reset": {"enabled": True, "aliases": ["重置"]},
+ "points": {"enabled": True, "aliases": ["积分"]},
+ "queue_list": {"enabled": True, "aliases": ["队列"]},
+ "help": {"enabled": True, "aliases": ["帮助"]},
+ }
+ for cmd_key, cmd_val in cmd_defaults.items():
+ node = self.data["commands"].setdefault(cmd_key, cmd_val)
+ # 补全保存后可能丢失的字段
+ node.setdefault("enabled", cmd_val["enabled"])
+ node.setdefault("aliases", cmd_val["aliases"])
+ node["allowed_roles"] = normalize_allowed_roles(
+ node.get("allowed_roles"),
+ COMMAND_ALLOWED_ROLE_DEFAULTS.get(cmd_key, ALL_DANMU_ROLES),
+ )
+ for rule in self.data["rules"]:
+ if not isinstance(rule, dict):
+ continue
+ rule["allowed_roles"] = default_rule_allowed_roles(rule)
+
+ @property
+ def room_id(self) -> int:
+ return int(self.data.get("bilibili", {}).get("room_id", 0))
+
+ @property
+ def sessdata(self) -> str:
+ return str(self.data.get("bilibili", {}).get("sessdata", "") or "").strip()
+
+ @property
+ def bilibili_cookie(self) -> str:
+ """构造 API 与 WebSocket 共用的登录 Cookie,保证认证上下文一致。"""
+ bili = self.data.get("bilibili", {})
+ explicit = str(bili.get("cookie", "") or "").strip()
+ if explicit:
+ return explicit
+ parts = []
+ if self.sessdata:
+ parts.append(f"SESSDATA={self.sessdata}")
+ bili_jct = str(bili.get("bili_jct", "") or "").strip()
+ if bili_jct:
+ parts.append(f"bili_jct={bili_jct}")
+ buvid3 = str(bili.get("buvid3", "") or "").strip()
+ if buvid3:
+ parts.append(f"buvid3={buvid3}")
+ return "; ".join(parts)
+
+ @property
+ def bettergi_exe(self) -> str:
+ return self.data.get("bettergi", {}).get("exe_path", "")
+
+ @property
+ def bettergi_work_dir(self) -> str:
+ wd = self.data.get("bettergi", {}).get("work_dir", "")
+ return wd if wd else str(Path(self.bettergi_exe).parent)
+
+ @property
+ def daily_cfg(self) -> dict:
+ return self.data.get("daily", {})
+
+ @property
+ def default_group(self) -> str:
+ return self.data.get("queue", {}).get("default_group", "薄荷")
+
+ @property
+ def admin_uids(self) -> set:
+ return set(int(x) for x in self.data.get("global", {}).get("admin_uids", []))
+
+ @property
+ def data_dir(self) -> str:
+ return str(project_path(self.data.get("queue", {}).get("data_dir", "data")))
+
+ @property
+ def log_level(self) -> str:
+ return self.data.get("global", {}).get("log_level", "INFO")
+
+ @property
+ def system_cfg(self) -> dict:
+ return self.data.setdefault("system", {})
+
+ @property
+ def bili_jct(self) -> str:
+ bili = self.data.get("bilibili", {})
+ explicit = str(bili.get("bili_jct", "") or "").strip()
+ if explicit:
+ return explicit
+ sessdata = str(bili.get("sessdata", "") or "")
+ if "bili_jct=" in sessdata:
+ try:
+ cookie = http.cookies.SimpleCookie()
+ cookie.load(sessdata.replace("; ", ";"))
+ if "bili_jct" in cookie:
+ return cookie["bili_jct"].value
+ except Exception:
+ pass
+ return ""
+
+ @property
+ def broadcast_cfg(self) -> dict:
+ return self.data.get("broadcast", {})
+
+ @property
+ def frontend_cfg(self) -> dict:
+ return self.data.get("frontend", {})
+
+ @property
+ def music_monitor_cfg(self) -> dict:
+ return self.data.get("music_monitor", {})
+
+ @property
+ def enable_danmu_reply(self) -> bool:
+ return self.broadcast_cfg.get("enable_danmu_reply", False)
+
+ @property
+ def enable_tts(self) -> bool:
+ return self.broadcast_cfg.get("enable_tts", False)
+
+ @property
+ def danmu_interval_sec(self) -> float:
+ return float(self.broadcast_cfg.get("danmu_interval_sec", 5))
+
+ @property
+ def tts_provider(self) -> str:
+ if not self.enable_tts:
+ return "none"
+ return self.broadcast_cfg.get("tts_provider", "none")
+
+ @property
+ def tts_cfg(self) -> dict:
+ return self.broadcast_cfg.get("tts", {})
+
+
+class ServiceRegistry:
+ """Runtime service state registry for admin UI and watchdogs."""
+
+ STARTING = "STARTING"
+ RUNNING = "RUNNING"
+ DEGRADED = "DEGRADED"
+ RECONNECTING = "RECONNECTING"
+ STOPPING = "STOPPING"
+ STOPPED = "STOPPED"
+ FAILED = "FAILED"
+
+ def __init__(self, stats_store: StatsStore | None = None):
+ self._services: dict[str, dict] = {}
+ self._lock = threading.Lock()
+ self.stats_store = stats_store
+
+ def set(self, name: str, state: str, message: str = "", error: str = ""):
+ now = time.time()
+ with self._lock:
+ item = self._services.setdefault(name, {
+ "name": name,
+ "state": self.STARTING,
+ "message": "",
+ "error": "",
+ "restarts": 0,
+ "updated_at": now,
+ })
+ old_state = item.get("state")
+ old_message = item.get("message", "")
+ old_error = item.get("error", "")
+ item["state"] = state
+ item["message"] = message
+ item["error"] = error
+ item["updated_at"] = now
+ if self.stats_store and (old_state, old_message, old_error) != (state, message, error):
+ self.stats_store.record_service_state(
+ name,
+ state,
+ old_state=old_state,
+ reason=message or None,
+ payload={"error": error} if error else {},
+ )
+
+ def bump_restart(self, name: str):
+ with self._lock:
+ item = self._services.setdefault(name, {
+ "name": name,
+ "state": self.STARTING,
+ "message": "",
+ "error": "",
+ "restarts": 0,
+ "updated_at": time.time(),
+ })
+ current_state = item.get("state")
+ item["restarts"] = int(item.get("restarts", 0)) + 1
+ restart_count = item["restarts"]
+ item["updated_at"] = time.time()
+ if self.stats_store:
+ self.stats_store.record_service_state(
+ name,
+ "restart",
+ old_state=current_state,
+ reason="watchdog_restart",
+ payload={"restart_count": restart_count},
+ )
+
+ def snapshot(self) -> list[dict]:
+ with self._lock:
+ return [dict(v) for v in sorted(self._services.values(), key=lambda x: x["name"])]
+
+ def summary(self) -> str:
+ states = {item["state"] for item in self.snapshot()}
+ if self.FAILED in states:
+ return self.FAILED
+ if self.DEGRADED in states:
+ return self.DEGRADED
+ if self.RECONNECTING in states:
+ return self.RECONNECTING
+ if self.STARTING in states:
+ return self.STARTING
+ if states and states <= {self.RUNNING, self.STOPPED}:
+ return self.RUNNING
+ return self.STOPPED
+
+
+# ============== 用户管理 ==============
+class UserManager:
+ """用户账号与积分管理,持久化到 data/users.json"""
+
+ def __init__(self, data_dir: str, logger: logging.Logger, config: "Config | None" = None,
+ stats_store: StatsStore | None = None):
+ self.data_dir = Path(data_dir)
+ self.data_dir.mkdir(parents=True, exist_ok=True)
+ self.users_path = self.data_dir / "users.json"
+ self.logger = logger
+ self.config = config # resolve_uname 用它读 sessdata
+ self.stats_store = stats_store
+ self.users: dict = {}
+ self._lock = asyncio.Lock()
+ self._points_backfill_needed = False
+ self._load()
+
+ def _load(self):
+ if self.users_path.exists():
+ try:
+ with open(self.users_path, "r", encoding="utf-8") as f:
+ self.users = json.load(f)
+ except (json.JSONDecodeError, OSError) as exc:
+ self.logger.error(f"[用户] 读取 users.json 失败,备份后重建: {exc}")
+ try:
+ backup = self.users_path.with_suffix(".corrupt-" + datetime.now().strftime("%Y%m%d-%H%M%S"))
+ self.users_path.replace(backup)
+ except Exception:
+ pass
+ self.users = {}
+ dirty = False
+ for uid_str, user in list(self.users.items()):
+ if not isinstance(user, dict):
+ self.users[uid_str] = self._default_user(uid_str, f"用户{uid_str}")
+ dirty = True
+ continue
+ before = dict(user)
+ self._normalize_user(user)
+ dirty = dirty or before != user
+ # 积分权威在 sqlite:统计库已有积分数据时覆盖 users.json,否则整体回填。
+ if self.stats_store is not None:
+ sqlite_points = self.stats_store.load_user_points("bilibili")
+ if not sqlite_points:
+ self._points_backfill_needed = True
+ else:
+ for uid_str, pts in sqlite_points.items():
+ user = self.users.get(uid_str)
+ if user is None:
+ user = self._default_user(uid_str, f"用户{uid_str}")
+ self.users[uid_str] = user
+ user["points"] = pts
+ if any(uid not in sqlite_points for uid in self.users):
+ self._points_backfill_needed = True
+ if dirty:
+ asyncio.create_task(self._save())
+
+ def _default_user(self, uid_str: str, uname: str) -> dict:
+ return {
+ "uname": uname,
+ "points": INITIAL_POINTS,
+ "last_signin_date": "",
+ "created_at": datetime.now().isoformat(),
+ "blocked_all": False,
+ "blocked_queue": False,
+ "blocked_song_request": False,
+ "note": "",
+ }
+
+ def _normalize_user(self, user: dict) -> dict:
+ user.setdefault("uname", "")
+ user.setdefault("points", INITIAL_POINTS)
+ user.setdefault("last_signin_date", "")
+ user.setdefault("created_at", datetime.now().isoformat())
+ user.setdefault("blocked_all", False)
+ user.setdefault("blocked_queue", False)
+ user.setdefault("blocked_song_request", False)
+ user.setdefault("note", "")
+ return user
+
+ async def _save(self):
+ async with self._lock:
+ tmp = self.users_path.with_suffix(".tmp")
+ try:
+ with open(tmp, "w", encoding="utf-8") as f:
+ json.dump(self.users, f, ensure_ascii=False, indent=2)
+ tmp.replace(self.users_path)
+ except OSError as exc:
+ # 积分已由 sqlite 权威持久化,users.json 仅作缓存,保存失败不致命。
+ self.logger.error(f"[用户] 保存 users.json 失败(积分已持久化到 sqlite): {exc}")
+
+ async def backfill_points_to_sqlite(self):
+ """首次迁移或补漏:把内存积分整体写入 sqlite 权威余额。"""
+ if not self.stats_store or not self._points_backfill_needed:
+ return
+ for uid_str, user in list(self.users.items()):
+ await self.stats_store.set_user_points("bilibili", uid_str, int(user.get("points", 0) or 0))
+ self._points_backfill_needed = False
+
+ def ensure_user(self, uid: int, uname: str) -> dict:
+ """确保有效 UID 用户存在;匿名 uid=0 不得进入积分/队列账户。"""
+ if not isinstance(uid, int) or isinstance(uid, bool) or uid <= 0:
+ raise ValueError(f"无效或匿名 UID: {uid!r}")
+ uid_str = str(uid)
+ created = uid_str not in self.users
+ if created:
+ self.users[uid_str] = self._default_user(uid_str, uname)
+ self.logger.info(f"[新用户] {uname}({uid}) 创建账号, 赠送{INITIAL_POINTS}积分")
+ asyncio.create_task(self._save())
+ if self.stats_store:
+ asyncio.create_task(self.stats_store.set_user_points("bilibili", uid_str, INITIAL_POINTS))
+ else:
+ # 更新昵称,但不允许平台脱敏昵称覆盖已经保存的真实昵称。
+ old_uname = str(self.users[uid_str].get("uname", "") or "")
+ if uname and uname != old_uname and ("*" not in uname or not old_uname or "*" in old_uname):
+ self.users[uid_str]["uname"] = uname
+ user = self._normalize_user(self.users[uid_str])
+ if self.stats_store:
+ self.stats_store.upsert_user_snapshot(
+ "bilibili",
+ uid_str,
+ display_name=user.get("uname"),
+ snapshot={
+ "points": user.get("points"),
+ "created_at": user.get("created_at"),
+ "blocked_all": user.get("blocked_all"),
+ "blocked_queue": user.get("blocked_queue"),
+ "blocked_song_request": user.get("blocked_song_request"),
+ "note": user.get("note", ""),
+ },
+ )
+ if created:
+ self.stats_store.record_point_transaction(
+ f"user-created:{uid_str}",
+ INITIAL_POINTS,
+ platform="bilibili",
+ platform_user_id=uid_str,
+ balance_after=INITIAL_POINTS,
+ reason="account_created",
+ )
+ return user
+
+ def get_user(self, uid: int) -> dict:
+ if not isinstance(uid, int) or isinstance(uid, bool) or uid <= 0:
+ raise ValueError(f"无效或匿名 UID: {uid!r}")
+ return self._normalize_user(self.users.setdefault(str(uid), self._default_user(str(uid), f"用户{uid}")))
+
+ def get_points(self, uid: int) -> int:
+ return self.users.get(str(uid), {}).get("points", 0)
+
+ def has_block(self, uid: int, scope: str) -> bool:
+ user = self.users.get(str(uid), {})
+ if user.get("blocked_all"):
+ return True
+ if scope == "queue":
+ return bool(user.get("blocked_queue"))
+ if scope == "song_request":
+ return bool(user.get("blocked_song_request"))
+ return False
+
+ def _is_masked_uname(self, uname: str) -> bool:
+ return not uname or "*" in uname
+
+ async def resolve_uname(self, uid: int, fallback: str = "") -> str:
+ """尽量用 B站公开接口按 uid 补全昵称。若平台返回仍脱敏/不可查,则保留 fallback。"""
+ if not uid or uid <= 0:
+ return fallback
+ uid_str = str(uid)
+ cached = self.users.get(uid_str, {}).get("uname", "")
+ if cached and not self._is_masked_uname(cached):
+ return cached
+ fallback = fallback or cached or f"用户{uid}"
+ urls = [
+ f"https://api.bilibili.com/x/space/wbi/acc/info?mid={uid}",
+ f"https://api.bilibili.com/x/space/acc/info?mid={uid}",
+ f"https://api.bilibili.com/x/web-interface/card?mid={uid}",
+ ]
+ headers = {"User-Agent": "Mozilla/5.0", "Referer": "https://www.bilibili.com/"}
+ sessdata = getattr(self.config, "sessdata", "") if self.config else ""
+ if sessdata:
+ headers["Cookie"] = f"SESSDATA={sessdata}"
+ # 同步网络查询放到线程池,避免阻塞事件循环(弹幕处理热路径)。
+ name = await asyncio.to_thread(self._resolve_uname_blocking, urls, headers, uid)
+ if name:
+ if uid_str in self.users:
+ self.users[uid_str]["uname"] = name
+ await self._save()
+ self.logger.info(f"[昵称补全] {fallback}({uid}) -> {name}")
+ return name
+ return fallback
+
+ def _resolve_uname_blocking(self, urls: list[str], headers: dict, uid: int) -> str:
+ for url in urls:
+ try:
+ req = urllib.request.Request(url, headers=headers)
+ with urllib.request.urlopen(req, timeout=5) as resp:
+ data = json.loads(resp.read().decode("utf-8", errors="replace"))
+ if data.get("code") != 0:
+ continue
+ node = data.get("data", {}) or {}
+ names = [node.get("name")]
+ if isinstance(node.get("card"), dict):
+ names.append(node["card"].get("name"))
+ for name in names:
+ if isinstance(name, str) and name and not self._is_masked_uname(name):
+ return name
+ except Exception as e:
+ self.logger.debug(f"[昵称补全] 查询 {uid} 失败: {e}")
+ return ""
+
+ async def add_points(
+ self,
+ uid: int,
+ delta: int,
+ *,
+ reason: str = "unspecified",
+ reference_type: str = "",
+ reference_id: str = "",
+ transaction_id: str | None = None,
+ ):
+ """增减积分(可为负),保存成功后旁路记录积分流水。返回新积分。"""
+ uid_str = str(uid)
+ if uid_str not in self.users:
+ return 0
+ new_points = self.users[uid_str]["points"] + delta
+ self.users[uid_str]["points"] = new_points
+ if self.stats_store:
+ await self.stats_store.set_user_points("bilibili", uid_str, new_points)
+ await self._save()
+ if self.stats_store:
+ self.stats_store.record_point_transaction(
+ transaction_id or uuid.uuid4().hex,
+ delta,
+ platform="bilibili",
+ platform_user_id=uid_str,
+ balance_after=new_points,
+ reason=reason,
+ reference_type=reference_type or None,
+ reference_id=reference_id or None,
+ )
+ user = self.users[uid_str]
+ self.stats_store.upsert_user_snapshot(
+ "bilibili",
+ uid_str,
+ display_name=user.get("uname"),
+ snapshot=user,
+ )
+ return new_points
+
+ async def signin(self, uid: int) -> dict:
+ """每日签到。返回 {"success": bool, "msg": str, "points": int}"""
+ uid_str = str(uid)
+ if uid_str not in self.users:
+ return {"success": False, "msg": "用户不存在", "points": 0}
+ business_date = get_signin_business_date()
+ user = self.users[uid_str]
+ if user.get("last_signin_date") == business_date:
+ return {"success": False, "msg": "本签到周期已签到", "points": user["points"]}
+ user["last_signin_date"] = business_date
+ old = user["points"]
+ reward = random.randint(SIGNIN_POINTS_MIN, SIGNIN_POINTS_MAX)
+ # 积分已达/超上限(如兑换码超限)时,签到不增加也不减少
+ if old >= MAX_POINTS:
+ user["points"] = old
+ else:
+ user["points"] = min(old + reward, MAX_POINTS)
+ if self.stats_store:
+ await self.stats_store.set_user_points("bilibili", uid_str, user["points"])
+ await self._save()
+ gained = user["points"] - old
+ if self.stats_store:
+ signin_event_id = f"signin:{business_date}:{uid_str}"
+ self.stats_store.record_signin(
+ signin_event_id,
+ platform="bilibili",
+ platform_user_id=uid_str,
+ points_awarded=gained,
+ status="success",
+ )
+ if gained:
+ self.stats_store.record_point_transaction(
+ f"signin-points:{business_date}:{uid_str}",
+ gained,
+ platform="bilibili",
+ platform_user_id=uid_str,
+ balance_after=user["points"],
+ reason="signin_reward",
+ reference_type="signin",
+ reference_id=signin_event_id,
+ )
+ self.stats_store.upsert_user_snapshot(
+ "bilibili",
+ uid_str,
+ display_name=user.get("uname"),
+ snapshot=user,
+ )
+ if gained > 0:
+ msg = f"签到成功 +{gained}积分 (当前{user['points']}/{MAX_POINTS})"
+ else:
+ msg = f"签到成功,积分已达上限不增加 (当前{user['points']}/{MAX_POINTS})"
+ return {
+ "success": True,
+ "msg": msg,
+ "points": user["points"],
+ }
+
+ async def update_flags(
+ self,
+ uid: int,
+ *,
+ blocked_all: bool | None = None,
+ blocked_queue: bool | None = None,
+ blocked_song_request: bool | None = None,
+ note: str | None = None,
+ ) -> dict:
+ user = self.get_user(uid)
+ if blocked_all is not None:
+ user["blocked_all"] = bool(blocked_all)
+ if blocked_queue is not None:
+ user["blocked_queue"] = bool(blocked_queue)
+ if blocked_song_request is not None:
+ user["blocked_song_request"] = bool(blocked_song_request)
+ if note is not None:
+ user["note"] = str(note)
+ await self._save()
+ if self.stats_store:
+ self.stats_store.upsert_user_snapshot(
+ "bilibili",
+ str(uid),
+ display_name=user.get("uname"),
+ snapshot=user,
+ )
+ return user
+
+
+# ============== 队列管理 ==============
+class QueueManager:
+ """排队队列管理。持久化到 data/queue_state.json"""
+
+ def __init__(self, data_dir: str, logger: logging.Logger,
+ stats_store: StatsStore | None = None):
+ self.data_dir = Path(data_dir)
+ self.data_dir.mkdir(parents=True, exist_ok=True)
+ self.state_path = self.data_dir / "queue_state.json"
+ self.logger = logger
+ self.stats_store = stats_store
+ self.state = {
+ "queue": [], # [uid, uid, ...]
+ "queue_joined_at": {}, # uid -> UTC ISO,本轮排队等待起点
+ "current_admin_uid": None, # 当前临时管理员UID
+ "current_group": None, # 当前运行的配置组名
+ "current_group_run_id": None, # 当前配置组任务实例编号,用于拒绝迟到/重复完成回调
+ "group_start_time": None, # ISO时间戳
+ "admin_window_end": None, # 90秒窗口结束时间戳(ISO)
+ "default_running": False, # 是否在跑默认薄荷
+ "login_status": None, # None/logining/confirming/logged_in
+ "login_session_id": None, # 本轮扫码登录稳定会话编号
+ "login_started_at": None, # 本轮扫码上号开始时间戳
+ "confirm_started_at": None, # 本轮账号确认开始时间戳
+ "billing_started_at": None, # 确认账号后开始扣积分的时间戳
+ "billing_last_at": None, # 上次扣积分时间戳
+ "billing_uid": None, # 当前正在计费的用户;积分耗尽出队后仍继续扣到负分
+ "last_login_remind_at": None, # 上号/确认阶段上次提醒时间戳
+ "reset_on_next_login_uid": None, # 登录超时后,下一位发送“上号”时先执行统一重置
+ "has_user_finished_once": False, # 至少一个用户任务结束后才允许空闲薄荷
+ }
+ self._load()
+ # 每次重新启动直播视为新会话:清空上次遗留的排队、队首、扫码登录和运行状态。
+ # 用户账号/积分保留在 users.json;这里只清理 queue_state.json 的运行态。
+ self.state["queue"] = []
+ self.state["queue_joined_at"] = {}
+ self.state["current_admin_uid"] = None
+ self.state["current_group"] = None
+ self.state["current_group_run_id"] = None
+ self.state["group_start_time"] = None
+ self.state["admin_window_end"] = None
+ self.state["default_running"] = False
+ self.state["login_status"] = None
+ self.state["login_session_id"] = None
+ self.state["login_started_at"] = None
+ self.state["confirm_started_at"] = None
+ self.state["billing_started_at"] = None
+ self.state["billing_last_at"] = None
+ self.state["billing_uid"] = None
+ self.state["last_login_remind_at"] = datetime.now().timestamp()
+ self.state["reset_on_next_login_uid"] = None
+ self.state["has_user_finished_once"] = False
+ self._save()
+
+ def _load(self):
+ if self.state_path.exists():
+ with open(self.state_path, "r", encoding="utf-8") as f:
+ saved = json.load(f)
+ self.state.update(saved)
+
+ def _save(self):
+ tmp = self.state_path.with_suffix(".tmp")
+ with open(tmp, "w", encoding="utf-8") as f:
+ json.dump(self.state, f, ensure_ascii=False, indent=2)
+ tmp.replace(self.state_path)
+
+ def interrupt_group(
+ self,
+ reason: str,
+ *,
+ status: str = "interrupted",
+ expected_run_id: str | None = None,
+ clear_state: bool = True,
+ ) -> dict:
+ """幂等收口当前配置组,供主动停止、替换和异常结束路径统一使用。"""
+ run_id = self.state.get("current_group_run_id")
+ group_name = self.state.get("current_group")
+ if not run_id or (expected_run_id is not None and run_id != expected_run_id):
+ return {"accepted": False, "run_id": run_id, "group_name": group_name}
+ final_status = status if status in {"cancelled", "interrupted", "failed"} else "interrupted"
+ payload = {
+ "reason": str(reason or "unspecified"),
+ "default_running": bool(self.state.get("default_running")),
+ "billing_uid": self.state.get("billing_uid"),
+ }
+ if clear_state:
+ self.state["current_group"] = None
+ self.state["current_group_run_id"] = None
+ self.state["group_start_time"] = None
+ self.state["default_running"] = False
+ self._save()
+ if self.stats_store:
+ self.stats_store.record_group_run(
+ str(run_id),
+ group_name=group_name,
+ status=final_status,
+ ended_at_utc=datetime.now(timezone.utc).isoformat(),
+ failed_count=1 if final_status == "failed" else 0,
+ payload=payload,
+ )
+ self.logger.info(
+ f"[配置组] 已收口运行实例: group='{group_name}', run_id={run_id}, "
+ f"status={final_status}, reason={reason}"
+ )
+ return {"accepted": True, "run_id": run_id, "group_name": group_name, "status": final_status}
+
+ def join_queue(self, uid: int) -> dict:
+ """加入队列。返回 {"success": bool, "msg": str, "position": int}"""
+ if uid in self.state["queue"]:
+ pos = self.state["queue"].index(uid) + 1
+ return {"success": False, "msg": f"已在队列中(第{pos}位)", "position": pos}
+ joined_at = datetime.now(timezone.utc)
+ self.state["queue"].append(uid)
+ self.state.setdefault("queue_joined_at", {})[str(uid)] = joined_at.isoformat()
+ pos = len(self.state["queue"])
+ self._save()
+ if self.stats_store:
+ self.stats_store.record_queue_event(
+ "main",
+ "join",
+ item_id=str(uid),
+ position=pos,
+ queue_size=len(self.state["queue"]),
+ payload={"platform": "bilibili", "platform_user_id": str(uid)},
+ )
+ # 如果是第一个,自动成为临时管理员,开启90秒窗口
+ if pos == 1 and self.state["current_admin_uid"] is None:
+ self._set_admin(uid)
+ return {"success": True, "msg": f"已加入队列(第{pos}位)", "position": pos}
+
+ def leave_queue(self, uid: int, *, action: str = "leave", reason: str = "") -> dict:
+ """退出队列,并在现有状态保存成功后记录离队原因。"""
+ if uid not in self.state["queue"]:
+ return {"success": False, "msg": "不在队列中",
+ "was_admin": False, "was_running": False}
+ old_position = self.state["queue"].index(uid) + 1
+ was_admin = (uid == self.state["current_admin_uid"])
+ preserve_default_group = bool(was_admin and self.state.get("default_running"))
+ was_running = (was_admin
+ and self.state["current_group"] is not None
+ and not self.state["default_running"])
+ login_session_id = self.state.get("login_session_id") if was_admin else None
+ if was_running:
+ self.interrupt_group(reason or action, status="cancelled")
+ self.state["queue"].remove(uid)
+ self.state.setdefault("queue_joined_at", {}).pop(str(uid), None)
+ if was_admin:
+ # 队首退出:清空配置组/登录状态,提升下一个
+ self.state["current_admin_uid"] = None
+ if not preserve_default_group:
+ self.state["current_group"] = None
+ self.state["current_group_run_id"] = None
+ self.state["group_start_time"] = None
+ self.state["admin_window_end"] = None
+ self.state["login_status"] = None
+ self.state["login_session_id"] = None
+ self.state["login_started_at"] = None
+ self.state["confirm_started_at"] = None
+ self.state["billing_started_at"] = None
+ self.state["billing_last_at"] = None
+ self.state["billing_uid"] = None
+ self._promote_next()
+ self._save()
+ if self.stats_store and login_session_id:
+ self.stats_store.record_login_session(
+ login_session_id,
+ status="cancelled",
+ ended_at_utc=datetime.now(timezone.utc).isoformat(),
+ metadata_json=json.dumps({"reason": reason or action}, ensure_ascii=False),
+ )
+ if self.stats_store:
+ self.stats_store.record_queue_event(
+ "main",
+ action,
+ item_id=str(uid),
+ position=old_position,
+ queue_size=len(self.state["queue"]),
+ payload={
+ "platform": "bilibili",
+ "platform_user_id": str(uid),
+ "reason": reason,
+ "was_admin": was_admin,
+ "was_running": was_running,
+ "promoted_uid": self.state.get("current_admin_uid") if was_admin else None,
+ },
+ )
+ return {"success": True, "msg": "已退出队列",
+ "was_admin": was_admin, "was_running": was_running}
+
+ def _set_admin(self, uid: int):
+ """设置队首并开启90秒上号窗口"""
+ self.state["current_admin_uid"] = uid
+ self.state["login_status"] = None
+ self.state["login_session_id"] = None
+ self.state["login_started_at"] = None
+ self.state["confirm_started_at"] = None
+ self.state["billing_started_at"] = None
+ self.state["billing_last_at"] = None
+ self.state["billing_uid"] = None
+ self.state["last_login_remind_at"] = datetime.now().timestamp()
+ if self.state.get("reset_on_next_login_uid") not in (None, uid):
+ self.state["reset_on_next_login_uid"] = None
+ self.state["admin_window_end"] = (
+ datetime.now().timestamp() + ADMIN_WINDOW_SECONDS
+ )
+ self.logger.info(f"[队列] {uid} 成为队首, 90秒上号窗口开启")
+ joined_at_raw = self.state.setdefault("queue_joined_at", {}).pop(str(uid), None)
+ promoted_at = datetime.now(timezone.utc)
+ wait_ms = None
+ if joined_at_raw:
+ try:
+ joined_at = datetime.fromisoformat(str(joined_at_raw).replace("Z", "+00:00"))
+ if joined_at.tzinfo is None:
+ joined_at = joined_at.replace(tzinfo=timezone.utc)
+ wait_ms = max(0, int((promoted_at - joined_at.astimezone(timezone.utc)).total_seconds() * 1000))
+ except (TypeError, ValueError):
+ self.logger.warning(f"[队列] {uid} 的入队时间无效,跳过等待时长统计")
+ if self.stats_store:
+ self.stats_store.record_queue_event(
+ "main",
+ "promoted",
+ item_id=str(uid),
+ occurred_at_utc=promoted_at.isoformat(),
+ position=1,
+ queue_size=len(self.state["queue"]),
+ wait_ms=wait_ms,
+ payload={"platform": "bilibili", "platform_user_id": str(uid)},
+ )
+ self._save()
+
+ def touch_admin(self, uid: int) -> bool:
+ """队首任意弹幕续期90秒窗口。"""
+ if uid != self.state["current_admin_uid"]:
+ return False
+ if self.state["current_group"] is not None:
+ return False
+ if self.state.get("login_status") == "logining":
+ return False
+ self.state["admin_window_end"] = (
+ datetime.now().timestamp() + ADMIN_WINDOW_SECONDS
+ )
+ self._save()
+ return True
+
+ def _promote_next(self):
+ """提升下一个用户为临时管理员"""
+ if self.state["queue"]:
+ next_uid = self.state["queue"][0]
+ self._set_admin(next_uid)
+ else:
+ self.state["current_admin_uid"] = None
+ self.state["admin_window_end"] = None
+
+ def remove_current_admin_keep_running(self, *, action: str = "leave", reason: str = "") -> dict:
+ """移出当前计费用户并提升下一位,但不清 current_group、不停止 BGI。
+
+ 适用于积分耗尽或用户在配置组执行中主动退出。running_group 继续自然运行;
+ billing_uid 继续指向被移出的用户并继续计费。下一位成为队首后可发送“上号”
+ 抢占,此时由 _cmd_login 负责停止旧 BetterGI 任务。
+ """
+ old_uid = self.state.get("current_admin_uid")
+ if not old_uid:
+ return {"success": False, "old_uid": None, "promoted_uid": None}
+ login_session_id = self.state.get("login_session_id")
+ running_group = self.state.get("current_group")
+ running_group_run_id = self.state.get("current_group_run_id")
+ group_start_time = self.state.get("group_start_time")
+ billing_started_at = self.state.get("billing_started_at")
+ billing_last_at = self.state.get("billing_last_at")
+ if old_uid in self.state["queue"]:
+ self.state["queue"].remove(old_uid)
+ self.state.setdefault("queue_joined_at", {}).pop(str(old_uid), None)
+ self.state["current_admin_uid"] = None
+ self.state["login_status"] = None
+ self.state["login_session_id"] = None
+ self.state["admin_window_end"] = None
+ self._promote_next()
+ # _set_admin 会清空计费字段;这里恢复旧用户计费与当前运行组,保证 BGI 继续跑且继续扣旧用户负分。
+ self.state["current_group"] = running_group
+ self.state["current_group_run_id"] = running_group_run_id
+ self.state["group_start_time"] = group_start_time
+ self.state["billing_uid"] = old_uid
+ self.state["billing_started_at"] = billing_started_at or datetime.now().isoformat()
+ self.state["billing_last_at"] = billing_last_at or datetime.now().isoformat()
+ self._save()
+ if self.stats_store and login_session_id:
+ self.stats_store.record_login_session(
+ login_session_id,
+ status="cancelled",
+ ended_at_utc=datetime.now(timezone.utc).isoformat(),
+ metadata_json=json.dumps({"reason": reason or action}, ensure_ascii=False),
+ )
+ if self.stats_store:
+ self.stats_store.record_queue_event(
+ "main",
+ action,
+ item_id=str(old_uid),
+ position=1,
+ queue_size=len(self.state["queue"]),
+ payload={
+ "platform": "bilibili",
+ "platform_user_id": str(old_uid),
+ "reason": reason,
+ "running_group": running_group,
+ "group_run_id": running_group_run_id,
+ "promoted_uid": self.state.get("current_admin_uid"),
+ },
+ )
+ return {
+ "success": True,
+ "old_uid": old_uid,
+ "promoted_uid": self.state.get("current_admin_uid"),
+ "running_group": running_group,
+ }
+
+ def start_login(self, uid: int) -> dict:
+ """队首触发扫码上号。返回 {"success": bool, "msg": str}"""
+ if uid != self.state["current_admin_uid"]:
+ return {"success": False, "msg": "你不是队首"}
+ if self.state["login_status"] == "logged_in":
+ return {"success": False, "msg": "已登录,请发\"执行 组名\""}
+ if self.state["login_status"] == "confirming":
+ return {"success": False, "msg": "请先回复\"是\"或\"不是\"确认账号"}
+ if self.state["current_group"] is not None:
+ return {"success": False, "msg": "当前有配置组在运行"}
+ now = datetime.now().isoformat()
+ login_session_id = uuid.uuid4().hex
+ self.state["login_status"] = "logining"
+ self.state["login_session_id"] = login_session_id
+ self.state["login_started_at"] = now
+ self.state["confirm_started_at"] = None
+ self.state["admin_window_end"] = None
+ self.state["last_login_remind_at"] = datetime.now().timestamp()
+ self._save()
+ if self.stats_store:
+ self.stats_store.record_login_session(
+ login_session_id,
+ platform="bilibili",
+ platform_user_id=str(uid),
+ status="logining",
+ client_kind="bettergi_qr",
+ metadata_json=json.dumps({"queue_position": 1}, ensure_ascii=False),
+ )
+ return {"success": True, "msg": "扫码上号已启动", "login_session_id": login_session_id}
+
+ def set_login_status(self, status: str | None, *, reason: str = ""):
+ """更新登录状态,并复用 login_session_id 推进登录生命周期。"""
+ now_iso = datetime.now().isoformat()
+ login_session_id = self.state.get("login_session_id")
+ self.state["login_status"] = status
+ if status in ("logining", "confirming"):
+ self.state["last_login_remind_at"] = datetime.now().timestamp()
+ if status == "logining":
+ self.state["login_started_at"] = self.state.get("login_started_at") or now_iso
+ self.state["confirm_started_at"] = None
+ elif status == "confirming":
+ self.state["confirm_started_at"] = now_iso
+ elif status == "logged_in":
+ self.state["login_started_at"] = None
+ self.state["confirm_started_at"] = None
+ if status in ("confirming", "logged_in"):
+ self.state["admin_window_end"] = None
+ elif status is None and self.state.get("current_admin_uid"):
+ self.state["admin_window_end"] = (
+ datetime.now().timestamp() + ADMIN_WINDOW_SECONDS
+ )
+ if status == "logged_in" and not self.state.get("billing_started_at"):
+ self.state["billing_started_at"] = now_iso
+ self.state["billing_last_at"] = now_iso
+ self.state["billing_uid"] = self.state.get("current_admin_uid")
+ if status is None:
+ self.state["login_started_at"] = None
+ self.state["confirm_started_at"] = None
+ self.state["billing_started_at"] = None
+ self.state["billing_last_at"] = None
+ self.state["billing_uid"] = None
+ if status not in ("logining", "confirming"):
+ self.state["last_login_remind_at"] = datetime.now().timestamp()
+ self._save()
+ if self.stats_store and login_session_id:
+ fields: dict[str, Any] = {
+ "status": status or "cancelled",
+ "metadata_json": json.dumps({"reason": reason}, ensure_ascii=False),
+ }
+ if status in ("logged_in", None):
+ fields["ended_at_utc"] = datetime.now(timezone.utc).isoformat()
+ self.stats_store.record_login_session(login_session_id, **fields)
+ if status in ("logged_in", None):
+ self.state["login_session_id"] = None
+ self._save()
+
+ def start_group(self, uid: int, group_name: str, run_id: str | None = None) -> dict:
+ """已登录队首执行配置组。每次运行绑定唯一任务实例编号。"""
+ if uid != self.state["current_admin_uid"]:
+ return {"success": False, "msg": "你不是队首"}
+ if self.state["login_status"] != "logged_in":
+ return {"success": False, "msg": "请先发\"上号\"完成扫码登录"}
+ run_id = str(run_id or uuid.uuid4().hex)
+ self.state["current_group"] = group_name
+ self.state["current_group_run_id"] = run_id
+ self.state["default_running"] = False
+ self.state["group_start_time"] = datetime.now().isoformat()
+ self.state["admin_window_end"] = None # 运行中不需要窗口
+ if not self.state.get("billing_started_at"):
+ now = datetime.now().isoformat()
+ self.state["billing_started_at"] = now
+ self.state["billing_last_at"] = now
+ self.state["billing_uid"] = uid
+ self._save()
+ if self.stats_store:
+ self.stats_store.record_group_run(
+ run_id,
+ group_name=group_name,
+ status="running",
+ payload={
+ "platform": "bilibili",
+ "platform_user_id": str(uid),
+ "default_running": False,
+ },
+ )
+ return {"success": True, "msg": f"开始执行配置组: {group_name}", "run_id": run_id}
+
+ def group_finished(self, expected_run_id: str | None, keep_current: bool = False) -> dict:
+ """配置组运行完成。
+
+ keep_current=True 表示本次配置组 3 分钟内结束,当前队首不出队,保留二级权限继续执行。
+ 返回 {"accepted": bool, "need_default": bool, "finished_uid": int|None, "promoted_uid": int|None, "kept": bool}
+ """
+ current_run_id = self.state.get("current_group_run_id")
+ if not expected_run_id or current_run_id != expected_run_id:
+ return {
+ "accepted": False,
+ "reason": "stale_or_duplicate",
+ "expected_run_id": expected_run_id,
+ "current_run_id": current_run_id,
+ "need_default": False,
+ "finished_uid": None,
+ "promoted_uid": None,
+ "kept": False,
+ }
+ # 同步消费该实例;此方法内没有 await,可保证自然完成与 watchdog 只能有一个入口结算成功。
+ self.state["current_group_run_id"] = None
+ was_default_running = bool(self.state.get("default_running"))
+ current_admin_uid = self.state.get("current_admin_uid")
+ if was_default_running:
+ # 默认组可以和“待上号队首”并存。默认组完成只能收口自身,不能把刚入队的
+ # current_admin_uid 当成任务执行者,否则会在入队与停止默认组的竞态中误踢队首。
+ self.state["current_group"] = None
+ self.state["group_start_time"] = None
+ self.state["default_running"] = False
+ need_default = self.state.get("login_status") is None
+ self._save()
+ if self.stats_store:
+ self.stats_store.record_group_run(
+ current_run_id,
+ status="completed",
+ ended_at_utc=datetime.now(timezone.utc).isoformat(),
+ completed_count=1,
+ payload={"default_running": True},
+ )
+ return {
+ "accepted": True,
+ "need_default": need_default,
+ "finished_uid": None,
+ "promoted_uid": None,
+ "kept": False,
+ "was_default_running": True,
+ }
+
+ # current_group 可能属于已因积分耗尽而移出队列的旧用户;此时 current_admin_uid 已经是下一位。
+ # 用 billing_uid 作为本次配置组的真正完成用户,避免误把新队首出队。
+ finished_uid = self.state.get("billing_uid") or current_admin_uid
+ promoted_uid = None
+ finished_user_is_current_admin = bool(
+ finished_uid and finished_uid == current_admin_uid
+ )
+ self.state["current_group"] = None
+ self.state["group_start_time"] = None
+ # 只有完成者仍是当前队首时,才清理该队首的操作窗口。
+ # 若旧用户已因积分耗尽/主动退出提前出队,当前队首的120秒窗口属于新用户,必须保留。
+ if finished_user_is_current_admin or not current_admin_uid:
+ self.state["admin_window_end"] = None
+
+ if keep_current and finished_user_is_current_admin:
+ self.state["login_status"] = "logged_in"
+ self.state["has_user_finished_once"] = True
+ self._save()
+ if self.stats_store:
+ self.stats_store.record_group_run(
+ current_run_id,
+ status="completed",
+ ended_at_utc=datetime.now(timezone.utc).isoformat(),
+ completed_count=1,
+ payload={"finished_uid": finished_uid, "kept": True},
+ )
+ return {"accepted": True,
+ "need_default": False,
+ "finished_uid": finished_uid,
+ "promoted_uid": None,
+ "kept": True}
+
+ if finished_user_is_current_admin or not current_admin_uid:
+ self.state["login_status"] = None
+ self.state["login_started_at"] = None
+ self.state["confirm_started_at"] = None
+ self.state["billing_started_at"] = None
+ self.state["billing_last_at"] = None
+ self.state["billing_uid"] = None
+ # 配置组跑完超过3分钟,本次配置组所属用户这次机会用完,必须出队。
+ # 如果旧用户已因积分耗尽提前出队,current_admin_uid 可能已经是下一位,不能清掉下一位。
+ if finished_uid and finished_uid in self.state["queue"]:
+ self.state["queue"].remove(finished_uid)
+ if finished_uid:
+ self.state.setdefault("queue_joined_at", {}).pop(str(finished_uid), None)
+ if self.state.get("current_admin_uid") == finished_uid:
+ self.state["current_admin_uid"] = None
+ if self.state["queue"]:
+ promoted_uid = self.state["queue"][0]
+ self._set_admin(promoted_uid)
+ # 当前队首若不是完成用户,说明其此前已被提升并已经提醒过;本次没有新提升,不重复播报。
+
+ self.state["has_user_finished_once"] = True
+ need_default = len(self.state["queue"]) == 0
+ self._save()
+ if self.stats_store:
+ self.stats_store.record_group_run(
+ current_run_id,
+ status="completed",
+ ended_at_utc=datetime.now(timezone.utc).isoformat(),
+ completed_count=1,
+ payload={
+ "finished_uid": finished_uid,
+ "promoted_uid": promoted_uid,
+ "kept": False,
+ "was_default_running": False,
+ },
+ )
+ return {"accepted": True,
+ "need_default": need_default,
+ "finished_uid": finished_uid,
+ "promoted_uid": promoted_uid,
+ "kept": False,
+ "was_default_running": False}
+
+ def check_admin_window_timeout(self) -> dict:
+ """检查队首固定等待“上号”窗口。仅未开始登录时生效。"""
+ running_blocks_timeout = (
+ self.state["current_group"] is not None
+ and not self.state.get("default_running")
+ and self.state.get("billing_uid") in (None, self.state.get("current_admin_uid"))
+ )
+ if (
+ self.state["admin_window_end"] is None
+ or running_blocks_timeout
+ or self.state.get("login_status") is not None
+ ):
+ return {"timeout": False, "kicked_uid": None}
+ now = datetime.now().timestamp()
+ if now < self.state["admin_window_end"]:
+ return {"timeout": False, "kicked_uid": None}
+ # 超时,队首过号。若当前仍在运行前一位用户的配置组,必须保留旧任务和旧计费归属;
+ # 否则第一次过号会清空 billing_uid,下一位会被误判为当前配置组执行者而永久跳过超时检查。
+ kicked = self.state["current_admin_uid"]
+ login_session_id = self.state.get("login_session_id")
+ running_group = self.state.get("current_group")
+ running_group_run_id = self.state.get("current_group_run_id")
+ billing_uid = self.state.get("billing_uid")
+ preserve_previous_run = (
+ running_group is not None
+ and billing_uid is not None
+ and billing_uid != kicked
+ )
+ preserved_billing_started_at = self.state.get("billing_started_at")
+ preserved_billing_last_at = self.state.get("billing_last_at")
+ self.logger.info(f"[队列] {kicked} 90秒窗口超时, 过号")
+ if kicked and kicked in self.state["queue"]:
+ self.state["queue"].remove(kicked)
+ if kicked:
+ self.state.setdefault("queue_joined_at", {}).pop(str(kicked), None)
+ self.state["current_admin_uid"] = None
+ self.state["login_status"] = None
+ self.state["login_session_id"] = None
+ self.state["login_started_at"] = None
+ self.state["confirm_started_at"] = None
+ if not preserve_previous_run:
+ self.state["billing_started_at"] = None
+ self.state["billing_last_at"] = None
+ self.state["billing_uid"] = None
+ self._promote_next()
+ if preserve_previous_run:
+ # _set_admin() 会清空计费字段;提升下一位后恢复旧用户计费,使后续队首仍按各自120秒窗口过号。
+ self.state["current_group_run_id"] = running_group_run_id
+ self.state["billing_uid"] = billing_uid
+ self.state["billing_started_at"] = preserved_billing_started_at
+ self.state["billing_last_at"] = preserved_billing_last_at
+ self._save()
+ if self.stats_store and login_session_id:
+ self.stats_store.record_login_session(
+ login_session_id,
+ status="timeout",
+ ended_at_utc=datetime.now(timezone.utc).isoformat(),
+ metadata_json=json.dumps({"reason": "admin_window_timeout"}, ensure_ascii=False),
+ )
+ if self.stats_store and kicked:
+ self.stats_store.record_queue_event(
+ "main",
+ "timeout",
+ item_id=str(kicked),
+ position=1,
+ queue_size=len(self.state["queue"]),
+ payload={
+ "platform": "bilibili",
+ "platform_user_id": str(kicked),
+ "reason": "admin_window_timeout",
+ "promoted_uid": self.state.get("current_admin_uid"),
+ },
+ )
+ return {"timeout": True, "kicked_uid": kicked}
+
+ def get_queue_info(self) -> dict:
+ return {
+ "queue": list(self.state["queue"]),
+ "current_admin": self.state["current_admin_uid"],
+ "current_group": self.state["current_group"],
+ "admin_window_end": self.state["admin_window_end"],
+ "default_running": self.state["default_running"],
+ "login_status": self.state.get("login_status"),
+ "billing_started_at": self.state.get("billing_started_at"),
+ "billing_last_at": self.state.get("billing_last_at"),
+ }
+
+ def is_admin(self, uid: int) -> bool:
+ return uid == self.state["current_admin_uid"]
+
+
+# ============== TTS 引擎 ==============
+_TTS_DIGITS_CN = "零一二三四五六七八九"
+
+
+def _int_to_cn_for_tts(num: int) -> str:
+ """把较短整数转成普通中文读法,用于 TTS 文本预处理。"""
+ if num < 0:
+ return "减" + _int_to_cn_for_tts(abs(num))
+ if num < 10:
+ return _TTS_DIGITS_CN[num]
+ if num < 20:
+ return "十" + (_TTS_DIGITS_CN[num % 10] if num % 10 else "")
+ if num < 100:
+ tens, ones = divmod(num, 10)
+ return _TTS_DIGITS_CN[tens] + "十" + (_TTS_DIGITS_CN[ones] if ones else "")
+ if num < 1000:
+ hundreds, rest = divmod(num, 100)
+ if rest == 0:
+ return _TTS_DIGITS_CN[hundreds] + "百"
+ joiner = "零" if rest < 10 else ""
+ return _TTS_DIGITS_CN[hundreds] + "百" + joiner + _int_to_cn_for_tts(rest)
+ return str(num)
+
+
+def normalize_tts_text(text: str) -> str:
+ """仅用于语音播报的文本归一化,不影响弹幕原文。
+
+ 重点处理积分余额等场景里的负数,避免 TTS 把 -1 读成“负一”,改成“减一”。
+ """
+ if not text:
+ return text
+
+ def replace_negative(match: re.Match) -> str:
+ prefix = match.group(1) or ""
+ value = int(match.group(2))
+ return prefix + "减" + _int_to_cn_for_tts(value)
+
+ # 只处理前面不是英文、数字或小数点的负整数,避免误改路径/ID/版本号等内容。
+ return re.sub(r"(^|[^A-Za-z0-9.])-([0-9]+)(?=\D|$)", replace_negative, text)
+
+
+class _StreamingAudioBuffer:
+ """Thread-safe bridge between GPU chunk generation and the audio player."""
+
+ END = object()
+
+ def __init__(self):
+ self.chunks: thread_queue.Queue = thread_queue.Queue()
+ self.ready = threading.Event()
+ self.sample_rate = 24000
+ self.error: Exception | None = None
+ self.has_audio = False
+ self._finished = False
+ self._lock = threading.Lock()
+
+ def put(self, chunk, sample_rate: int) -> None:
+ with self._lock:
+ if self._finished:
+ return
+ self.sample_rate = int(sample_rate or 24000)
+ self.has_audio = True
+ self.ready.set()
+ self.chunks.put(chunk)
+
+ def finish(self, error: Exception | None = None) -> None:
+ with self._lock:
+ if self._finished:
+ return
+ self._finished = True
+ self.error = error
+ self.ready.set()
+ self.chunks.put(self.END)
+
+
+class _BoundedPriorityQueue:
+ """Small priority queue that replaces the least valuable pending item when full."""
+
+ def __init__(self, maxsize: int):
+ self.maxsize = max(1, int(maxsize))
+ self._heap: list[tuple[int, int, dict[str, Any]]] = []
+ self._condition = asyncio.Condition()
+ self._closed = False
+
+ def qsize(self) -> int:
+ return len(self._heap)
+
+ async def put(self, item: dict[str, Any]) -> tuple[bool, dict[str, Any] | None]:
+ entry = (int(item["priority"]), int(item["sequence"]), item)
+ async with self._condition:
+ if self._closed:
+ return False, None
+ dropped = None
+ if len(self._heap) >= self.maxsize:
+ worst_index = max(
+ range(len(self._heap)),
+ key=lambda index: (self._heap[index][0], self._heap[index][1]),
+ )
+ worst = self._heap[worst_index]
+ if entry[:2] >= worst[:2]:
+ return False, None
+ dropped = worst[2]
+ self._heap[worst_index] = entry
+ heapq.heapify(self._heap)
+ else:
+ heapq.heappush(self._heap, entry)
+ self._condition.notify()
+ return True, dropped
+
+ async def get(self) -> dict[str, Any] | None:
+ async with self._condition:
+ while not self._heap and not self._closed:
+ await self._condition.wait()
+ if not self._heap:
+ return None
+ return heapq.heappop(self._heap)[2]
+
+ async def close(self) -> list[dict[str, Any]]:
+ async with self._condition:
+ self._closed = True
+ pending = [entry[2] for entry in self._heap]
+ self._heap.clear()
+ self._condition.notify_all()
+ return pending
+
+
+class TTSEngine:
+ """TTS 抽象层。根据 config.tts_provider 选择具体引擎。
+ 支持: none / edge-tts / gptsovits / cosyvoice / volcengine / dots-tts / faster-qwen3-tts
+ 子引擎需实现 async def _synthesize(text) -> bytes (wav/mp3 二进制)"""
+
+ def __init__(self, config: Config, logger: logging.Logger,
+ stats_store: StatsStore | None = None):
+ self.config = config
+ self.logger = logger
+ self.stats_store = stats_store
+ self.provider = config.tts_provider
+ self.enabled = (self.provider != "none")
+ self._engine = None
+ self._player_lock = asyncio.Lock()
+ self._synthesis_lock = asyncio.Lock()
+ self._pygame = None
+ self._state_file = Path(config.data_dir) / "tts_state.json"
+ self._state = self._load_state()
+ self._state["enabled"] = self.enabled
+ self._state["provider"] = self.provider
+ self._save_state()
+ if not self.enabled:
+ return
+ try:
+ if self.provider == "edge-tts":
+ self._engine = EdgeTTSEngine(config, logger)
+ elif self.provider == "gptsovits":
+ self._engine = GPTSoVITSEngine(config, logger)
+ elif self.provider == "cosyvoice":
+ self._engine = CosyVoiceEngine(config, logger)
+ elif self.provider == "volcengine":
+ self._engine = VolcEngineTTS(config, logger)
+ elif self.provider == "dots-tts":
+ self._engine = DotsTTSEngine(config, logger, self)
+ elif self.provider == "faster-qwen3-tts":
+ self._engine = FasterQwen3TTSEngine(config, logger, self)
+ else:
+ self.logger.warning(f"[TTS] 未知provider '{self.provider}', TTS关闭")
+ self.enabled = False
+ except Exception as e:
+ self.logger.error(f"[TTS] 初始化失败({self.provider}): {e}")
+ self.enabled = False
+ self._state["enabled"] = self.enabled
+ self._state["provider"] = self.provider
+ self._save_state()
+
+ def _load_state(self) -> dict:
+ default = {
+ "enabled": False,
+ "provider": "none",
+ "model_loaded": False,
+ "model_name": "",
+ "last_text": "",
+ "last_duration_ms": 0,
+ "last_error": "",
+ "total_synthesized": 0,
+ "total_errors": 0,
+ "recent_events": [],
+ "updated_at": 0,
+ }
+ if not self._state_file.exists():
+ return default
+ try:
+ data = json.loads(self._state_file.read_text(encoding="utf-8"))
+ if isinstance(data, dict):
+ default.update(data)
+ except Exception:
+ pass
+ return default
+
+ def _save_state(self):
+ try:
+ self._state_file.parent.mkdir(parents=True, exist_ok=True)
+ self._state["updated_at"] = time.time()
+ tmp = self._state_file.with_suffix(".tmp")
+ tmp.write_text(json.dumps(self._state, ensure_ascii=False, indent=2), encoding="utf-8")
+ tmp.replace(self._state_file)
+ except Exception as e:
+ self.logger.debug(f"[TTS] 保存状态失败: {e}")
+
+ def _log_event(self, msg: str):
+ try:
+ now = time.strftime("%H:%M:%S", time.localtime())
+ events = self._state.setdefault("recent_events", [])
+ events.append({"time": now, "msg": msg})
+ if len(events) > 20:
+ events[:] = events[-20:]
+ self._save_state()
+ except Exception:
+ pass
+
+ def set_state(self, **kwargs):
+ self._state.update(kwargs)
+ self._save_state()
+
+ def create_request(self, text: str, *, parent_request_id: str | None = None,
+ source: str = "broadcast") -> dict[str, Any] | None:
+ if not self.enabled or not self._engine or not text:
+ return None
+ normalized = normalize_tts_text(text)
+ request = {
+ "request_id": uuid.uuid4().hex,
+ "text": normalized,
+ "started_at": time.time(),
+ "payload": {
+ "parent_broadcast_id": parent_request_id,
+ "source": source,
+ },
+ }
+ if self.stats_store:
+ self.stats_store.record_tts_request(
+ request["request_id"],
+ voice=self.provider,
+ text_length=len(normalized),
+ status="queued",
+ payload=request["payload"],
+ )
+ return request
+
+ async def warmup(self) -> bool:
+ if not self.enabled or not self._engine:
+ return False
+ warmup = getattr(self._engine, "warmup", None)
+ if not warmup:
+ return True
+ self._log_event("启动预热开始")
+ try:
+ async with self._synthesis_lock:
+ await warmup()
+ self._log_event("启动预热完成")
+ return True
+ except Exception as e:
+ self.logger.error(f"[TTS] 启动预热失败: {e}")
+ self._state["last_error"] = str(e)
+ self._save_state()
+ return False
+
+ async def restart_worker(self, reason: str = "scheduled") -> bool:
+ """重建底层引擎的 worker 进程(若引擎支持),消除长运行性能退化。"""
+ if not self.enabled or not self._engine:
+ return False
+ restart = getattr(self._engine, "restart_worker", None)
+ if not restart:
+ return False
+ self._log_event(f"worker 定时重建 ({reason})")
+ try:
+ async with self._synthesis_lock:
+ result = restart(reason=reason)
+ if asyncio.iscoroutine(result):
+ result = await result
+ self._log_event(f"worker 重建完成 ({reason})")
+ return bool(result)
+ except Exception as e:
+ self.logger.error(f"[TTS] worker 重建失败 ({reason}): {e}")
+ self._state["last_error"] = str(e)
+ self._save_state()
+ return False
+
+ async def prepare_request(self, request: dict[str, Any]) -> dict[str, Any]:
+ text = str(request["text"])
+ self._log_event(f"开始合成: {text[:30]}")
+ if getattr(self._engine, "streaming", False) and hasattr(self._engine, "_synthesize_to_buffer"):
+ buffer = _StreamingAudioBuffer()
+
+ async def _stream_with_lock():
+ async with self._synthesis_lock:
+ await self._engine._synthesize_to_buffer(text, buffer)
+
+ synth_task = asyncio.create_task(
+ _stream_with_lock(),
+ name=f"tts-stream-{request['request_id'][:8]}",
+ )
+ await asyncio.to_thread(buffer.ready.wait)
+ if buffer.error and not buffer.has_audio:
+ await asyncio.gather(synth_task, return_exceptions=True)
+ raise buffer.error
+ return {"kind": "stream", "buffer": buffer, "synth_task": synth_task}
+
+ async with self._synthesis_lock:
+ audio = await self._engine._synthesize(text)
+ if not audio:
+ raise RuntimeError("合成返回空音频")
+ return {"kind": "audio", "audio": audio}
+
+ def mark_playing(self, request: dict[str, Any]) -> None:
+ if self.stats_store:
+ self.stats_store.record_tts_request(
+ request["request_id"],
+ voice=self.provider,
+ text_length=len(request["text"]),
+ status="playing",
+ payload=request["payload"],
+ )
+
+ async def play_prepared(self, prepared: dict[str, Any]) -> bool:
+ if prepared["kind"] == "stream":
+ await self._play_stream_buffer(prepared["buffer"])
+ await prepared["synth_task"]
+ return True
+ return await self._play_audio(prepared["audio"])
+
+ def complete_request(self, request: dict[str, Any]) -> None:
+ duration_ms = int((time.time() - request["started_at"]) * 1000)
+ text = str(request["text"])
+ self._state["total_synthesized"] = self._state.get("total_synthesized", 0) + 1
+ self._state["last_text"] = text
+ self._state["last_duration_ms"] = duration_ms
+ self._state["last_error"] = ""
+ self._save_state()
+ label = "流式播放完成" if getattr(self._engine, "streaming", False) else "播放完成"
+ self._log_event(f"{label} ({duration_ms}ms): {text[:30]}")
+ if self.stats_store:
+ self.stats_store.record_tts_request(
+ request["request_id"],
+ completed_at_utc=datetime.now(timezone.utc).isoformat(),
+ voice=self.provider,
+ text_length=len(text),
+ status="success",
+ duration_ms=duration_ms,
+ result_code="ok",
+ payload=request["payload"],
+ )
+
+ def fail_request(self, request: dict[str, Any], error: Exception | str,
+ *, result_code: str | None = None, cancelled: bool = False) -> None:
+ duration_ms = int((time.time() - request["started_at"]) * 1000)
+ err = str(error)
+ if "device-side assert" in err:
+ err = "CUDA device-side assert:当前 Python 进程的 CUDA 上下文已损坏,必须关闭 DanmuQueue 后重新启动;单纯刷新后台无效。原始错误: " + err
+ if not cancelled:
+ self._state["total_errors"] = self._state.get("total_errors", 0) + 1
+ self._state["last_error"] = err
+ self._save_state()
+ self._log_event(f"异常: {err[:80]}")
+ if self.stats_store:
+ self.stats_store.record_tts_request(
+ request["request_id"],
+ completed_at_utc=datetime.now(timezone.utc).isoformat(),
+ voice=self.provider,
+ text_length=len(request["text"]),
+ status="cancelled" if cancelled else "failed",
+ duration_ms=duration_ms,
+ result_code=result_code or ("cancelled" if cancelled else type(error).__name__),
+ payload=request["payload"],
+ )
+
+ async def speak(self, text: str, *, parent_request_id: str | None = None,
+ source: str = "broadcast") -> bool:
+ """Direct compatibility path; Broadcaster normally uses the pipelined API."""
+ request = self.create_request(text, parent_request_id=parent_request_id, source=source)
+ if not request:
+ return False
+ try:
+ prepared = await self.prepare_request(request)
+ async with self._player_lock:
+ self.mark_playing(request)
+ await self.play_prepared(prepared)
+ self.complete_request(request)
+ return True
+ except asyncio.CancelledError:
+ self.fail_request(request, "cancelled", cancelled=True)
+ raise
+ except Exception as e:
+ self.logger.error(f"[TTS] 播放失败: {e}")
+ self.fail_request(request, e)
+ return False
+
+ async def close(self) -> None:
+ close = getattr(self._engine, "close", None)
+ try:
+ if close:
+ result = close()
+ if asyncio.iscoroutine(result):
+ await result
+ finally:
+ try:
+ if self._pygame is not None and self._pygame.mixer.get_init():
+ self._pygame.mixer.quit()
+ except Exception:
+ pass
+ self._pygame = None
+
+ async def _play_stream_buffer(self, buffer: _StreamingAudioBuffer) -> bool:
+ def _play() -> None:
+ import sounddevice as sd
+
+ stream = None
+ try:
+ while True:
+ chunk = buffer.chunks.get()
+ if chunk is _StreamingAudioBuffer.END:
+ break
+ if stream is None:
+ stream = sd.OutputStream(
+ samplerate=buffer.sample_rate,
+ channels=1,
+ dtype="float32",
+ )
+ stream.start()
+ self.logger.info(f"[TTS] 首块到达,开始流式播放 (sr={buffer.sample_rate})")
+ stream.write(chunk.reshape(-1, 1))
+ finally:
+ if stream is not None:
+ stream.stop()
+ stream.close()
+ if buffer.error:
+ raise buffer.error
+ if not buffer.has_audio:
+ raise RuntimeError("流式合成返回空音频")
+
+ await asyncio.to_thread(_play)
+ return True
+
+ async def _play_audio(self, audio: bytes) -> bool:
+ """播放音频到系统默认输出设备,成功返回 True,失败抛出异常。"""
+ try:
+ import io
+ import pygame
+ if self._pygame is None:
+ self._pygame = pygame
+ if not self._pygame.mixer.get_init():
+ self._pygame.mixer.init()
+ channel = self._pygame.mixer.Sound(file=io.BytesIO(audio)).play()
+ if channel is None:
+ raise RuntimeError("pygame mixer 无可用播放通道")
+ while channel.get_busy():
+ await asyncio.sleep(0.1)
+ return True
+ except ImportError:
+ pass
+ proc = await asyncio.create_subprocess_exec(
+ "ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet",
+ "-i", "pipe:0",
+ stdin=subprocess.PIPE,
+ )
+ proc.stdin.write(audio)
+ await proc.stdin.drain()
+ proc.stdin.close()
+ return_code = await proc.wait()
+ if return_code != 0:
+ raise RuntimeError(f"ffplay退出码异常: {return_code}")
+ return True
+
+
+class EdgeTTSEngine:
+ """免费占位引擎: 微软Edge TTS。不支持自定义音色。"""
+ def __init__(self, config: Config, logger: logging.Logger):
+ self.cfg = config.tts_cfg.get("edge-tts", {})
+ self.voice = self.cfg.get("voice", "zh-CN-XiaoxiaoNeural")
+ self.rate = self.cfg.get("rate", "+0%")
+ self.volume = self.cfg.get("volume", "+0%")
+ self.logger = logger
+ try:
+ import edge_tts # type: ignore
+ self._mod = edge_tts
+ except ImportError:
+ raise ImportError("未安装 edge-tts, 请运行: pip install edge-tts")
+
+ async def _synthesize(self, text: str) -> bytes:
+ import io
+ buf = io.BytesIO()
+ communicate = self._mod.Communicate(
+ text, self.voice, rate=self.rate, volume=self.volume
+ )
+ async for chunk in communicate.stream():
+ if chunk["type"] == "audio":
+ buf.write(chunk["data"])
+ buf.seek(0)
+ return buf.read()
+
+
+class GPTSoVITSEngine:
+ """本地 GPT-SoVITS 引擎。需先启动 GPT-SoVITS API 服务(默认9880端口)。"""
+ def __init__(self, config: Config, logger: logging.Logger):
+ self.cfg = config.tts_cfg.get("gptsovits", {})
+ self.api_url = self.cfg.get("api_url", "http://127.0.0.1:9880").rstrip("/")
+ self.character = self.cfg.get("character", "default")
+ self.emotion = self.cfg.get("emotion", "happy")
+ self.logger = logger
+
+ async def _synthesize(self, text: str) -> bytes:
+ url = f"{self.api_url}/tts"
+ params = {
+ "text": text,
+ "character": self.character,
+ "emotion": self.emotion,
+ "text_lang": "zh",
+ }
+ async with aiohttp.ClientSession() as sess:
+ async with sess.post(url, json=params, timeout=30) as resp:
+ if resp.status != 200:
+ self.logger.error(f"[TTS] GPT-SoVITS HTTP {resp.status}: {await resp.text()}")
+ return b""
+ return await resp.read()
+
+
+class CosyVoiceEngine:
+ """阿里云 CosyVoice (DashScope)。需DashScope API key + 预置/克隆音色ID。"""
+ def __init__(self, config: Config, logger: logging.Logger):
+ self.cfg = config.tts_cfg.get("cosyvoice", {})
+ self.api_key = self.cfg.get("api_key", "")
+ self.voice = self.cfg.get("voice", "longxiaochun")
+ self.model = self.cfg.get("model", "cosyvoice-v1")
+ self.logger = logger
+ if not self.api_key:
+ raise ValueError("CosyVoice 缺少 api_key")
+
+ async def _synthesize(self, text: str) -> bytes:
+ # DashScope WebSocket TTS 协议
+ url = f"wss://dashscope.aliyuncs.com/api-ws/v1/inference/"
+ headers = {
+ "Authorization": f"bearer {self.api_key}",
+ "X-DashScope-DataInspection": "enable",
+ }
+ payload = {
+ "model": self.model,
+ "input": {"text": text},
+ "parameters": {"voice": self.voice, "format": "mp3"},
+ }
+ audio_chunks = []
+ try:
+ async with websockets.connect(url, additional_headers=headers) as ws:
+ await ws.send(json.dumps({
+ "action": "run-task",
+ "header": {"event_id": "tts-1", "action": "run-task"},
+ "payload": payload,
+ "payload": {"task_group": "audio", "task": "tts", "function": "SpeechSynthesizer", "model": self.model},
+ "input": {"text": text},
+ "parameters": {"voice": self.voice, "format": "mp3", "sample_rate": 16000},
+ }))
+ async for msg in ws:
+ data = json.loads(msg)
+ if data.get("header", {}).get("event") == "task-finished":
+ break
+ if data.get("header", {}).get("event") == "result-generated":
+ audio = data.get("output", {}).get("audio", "")
+ if audio:
+ import base64
+ audio_chunks.append(base64.b64decode(audio))
+ except Exception as e:
+ self.logger.error(f"[TTS] CosyVoice 合成失败: {e}")
+ return b""
+ return b"".join(audio_chunks)
+
+
+class VolcEngineTTS:
+ """火山引擎声音复刻。需 app_id + access_token + voice_type。"""
+ def __init__(self, config: Config, logger: logging.Logger):
+ self.cfg = config.tts_cfg.get("volcengine", {})
+ self.app_id = self.cfg.get("app_id", "")
+ self.access_token = self.cfg.get("access_token", "")
+ self.voice_type = self.cfg.get("voice_type", "BV001_streaming")
+ self.logger = logger
+ if not self.app_id or not self.access_token:
+ raise ValueError("火山TTS缺少 app_id 或 access_token")
+
+ async def _synthesize(self, text: str) -> bytes:
+ # 火山引擎 TTS HTTP API
+ url = "https://openspeech.bytedance.com/api/v1/tts"
+ payload = {
+ "app": {"appid": self.app_id, "token": self.access_token, "cluster": "volcano_tts"},
+ "user": {"uid": "bgi_live"},
+ "audio": {
+ "voice_type": self.voice_type,
+ "encoding": "mp3",
+ "speed_ratio": 1.0,
+ "volume_ratio": 1.0,
+ "pitch_ratio": 1.0,
+ },
+ "request": {
+ "reqid": str(int(time.time() * 1000)),
+ "text": text,
+ "text_type": "plain",
+ "operation": "query",
+ },
+ }
+ headers = {"Content-Type": "application/json"}
+ async with aiohttp.ClientSession() as sess:
+ async with sess.post(url, json=payload, headers=headers, timeout=15) as resp:
+ result = await resp.json()
+ if result.get("code") != 3000:
+ self.logger.error(f"[TTS] 火山引擎错误: {result}")
+ return b""
+ import base64
+ audio = result.get("data", "")
+ return base64.b64decode(audio) if audio else b""
+
+
+
+
+class FasterQwen3TTSEngine:
+ """faster-qwen3-tts 引擎,使用 Base 模型 + 语音克隆。
+
+ pip install faster-qwen3-tts
+ API: https://github.com/andimarafioti/faster-qwen3-tts
+
+ 模型与 CUDA 上下文运行在独立 worker 进程中。主进程负责超时熔断与自动重启,
+ 避免异常长合成拖慢音乐播放和其他直播业务。
+ """
+
+ def __init__(self, config: Config, logger: logging.Logger, parent: "TTSEngine | None" = None):
+ self.cfg = config.tts_cfg.get("faster-qwen3-tts", {})
+ self.model_name = self.cfg.get("model_name_or_path", "Qwen/Qwen3-TTS-12Hz-0.6B-Base")
+ self.language = str(self.cfg.get("language", "Chinese"))
+ self.ref_audio = str(self.cfg.get("ref_audio", "") or "")
+ self.ref_text = str(self.cfg.get("ref_text", "") or "")
+ self.xvec_only = bool(self.cfg.get("xvec_only", True))
+ self.non_streaming_mode = bool(self.cfg.get("non_streaming_mode", True))
+ self.append_silence = bool(self.cfg.get("append_silence", True))
+ self.streaming = False
+ self.device = str(self.cfg.get("device", "cuda"))
+ self.cpu_threads = max(1, min(8, int(self.cfg.get("cpu_threads", 4) or 4)))
+ self.cpu_affinity_count = max(0, int(self.cfg.get("cpu_affinity_count", 8) or 0))
+ self.process_priority = str(self.cfg.get("process_priority", "below_normal") or "below_normal")
+ self.logger = logger
+ self._parent = parent
+ self._worker_ready = False
+ self._warmed_up = False
+ model_path = project_path(self.model_name)
+ resolved_model_name = str(model_path) if model_path.exists() else self.model_name
+ resolved_ref_audio = str(project_path(self.ref_audio)) if self.ref_audio else ""
+ self._worker = FasterQwenWorkerClient(
+ {
+ "model_name_or_path": resolved_model_name,
+ "language": self.language,
+ "ref_audio": resolved_ref_audio,
+ "ref_text": self.ref_text,
+ "xvec_only": self.xvec_only,
+ "non_streaming_mode": self.non_streaming_mode,
+ "append_silence": self.append_silence,
+ "device": self.device,
+ "cpu_threads": self.cpu_threads,
+ "cpu_affinity_count": self.cpu_affinity_count,
+ "process_priority": self.process_priority,
+ },
+ logger,
+ synthesis_timeout_seconds=120,
+ )
+ if bool(self.cfg.get("streaming", False)):
+ self.logger.warning("[FasterQwenTTS] 独立 worker 仅使用非流式模式,已忽略 streaming=true")
+
+ def _update_state(self, **kwargs):
+ if self._parent:
+ self._parent.set_state(**kwargs)
+
+ async def _ensure_model(self):
+ if self._worker_ready and self._worker.worker_pid:
+ return
+ self._update_state(model_loaded=False, model_name=self.model_name, last_error="")
+ dev_label = "CPU" if self.device == "cpu" else "GPU"
+ self.logger.info(f"[FasterQwenTTS] 启动独立 worker: {self.model_name} ({dev_label})")
+ try:
+ await asyncio.to_thread(self._worker.ensure_ready)
+ self._worker_ready = True
+ self._update_state(model_loaded=True)
+ clone = "xvec_only" if self.xvec_only else "ICL"
+ self.logger.info(
+ f"[FasterQwenTTS] 独立 worker 就绪, pid={self._worker.worker_pid}, "
+ f"{dev_label} + {clone}, max_new_tokens=384, "
+ f"cpu_threads={self.cpu_threads}, affinity={self.cpu_affinity_count}, "
+ f"priority={self.process_priority}"
+ )
+ except Exception as e:
+ self._worker_ready = False
+ self._update_state(model_loaded=False, last_error=str(e))
+ raise
+
+ async def _precompute_prompt(self):
+ await self._ensure_model()
+
+ async def warmup(self):
+ if self._warmed_up:
+ return
+ self.logger.info("[FasterQwenTTS] 启动独立 worker、加载模型并预热...")
+ started = time.time()
+ await self._ensure_model()
+ self._warmed_up = True
+ self.logger.info(f"[FasterQwenTTS] 启动预热完成,耗时 {int((time.time() - started) * 1000)} ms")
+
+ def _check_ref_audio(self) -> bool:
+ if not self.ref_audio:
+ err = "Faster-Qwen3-TTS (Base) 需要 ref_audio 参考音频;请先在 config.json 中配置"
+ self.logger.warning(f"[FasterQwenTTS] {err}")
+ self._update_state(last_error=err)
+ return False
+ return True
+
+ async def _synthesize(self, text: str) -> bytes:
+ """非流式合成,返回完整 WAV bytes。"""
+ await self._ensure_model()
+ safe_text = text.strip()[:80]
+ if not safe_text:
+ safe_text = "欢迎来到直播间。"
+ self._update_state(last_text=safe_text)
+ if not self._check_ref_audio():
+ raise RuntimeError("缺少 ref_audio")
+
+ try:
+ audio_bytes, metadata = await asyncio.to_thread(self._worker.synthesize, safe_text)
+ except Exception as exc:
+ self._worker_ready = bool(self._worker.worker_pid)
+ self._update_state(model_loaded=self._worker_ready, last_error=str(exc))
+ raise
+ self._worker_ready = True
+ self._update_state(model_loaded=True, last_error="")
+ self.logger.info(
+ f"[FasterQwenTTS] 完成: {len(safe_text)}字 -> {len(audio_bytes)} bytes, "
+ f"worker={metadata.get('duration_ms')}ms, max_new_tokens=384"
+ )
+ return audio_bytes
+
+ async def _synthesize_to_buffer(self, text: str, buffer: _StreamingAudioBuffer) -> None:
+ try:
+ import io
+ import numpy as np
+ import soundfile as sf
+
+ audio = await self._synthesize(text)
+ samples, sample_rate = await asyncio.to_thread(
+ sf.read,
+ io.BytesIO(audio),
+ dtype="float32",
+ )
+ buffer.put(np.asarray(samples, dtype=np.float32).reshape(-1), sample_rate)
+ buffer.finish()
+ except Exception as e:
+ buffer.finish(e)
+ raise
+
+ async def close(self):
+ await asyncio.to_thread(self._worker.close)
+ self._worker_ready = False
+ self._warmed_up = False
+ self._update_state(model_loaded=False)
+
+ async def restart_worker(self, reason: str = "scheduled") -> bool:
+ """关闭并重建 worker 进程,消除长时间运行后的合成性能退化。"""
+ started = time.time()
+ self.logger.info(f"[FasterQwenTTS] 重建 worker ({reason})...")
+ await self.close()
+ await self.warmup()
+ elapsed = int(time.time() - started)
+ if self._worker_ready:
+ self.logger.info(f"[FasterQwenTTS] worker 重建完成 ({reason}),耗时 {elapsed}s")
+ else:
+ self.logger.error(f"[FasterQwenTTS] worker 重建失败 ({reason}),耗时 {elapsed}s")
+ return self._worker_ready
+
+
+
+
+class DotsTTSEngine:
+ """dots.tts 本地 GPU 引擎(小红书开源, 2B 全连续自回归 TTS)。
+
+ GitHub: https://github.com/rednote-hilab/dots.tts
+
+ 推荐使用 MF(MeanFlow 蒸馏)模型,4 步推理,RTX 2080 8GB 可用。
+
+ 配置示例 (config.json):
+ "dots-tts": {
+ "model_name_or_path": "rednote-hilab/dots.tts-mf",
+ "device": "cuda",
+ "optimize": false,
+ "num_steps": 4,
+ "language": "chinese"
+ }
+ """
+
+ def __init__(self, config: Config, logger: logging.Logger, parent: "TTSEngine | None" = None):
+ self.cfg = config.tts_cfg.get("dots-tts", {})
+ self.model_name = self.cfg.get("model_name_or_path", "rednote-hilab/dots.tts-mf")
+ self.device = self.cfg.get("device", "auto") # dots.tts 当前运行时会自动选择 cuda/cpu
+ self.precision = str(self.cfg.get("precision", "float16")) # RTX 2080 不支持 bfloat16;优先使用 float16,源码补丁处理 Float/Half 混用
+ self.optimize = bool(self.cfg.get("optimize", False))
+ self.max_generate_length = int(self.cfg.get("max_generate_length", 500))
+ self.num_steps = int(self.cfg.get("num_steps", 4))
+ self.language = str(self.cfg.get("language", "chinese"))
+ self.logger = logger
+ self._parent = parent
+ self._model = None # 懒加载, 首次合成时初始化
+
+ def _update_state(self, **kwargs):
+ if self._parent:
+ self._parent.set_state(**kwargs)
+
+ async def _ensure_model(self):
+ if self._model is not None:
+ return
+ self._update_state(model_loaded=False, model_name=self.model_name, last_error="")
+ self.logger.info(f"[DotsTTS] 正在加载模型: {self.model_name}")
+ self._update_state(last_text=f"[系统] 正在加载模型 {self.model_name}...")
+ try:
+ from dots_tts.runtime import DotsTtsRuntime
+
+ def _load(precision, optimize):
+ return DotsTtsRuntime.from_pretrained(
+ self.model_name,
+ precision=precision,
+ optimize=optimize,
+ max_generate_length=self.max_generate_length,
+ )
+
+ # 模型加载是重度同步操作,放到线程池避免阻塞事件循环
+ self._model = await asyncio.to_thread(_load, self.precision, self.optimize)
+ self._update_state(model_loaded=True, model_name=self.model_name, last_error="")
+ runtime_device = getattr(self._model, "device", "auto")
+ self.logger.info(f"[DotsTTS] 模型加载完成, device={runtime_device}, precision={self.precision}")
+ except ImportError:
+ raise ImportError(
+ "dots.tts 未安装。安装方式:\n"
+ " pip install -e /path/to/dots.tts"
+ )
+ except Exception as e:
+ msg = str(e).lower()
+ if "out of memory" in msg or "cuda" in msg:
+ self.logger.warning(f"[DotsTTS] GPU 显存不足({e}), 回退到 CPU")
+ self._model = await asyncio.to_thread(_load, "float32", False)
+ self._update_state(model_loaded=True, model_name=self.model_name, last_error="")
+ else:
+ self._update_state(model_loaded=False, last_error=str(e))
+ raise
+
+ async def _synthesize(self, text: str) -> bytes:
+ await self._ensure_model()
+ self._update_state(last_text=text)
+
+ # generate() 是重度同步 CPU/GPU 操作,放到线程池避免阻塞事件循环
+ def _do_generate():
+ return self._model.generate(
+ text=text,
+ prompt_audio_path=None,
+ prompt_text=None,
+ language=self.language,
+ template_name="tts",
+ num_steps=self.num_steps,
+ )
+
+ result = await asyncio.to_thread(_do_generate)
+
+ def _extract_audio():
+ import torch
+ audio = result["audio"].float().cpu().squeeze().numpy()
+ sample_rate = result["sample_rate"]
+ import io
+ import soundfile as sf
+ buf = io.BytesIO()
+ sf.write(buf, audio, sample_rate, format="WAV")
+ buf.seek(0)
+ return buf.read()
+
+ audio_bytes = await asyncio.to_thread(_extract_audio)
+ self.logger.info(f"[DotsTTS] 合成完成: {len(text)}字 -> {len(audio_bytes)} bytes")
+ return audio_bytes
+
+
+# ============== 直播播报器 ==============
+class Broadcaster:
+ """统一播报: B站发弹幕 + TTS语音。
+ - 弹幕发送走队列, 间隔 danmu_interval_sec 秒(B站风控约5秒1条)。
+ - TTS 使用有界优先队列,合成与播放分别由单一 worker 串行处理。
+ - broadcast(text, tts=True) 同时发弹幕+TTS。"""
+
+ def __init__(self, config: Config, logger: logging.Logger,
+ stats_store: StatsStore | None = None):
+ self.config = config
+ self.logger = logger
+ self.stats_store = stats_store
+ self.room_id = config.room_id
+ self.sessdata = config.sessdata
+ self.bili_jct = config.bili_jct
+ self.enable_danmu = (config.enable_danmu_reply or config.broadcast_cfg.get("enable_system_danmu", True)) and bool(self.bili_jct)
+ self.enable_tts = config.enable_tts
+ self.danmu_interval = config.danmu_interval_sec
+ self._danmu_queue: asyncio.Queue = asyncio.Queue()
+ tts_queue_cfg = config.broadcast_cfg.get("tts_queue", {})
+ self._tts_queue_cfg = tts_queue_cfg
+ self._tts_pending = _BoundedPriorityQueue(int(tts_queue_cfg.get("max_pending", 8)))
+ self._tts_playback_queue: asyncio.Queue = asyncio.Queue(
+ maxsize=max(1, int(tts_queue_cfg.get("playback_max_pending", 2)))
+ )
+ self._tts_worker_tasks: set[asyncio.Task] = set()
+ self._tts_sequence = 0
+ self._tts_started = False
+ self._tts_warmup_done = asyncio.Event()
+ self._broadcast_channels: dict[str, dict[str, str]] = {}
+ self._stop = False
+ # 弹幕会话已登录标志: 启动时ping一下 nav API, -101 表示 SESSDATA 失效, 关闭弹幕发送
+ self._login_ok = False
+ # TTS 引擎
+ self.tts = TTSEngine(config, logger, stats_store=stats_store)
+ if self.enable_danmu:
+ self.logger.info(f"[播报] 弹幕发送已启用 (间隔{self.danmu_interval}秒)")
+ elif (config.enable_danmu_reply or config.broadcast_cfg.get("enable_system_danmu", True)) and not self.bili_jct:
+ self.logger.warning("[播报] 弹幕发送未启用: 缺少 bili_jct。需要在 config.json 的 bilibili.bili_jct 填入浏览器 Cookie 里的 bili_jct;仅有 SESSDATA 不能调用发弹幕接口")
+ if self.tts.enabled:
+ self.logger.info(f"[播报] TTS已启用 ({self.tts.provider})")
+ else:
+ self.logger.info("[播报] TTS未启用")
+
+ def reload_config(self):
+ old_tts = self.tts
+ old_tts_provider = getattr(old_tts, "provider", "none") if old_tts else "none"
+ old_tts_enabled = bool(getattr(old_tts, "enabled", False)) if old_tts else False
+ self.room_id = self.config.room_id
+ self.sessdata = self.config.sessdata
+ self.bili_jct = self.config.bili_jct
+ self.enable_danmu = (self.config.enable_danmu_reply or self.config.broadcast_cfg.get("enable_system_danmu", True)) and bool(self.bili_jct)
+ self._login_ok = False
+ self.enable_tts = self.config.enable_tts
+ self.danmu_interval = self.config.danmu_interval_sec
+ if old_tts_provider != self.config.tts_provider or old_tts_enabled != self.enable_tts:
+ self.tts = TTSEngine(
+ self.config,
+ self.logger,
+ stats_store=self.stats_store,
+ )
+ if old_tts:
+ try:
+ asyncio.get_running_loop().create_task(
+ old_tts.close(),
+ name="关闭旧TTS引擎",
+ )
+ except RuntimeError:
+ self.logger.warning("[TTS] 配置热更新时无法调度旧 worker 关闭")
+ self.logger.info(f"[配置] 播报配置已热更新: danmu={self.enable_danmu}, tts={self.config.tts_provider}")
+
+ async def start(self):
+ if self._tts_started:
+ await self._tts_warmup_done.wait()
+ return
+ self._tts_started = True
+ if not self.tts.enabled:
+ self._tts_warmup_done.set()
+ return
+
+ synth_task = asyncio.create_task(self._tts_synthesis_loop(), name="TTS合成流水线")
+ play_task = asyncio.create_task(self._tts_playback_loop(), name="TTS播放流水线")
+ self._tts_worker_tasks.update((synth_task, play_task))
+ for task in (synth_task, play_task):
+ task.add_done_callback(self._tts_worker_tasks.discard)
+
+ try:
+ if bool(self._tts_queue_cfg.get("warmup_on_start", True)):
+ await self.tts.warmup()
+ finally:
+ self._tts_warmup_done.set()
+ self.logger.info(
+ f"[TTS队列] 流水线启动, pending={self._tts_pending.maxsize}, "
+ f"playback={self._tts_playback_queue.maxsize}"
+ )
+
+ def _tts_policy(self, category: str | None) -> tuple[int, float]:
+ category = str(category or "default")
+ if category in {"login", "reset"}:
+ return 0, float(self._tts_queue_cfg.get("urgent_max_age_sec", 60.0))
+ if category in {"execution", "system"}:
+ return 1, float(self._tts_queue_cfg.get("max_age_sec", 40.0))
+ if category in {"queue", "gift", "default"}:
+ return 2, float(self._tts_queue_cfg.get("max_age_sec", 40.0))
+ return 3, float(self._tts_queue_cfg.get("low_priority_max_age_sec", 30.0))
+
+ def _finish_tts_job(self, job: dict[str, Any], success: bool, result_code: str) -> None:
+ self._complete_broadcast_channel(
+ str(job.get("broadcast_request_id") or ""),
+ "tts",
+ "success" if success else "failed",
+ result_code,
+ )
+
+ def _drop_tts_job(self, job: dict[str, Any], result_code: str) -> None:
+ request = job.get("tts_request")
+ if request:
+ self.tts.fail_request(request, result_code, result_code=result_code, cancelled=True)
+ self.logger.info(
+ f"[TTS队列] 丢弃 {result_code}: priority={job.get('priority')} "
+ f"category={job.get('category')} text={str(job.get('text') or '')[:30]}"
+ )
+ self._finish_tts_job(job, False, result_code)
+
+ async def _tts_synthesis_loop(self):
+ await self._tts_warmup_done.wait()
+ self.logger.info("[TTS队列] 合成 worker 启动")
+ while not self._stop:
+ job = await self._tts_pending.get()
+ if job is None:
+ break
+ request = job["tts_request"]
+ if time.monotonic() >= float(job["expires_at"]):
+ self._drop_tts_job(job, "expired_before_synthesis")
+ continue
+ prepared = None
+ try:
+ prepared = await self.tts.prepare_request(request)
+ await self._tts_playback_queue.put({"job": job, "prepared": prepared})
+ if prepared["kind"] == "stream":
+ try:
+ await prepared["synth_task"]
+ except Exception as e:
+ self.logger.warning(f"[TTS队列] 流式生成异常,交由播放 worker 收口: {e}")
+ except asyncio.CancelledError:
+ if prepared is None:
+ self.tts.fail_request(request, "cancelled", cancelled=True)
+ self._finish_tts_job(job, False, "cancelled")
+ raise
+ except Exception as e:
+ self.logger.error(f"[TTS] 合成失败: {e}")
+ self.tts.fail_request(request, e, result_code=type(e).__name__)
+ self._finish_tts_job(job, False, "tts_synthesis_failed")
+
+ async def _tts_playback_loop(self):
+ await self._tts_warmup_done.wait()
+ self.logger.info("[TTS队列] 播放 worker 启动")
+ while not self._stop:
+ item = await self._tts_playback_queue.get()
+ if item is None:
+ break
+ job = item["job"]
+ request = job["tts_request"]
+ try:
+ if time.monotonic() >= float(job["expires_at"]):
+ self._drop_tts_job(job, "expired_before_playback")
+ continue
+ self.tts.mark_playing(request)
+ await self.tts.play_prepared(item["prepared"])
+ self.tts.complete_request(request)
+ self._finish_tts_job(job, True, "ok")
+ except asyncio.CancelledError:
+ self.tts.fail_request(request, "cancelled", cancelled=True)
+ self._finish_tts_job(job, False, "cancelled")
+ raise
+ except Exception as e:
+ self.logger.error(f"[TTS] 播放失败: {e}")
+ self.tts.fail_request(request, e, result_code=type(e).__name__)
+ self._finish_tts_job(job, False, "tts_playback_failed")
+
+ async def _check_login(self):
+ """启动时ping nav API, SESSDATA失效时关闭弹幕发送避免刷 -101 错误"""
+ if not self.enable_danmu:
+ return
+ try:
+ req = urllib.request.Request("https://api.bilibili.com/x/web-interface/nav")
+ req.add_header("User-Agent", "Mozilla/5.0")
+ req.add_header("Cookie", f"SESSDATA={self.sessdata}; bili_jct={self.bili_jct}")
+ data = json.loads(urllib.request.urlopen(req, timeout=5).read())
+ if data.get("code") == 0:
+ self._login_ok = True
+ self.logger.info(f"[播报] SESSDATA有效, 账号={data['data'].get('uname')}, 可发弹幕")
+ else:
+ self._login_ok = False
+ self.enable_danmu = False
+ self.logger.warning(
+ f"[播报] SESSDATA失效 (code={data.get('code')}), 关闭弹幕发送, "
+ f"仅保留TTS播报。请重新从浏览器获取SESSDATA和bili_jct填入config.json"
+ )
+ except Exception as e:
+ self.logger.warning(f"[播报] 检测登录状态失败: {e}")
+
+ async def run(self):
+ """后台消费弹幕队列。"""
+ if not self._tts_started:
+ await self.start()
+ self.logger.info("[播报] 弹幕发送循环启动")
+ while not self._stop:
+ try:
+ if not self.enable_danmu:
+ await asyncio.sleep(2)
+ continue
+ if not self._login_ok:
+ await self._check_login()
+ if not self.enable_danmu:
+ continue
+ item = await self._danmu_queue.get()
+ if item is None:
+ break
+ result = await self._send_danmu(str(item.get("text") or ""))
+ self._complete_broadcast_channel(
+ str(item.get("request_id") or ""),
+ "danmu",
+ "success" if result.get("success") else "failed",
+ str(result.get("code") or "unknown"),
+ )
+ await asyncio.sleep(self.danmu_interval)
+ except asyncio.CancelledError:
+ break
+ except Exception as e:
+ self.logger.error(f"[播报] 弹幕发送异常: {e}")
+ await asyncio.sleep(self.danmu_interval)
+
+ def stop(self):
+ self._stop = True
+
+ async def close(self):
+ self.stop()
+ while not self._danmu_queue.empty():
+ try:
+ item = self._danmu_queue.get_nowait()
+ except asyncio.QueueEmpty:
+ break
+ if isinstance(item, dict):
+ self._complete_broadcast_channel(
+ str(item.get("request_id") or ""),
+ "danmu",
+ "failed",
+ "cancelled",
+ )
+ pending_tts = await self._tts_pending.close()
+ for job in pending_tts:
+ self._drop_tts_job(job, "shutdown")
+ tasks = list(self._tts_worker_tasks)
+ for task in tasks:
+ task.cancel()
+ if tasks:
+ await asyncio.gather(*tasks, return_exceptions=True)
+ self._tts_worker_tasks.clear()
+ while not self._tts_playback_queue.empty():
+ try:
+ queued = self._tts_playback_queue.get_nowait()
+ except asyncio.QueueEmpty:
+ break
+ if isinstance(queued, dict) and isinstance(queued.get("job"), dict):
+ self._drop_tts_job(queued["job"], "shutdown")
+ await self.tts.close()
+ for request_id, channels in list(self._broadcast_channels.items()):
+ for channel, status in list(channels.items()):
+ if status == "pending":
+ channels[channel] = "failed"
+ self._complete_broadcast_channel(
+ request_id,
+ next(iter(channels), "shutdown"),
+ channels.get(next(iter(channels), ""), "failed"),
+ "cancelled",
+ )
+
+ def _complete_broadcast_channel(self, request_id: str, channel: str,
+ status: str, result_code: str = ""):
+ if not request_id or request_id not in self._broadcast_channels:
+ return
+ channels = self._broadcast_channels[request_id]
+ channels[channel] = status
+ if any(value == "pending" for value in channels.values()):
+ return
+ final_status = "success" if all(value == "success" for value in channels.values()) else "failed"
+ if self.stats_store:
+ self.stats_store.record_broadcast_request(
+ request_id,
+ completed_at_utc=datetime.now(timezone.utc).isoformat(),
+ status=final_status,
+ result_code=result_code or final_status,
+ payload={"channel_results": channels},
+ )
+ self._broadcast_channels.pop(request_id, None)
+
+ async def broadcast(self, text: str, tts: bool = True, danmu: bool = True,
+ tts_category: str | None = None, tts_priority: int | None = None):
+ """统一播报并汇总弹幕、TTS 子通道结果。"""
+ if not text:
+ return None
+ request_id = uuid.uuid4().hex
+ use_danmu = bool(danmu and self.enable_danmu)
+ use_tts = bool(tts and self.tts.enabled)
+ channels = {
+ name: "pending"
+ for name, enabled in (("danmu", use_danmu), ("tts", use_tts))
+ if enabled
+ }
+ self._broadcast_channels[request_id] = channels
+ channel_name = "+".join(channels) if channels else "none"
+ if self.stats_store:
+ self.stats_store.record_broadcast_request(
+ request_id,
+ channel=channel_name,
+ content_length=len(text),
+ status="queued" if channels else "failed",
+ result_code=None if channels else "no_enabled_channel",
+ completed_at_utc=None if channels else datetime.now(timezone.utc).isoformat(),
+ payload={"source": "broadcast", "tts_category": tts_category},
+ )
+ self.logger.info(f"[播报] {text}")
+ if use_danmu:
+ await self._danmu_queue.put({"request_id": request_id, "text": text})
+ if use_tts:
+ if not self._tts_started:
+ await self.start()
+ priority, max_age = self._tts_policy(tts_category)
+ if tts_priority is not None:
+ priority = int(tts_priority)
+ tts_request = self.tts.create_request(
+ text,
+ parent_request_id=request_id,
+ source="broadcast",
+ )
+ if not tts_request:
+ self._complete_broadcast_channel(request_id, "tts", "failed", "tts_unavailable")
+ else:
+ self._tts_sequence += 1
+ job = {
+ "broadcast_request_id": request_id,
+ "tts_request": tts_request,
+ "text": text,
+ "category": tts_category or "default",
+ "priority": priority,
+ "sequence": self._tts_sequence,
+ "expires_at": time.monotonic() + max(1.0, max_age),
+ }
+ accepted, dropped = await self._tts_pending.put(job)
+ if dropped:
+ self._drop_tts_job(dropped, "queue_replaced")
+ if not accepted:
+ self._drop_tts_job(job, "queue_full")
+ else:
+ self.logger.debug(
+ f"[TTS队列] 入队 priority={priority} category={job['category']} "
+ f"pending={self._tts_pending.qsize()}"
+ )
+ if not channels:
+ self._broadcast_channels.pop(request_id, None)
+ return request_id
+
+ async def _send_danmu(self, text: str) -> dict:
+ """调用B站发弹幕API。
+ POST https://api.live.bilibili.com/msg/send
+ 必填: msg, roomid, rnd, color, mode, fontsize, bubble, csrf, csrf_token
+ Cookie: SESSDATA + bili_jct
+ B站弹幕限制30个汉字, 超出裁切并加省略号(保留关键信息)"""
+ if not text:
+ return {"success": False, "code": "empty_text"}
+ # 弹幕长度限制: B站限30个汉字。按字符数裁切, 中文/emoji/全角按1算, 半角按1算(简单len即可)
+ MAX_LEN = 30
+ if len(text) > MAX_LEN:
+ text = text[:MAX_LEN - 1] + "…"
+ self.logger.info(f"[播报] 弹幕过长, 已裁切: {text}")
+ # 同步网络请求放到线程池,避免 B 站发弹幕接口慢时(最长 10s)阻塞事件循环。
+ try:
+ return await asyncio.to_thread(self._send_danmu_blocking, text)
+ except Exception as e:
+ self.logger.error(f"[播报] 发弹幕异常: {e}")
+ return {"success": False, "code": type(e).__name__}
+
+ def _send_danmu_blocking(self, text: str) -> dict:
+ url = "https://api.live.bilibili.com/msg/send"
+ rnd = str(int(time.time()))
+ data = urllib.parse.urlencode({
+ "bubble": "0",
+ "msg": text,
+ "color": "16777215",
+ "mode": "1",
+ "fontsize": "25",
+ "rnd": rnd,
+ "roomid": str(self.room_id),
+ "csrf": self.bili_jct,
+ "csrf_token": self.bili_jct,
+ }).encode("utf-8")
+ req = urllib.request.Request(url, data=data, method="POST")
+ req.add_header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
+ req.add_header("Cookie", f"SESSDATA={self.sessdata}; bili_jct={self.bili_jct}")
+ req.add_header("Origin", "https://live.bilibili.com")
+ req.add_header("Referer", f"https://live.bilibili.com/{self.room_id}")
+ req.add_header("Content-Type", "application/x-www-form-urlencoded")
+ resp = urllib.request.urlopen(req, timeout=10)
+ result = json.loads(resp.read())
+ if result.get("code") == 0:
+ self.logger.debug(f"[播报] 已发送弹幕: {text[:30]}")
+ return {"success": True, "code": "ok"}
+ code = str(result.get("code") or "api_error")
+ self.logger.warning(f"[播报] 发弹幕失败: {result.get('message', result)}")
+ return {"success": False, "code": code}
+
+
+# ============== BetterGI 日志监控 ==============
+class BgiLogMonitor:
+ """实时监控 BetterGI 日志,检测配置组或一条龙完成。"""
+
+ # 完成关键字: 配置组 "组名" 执行结束
+ FINISH_PATTERN = "执行结束"
+ ONE_DRAGON_FINISH_PATTERN = "一条龙和配置组任务结束"
+ LOG_HEADER_PATTERN = re.compile(
+ r"^\[(?P