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