Update Live-streaming code (auto-daily features)

This commit is contained in:
2026-08-15 17:45:19 +08:00
parent ba39e9743d
commit f623959fd4
19 changed files with 3517 additions and 62 deletions
+27 -7
View File
@@ -1,19 +1,20 @@
# BetterGI 直播联动
监听 B 站直播间弹幕,管理观众排队、积分、扫码上号、BetterGI 配置组执行、点歌和 TTS 播报。
监听 B 站直播间弹幕,管理观众排队、积分、扫码上号、BetterGI 配置组与自动每日一条龙、队伍管理、点歌和 TTS 播报。
## 环境
- **Python**: conda 环境 `Live-streaming``E:\Programs\Anaconda3\envs\Live-streaming\python.exe`
- **Python**: 项目虚拟环境 `.venv`Python 3.11
- **Node.js**: 构建前端用
- **BetterGI**: 自动化脚本引擎,路径在 `config/config.json` 中配置
- **BetterGI**: `0.63.0+`,路径在 `config/config.json` 中配置
```powershell
# 安装 Python 依赖
E:\Programs\Anaconda3\envs\Live-streaming\python.exe -m pip install -r requirements.txt
py -3.11 -m venv .venv
.venv\Scripts\python.exe -m pip install -r requirements.txt
# 下载 TTS 模型(约 1.2GB,仅需一次)
E:\Programs\Anaconda3\envs\Live-streaming\python.exe -c "from huggingface_hub import snapshot_download; snapshot_download('Qwen/Qwen3-TTS-12Hz-0.6B-Base')"
.venv\Scripts\python.exe -c "from huggingface_hub import snapshot_download; snapshot_download('Qwen/Qwen3-TTS-12Hz-0.6B-Base')"
# 安装前端依赖并构建
cd frontend\admin
@@ -69,6 +70,20 @@ npm run build
| `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
| 字段 | 默认值 | 说明 |
@@ -81,7 +96,7 @@ npm run build
### commands — 内置指令别名
11 个内置指令,每个可单独启用/禁用、自定义别名:
14 个内置指令,每个可单独启用/禁用、自定义别名和允许角色
| key | 默认别名 | 功能 |
|-----|----------|------|
@@ -91,6 +106,9 @@ npm run build
| `confirm_yes` | 是 | 确认账号正确 |
| `confirm_no` | 不是 | 确认账号不正确,重新扫码 |
| `run` | 执行, 跑, 开始 | 执行配置组(需带参数) |
| `daily` | 自动每日 | 启动托管的一条龙每日任务,可选秘境、地脉或委托模式 |
| `switch_party` | 切换队伍, 更换队伍 | 修改并执行配置组“切换队伍” |
| `edit_party` | 修改队员, 更换队员 | 校验四名角色后修改并执行配置组“修改队员” |
| `leave` | 退出 | 退出队列 |
| `reset` | 重置 | 一级用户重启原神和 BGI |
| `points` | 积分 | 查询积分 |
@@ -162,7 +180,7 @@ npm run build # 构建到 web/admin/
```powershell
# 编译检查
E:\Programs\Anaconda3\envs\Live-streaming\python.exe -m py_compile app/danmu_queue.py
.venv\Scripts\python.exe -m py_compile app/danmu_queue.py
```
核心文件:
@@ -170,6 +188,8 @@ E:\Programs\Anaconda3\envs\Live-streaming\python.exe -m py_compile app/danmu_que
|------|------|
| `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` | 运行时路径解析 |
+181
View File
@@ -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}")
+828
View File
@@ -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)
+410 -49
View File
@@ -15,6 +15,9 @@ BetterGI 弹幕排队系统
上号 - 队首触发扫码上号配置组
执行 <组名> - 已确认账号的队首触发配置组(: 执行 泡泡桔)
<组名> - 兼容旧指令等同于执行
自动每日 [模式] - 启动托管的一条龙每日任务
切换队伍 <名称> - 执行切换队伍配置组
修改队员 <四人> - 执行修改队员配置组
退出 - 退出排队队列(三级队首不可用)
重置 - 一级用户重启原神和BetterGI
积分 - 查询自己的积分
@@ -105,6 +108,42 @@ if str(APP_DIR) not in sys.path:
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
@@ -199,6 +238,9 @@ COMMAND_ALLOWED_ROLE_DEFAULTS = {
"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,
@@ -406,6 +448,13 @@ class Config:
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", {})
@@ -574,6 +623,9 @@ class Config:
"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": ["积分"]},
@@ -629,6 +681,10 @@ class Config:
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", "薄荷")
@@ -3050,10 +3106,11 @@ class Broadcaster:
# ============== BetterGI 日志监控 ==============
class BgiLogMonitor:
"""实时监控BetterGI日志,检测配置组完成。"""
"""实时监控 BetterGI 日志,检测配置组或一条龙完成。"""
# 完成关键字: 配置组 "组名" 执行结束
FINISH_PATTERN = "执行结束"
ONE_DRAGON_FINISH_PATTERN = "一条龙和配置组任务结束"
LOG_HEADER_PATTERN = re.compile(
r"^\[(?P<time>\d{2}:\d{2}:\d{2})(?:\.\d+)?\]"
r"\s+\[[A-Z]+\](?:\s+\[[^\]\r\n]+\])*\s+.+$"
@@ -3066,6 +3123,7 @@ class BgiLogMonitor:
self._on_finish_callback = None
self._current_group = None
self._current_run_id = None
self._current_kind = None
self._position = 0
def set_work_dir(self, work_dir: str):
@@ -3080,8 +3138,16 @@ class BgiLogMonitor:
def set_current_group(self, group_name: str | None, run_id: str | None = None):
"""设置当前配置组及任务实例编号,用于精确匹配完成事件。"""
self._current_group = group_name
self._current_run_id = str(run_id) if group_name and run_id else None
self._set_current_task(group_name, run_id, "group")
def set_current_one_dragon(self, task_name: str | None, run_id: str | None = None):
"""设置当前一条龙任务及任务实例编号。"""
self._set_current_task(task_name, run_id, "one_dragon")
def _set_current_task(self, task_name: str | None, run_id: str | None, kind: str):
self._current_group = task_name
self._current_run_id = str(run_id) if task_name and run_id else None
self._current_kind = kind if task_name else None
# 重置读取位置到文件末尾(只监听新日志)
self._position = self._get_current_log_size()
@@ -3159,17 +3225,24 @@ class BgiLogMonitor:
def _process_line(self, line: str):
if not line or not self._current_group:
return
# 匹配: 配置组 "组名" 执行结束
if (self.FINISH_PATTERN in line
and self._current_group in line
and "配置组" in line):
self.logger.info(
f"[日志监控] 检测到配置组 '{self._current_group}' 执行结束"
)
group_finished = (
self._current_kind == "group"
and self.FINISH_PATTERN in line
and self._current_group in line
and "配置组" in line
)
one_dragon_finished = (
self._current_kind == "one_dragon"
and self.ONE_DRAGON_FINISH_PATTERN in line
)
if group_finished or one_dragon_finished:
kind_label = "一条龙" if one_dragon_finished else "配置组"
self.logger.info(f"[日志监控] 检测到{kind_label} '{self._current_group}' 执行结束")
finished = self._current_group
finished_run_id = self._current_run_id
self._current_group = None
self._current_run_id = None
self._current_kind = None
if self._on_finish_callback:
asyncio.create_task(self._on_finish_callback(finished, finished_run_id))
@@ -3243,6 +3316,24 @@ class BetterGIRunner:
self.logger.error(f"[BGI] 启动失败: {e}")
return False
async def start_one_dragon(self, config_name: str) -> bool:
"""通过 BetterGI 0.63.0+ 命令行入口启动一条龙。"""
cmd = [self.exe_path, "startOneDragon", config_name]
self.logger.info(f"[BGI] 调用: {' '.join(cmd)}")
try:
await asyncio.create_subprocess_exec(
*cmd,
cwd=self.work_dir,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await asyncio.sleep(2)
self.logger.info(f"[BGI] 一条龙启动成功: {config_name}")
return True
except Exception as e:
self.logger.error(f"[BGI] 一条龙启动失败: {e}")
return False
async def start_bgi(self) -> bool:
"""仅启动 BetterGI 主程序,不执行配置组。"""
self.logger.info(f"[BGI] 启动主程序: {self.exe_path}")
@@ -4902,6 +4993,7 @@ class CommandHandler:
self.login_monitor = None # 由QueueSystem设置
self._rule_last_trigger_at: dict[int, float] = {}
self._pending_group_confirmations: dict[int, dict[str, Any]] = {}
self._bgi_operation_lock = asyncio.Lock()
self._tts_category_context = contextvars.ContextVar(
"command_tts_category",
default=None,
@@ -5107,6 +5199,33 @@ class CommandHandler:
return key
return None
def _split_command_text(self, text: str) -> tuple[str, str]:
"""拆分指令与参数,并兼容执行类指令省略命令后的空格。"""
raw_text = str(text or "").strip()
parts = raw_text.split(None, 1)
if not parts:
return "", ""
cmd = parts[0]
arg = parts[1].strip() if len(parts) > 1 else ""
if self._get_command_key(cmd) is not None:
return cmd, arg
aliases: list[str] = []
commands = self.config.data.get("commands", {})
for command_key in ("run", "daily", "switch_party", "edit_party"):
cfg = commands.get(command_key, {})
if not cfg.get("enabled", True):
continue
aliases.extend(
str(alias).strip()
for alias in cfg.get("aliases", [])
if str(alias).strip()
)
for alias in sorted(set(aliases), key=len, reverse=True):
if raw_text.startswith(alias) and len(raw_text) > len(alias):
return alias, raw_text[len(alias):].strip()
return cmd, arg
def get_user_role(self, uid: int) -> str:
if uid in self.config.admin_uids:
return ROLE_SUPER_ADMIN
@@ -5341,19 +5460,7 @@ class CommandHandler:
if len(self.recent_danmu) > self.max_danmu:
self.recent_danmu.pop(0)
parts = text.split(None, 1)
cmd = parts[0]
if len(parts) == 1:
run_cfg = self.config.data.get("commands", {}).get("run", {})
run_aliases = sorted(
(str(alias).strip() for alias in run_cfg.get("aliases", []) if str(alias).strip()),
key=len,
reverse=True,
)
for alias in run_aliases:
if text.startswith(alias) and len(text) > len(alias):
cmd = alias
break
cmd, _ = self._split_command_text(text)
cmd_key = self._get_command_key(cmd)
is_song_command = self._is_song_request_command(cmd)
is_custom_rule_command = self._is_custom_rule_command(text)
@@ -5387,21 +5494,7 @@ class CommandHandler:
# 只有真正发送“上号”进入登录阶段后,才由 login_started_at 接管计时。
# 解析指令。执行类指令兼容省略空格,例如“执行薄荷”“跑清心”。
parts = text.split(None, 1) # 按空格分割,最多2段
cmd = parts[0]
arg = parts[1].strip() if len(parts) > 1 else ""
if not arg:
run_cfg = self.config.data.get("commands", {}).get("run", {})
run_aliases = sorted(
(str(alias).strip() for alias in run_cfg.get("aliases", []) if str(alias).strip()),
key=len,
reverse=True,
)
for alias in run_aliases:
if text.startswith(alias) and len(text) > len(alias):
cmd = alias
arg = text[len(alias):].strip()
break
cmd, arg = self._split_command_text(text)
# 用配置别名匹配内置指令。当前指令类别通过 ContextVar 传给所有
# broadcast 调用,因此无需在每个分支重复传 category。
@@ -5413,6 +5506,9 @@ class CommandHandler:
"confirm_yes": "login",
"confirm_no": "login",
"run": "execution",
"daily": "execution",
"switch_party": "execution",
"edit_party": "execution",
"leave": "queue",
"reset": "reset",
"points": "points",
@@ -5478,6 +5574,30 @@ class CommandHandler:
)
return
await self._cmd_run(uid, uname, arg)
elif cmd_key == "daily":
if not await self._ensure_user_permitted(uid, uname, scope="command", allowed_roles=self._command_allowed_roles("daily")):
return
await self._cmd_daily(uid, uname, arg)
elif cmd_key == "switch_party":
if not await self._ensure_user_permitted(uid, uname, scope="command", allowed_roles=self._command_allowed_roles("switch_party")):
return
if not arg:
await self.broadcast(
f"{uname}」请发送“切换队伍 <队伍名称>”",
tts=True,
)
return
await self._cmd_switch_party(uid, uname, arg)
elif cmd_key == "edit_party":
if not await self._ensure_user_permitted(uid, uname, scope="command", allowed_roles=self._command_allowed_roles("edit_party")):
return
if not arg:
await self.broadcast(
f"{uname}」请发送“修改队员 <队员1> <队员2> <队员3> <队员4>”",
tts=True,
)
return
await self._cmd_edit_party(uid, uname, arg)
elif cmd_key == "leave":
if not await self._ensure_user_permitted(uid, uname, scope="command", allowed_roles=self._command_allowed_roles("leave")):
return
@@ -5768,6 +5888,18 @@ class CommandHandler:
return False
async def _cmd_run(self, uid: int, uname: str, group_name: str):
async with self._bgi_operation_lock:
await self._cmd_run_locked(uid, uname, group_name)
async def _cmd_run_locked(
self,
uid: int,
uname: str,
group_name: str,
*,
updates: tuple[JsonUpdate, ...] = (),
action_description: str | None = None,
):
"""已登录队首发送"执行 组名": taskkill → 启动指定配置组"""
effective_uid = self._effective_queue_admin_uid(uid)
# 校验配置组是否存在,支持模糊匹配/错别字纠正。
@@ -5812,7 +5944,15 @@ class CommandHandler:
if was_running:
self.logger.info(f"[执行] 先停止当前任务,再启动'{group_name}'")
self.queue_mgr.interrupt_group("group_replaced", status="cancelled")
self.log_monitor.set_current_group(None)
await self.runner.kill_bgi(reason="配置组替换")
if updates:
try:
await asyncio.to_thread(apply_json_updates, updates)
except Exception as exc:
self.logger.error(f"[执行失败] 写入 BGI 配置失败: {exc}")
await self.broadcast(f"{uname}」写入 BGI 配置失败:{exc}", tts=True)
return
run_id = uuid.uuid4().hex
result = self.queue_mgr.start_group(effective_uid, group_name, run_id=run_id)
if not result["success"]:
@@ -5824,11 +5964,12 @@ class CommandHandler:
self.log_monitor.set_current_group(group_name, run_id)
ok = await self.runner.start_groups([group_name])
if ok:
action_text = action_description or f"开始执行配置组'{group_name}'"
self.logger.info(
f"[执行] {uname}({uid}) 启动'{group_name}', 每分钟扣{POINTS_PER_MINUTE}积分"
f"[执行] {uname}({uid}) {action_text}, 每分钟扣{POINTS_PER_MINUTE}积分"
)
await self.broadcast(
f"{uname}开始执行配置组'{group_name}',每分钟扣{POINTS_PER_MINUTE}积分"
f"{uname}{action_text},每分钟扣{POINTS_PER_MINUTE}积分"
)
else:
self.log_monitor.set_current_group(None)
@@ -5843,6 +5984,219 @@ class CommandHandler:
else:
await self._perform_reset("配置组启动失败,正在关闭原神并启动扫码上号")
async def _capture_current_party_name(
self,
prepared: PreparedCurrentPartyRead,
timeout_sec: float,
) -> str:
try:
timeout = max(10.0, min(120.0, float(timeout_sec or 45.0)))
except (TypeError, ValueError):
timeout = 45.0
synced = await asyncio.to_thread(
self.runner.sync_js_script,
CURRENT_PARTY_SCRIPT_NAME,
)
if not synced:
raise CurrentPartyReadError("同步当前队伍读取脚本失败")
await asyncio.to_thread(clear_current_party_status, prepared.status_path)
await asyncio.to_thread(apply_json_updates, prepared.updates)
self.logger.info(
f"[自动每日] 启动当前队伍读取配置组: {prepared.group_name}, "
f"request_id={prepared.request_id}"
)
if not await self.runner.start_groups([prepared.group_name]):
raise CurrentPartyReadError("BetterGI 当前队伍读取配置组启动失败")
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
result = await asyncio.to_thread(
read_current_party_status,
prepared.status_path,
prepared.request_id,
)
if result is not None:
await asyncio.sleep(0.75)
self.logger.info(
f"[自动每日] 当前队伍读取成功: {result.party_name}, "
f"candidates={list(result.candidates)}"
)
return result.party_name
await asyncio.sleep(0.5)
raise CurrentPartyReadError(f"读取当前队伍名称超时({timeout:g}秒)")
async def _cmd_daily(self, uid: int, uname: str, argument: str):
async with self._bgi_operation_lock:
effective_uid = self._effective_queue_admin_uid(uid)
if not self.queue_mgr.is_admin(effective_uid):
await self.broadcast(f"{uname}」你不是队首,请先排队", tts=True)
return
state = self.queue_mgr.state
if state.get("login_status") != "logged_in":
await self.broadcast(f"{uname}」请先发\"上号\"完成扫码登录", tts=True)
return
daily_cfg = self.config.daily_cfg
try:
prepared = await asyncio.to_thread(
prepare_daily_run,
self.config.bettergi_work_dir,
CONFIG_DIR / "domain_aliases.json",
argument,
template_name=str(daily_cfg.get("one_dragon_template") or "默认配置"),
managed_name=str(daily_cfg.get("managed_one_dragon_name") or "直播系统自动每日"),
ley_line_craft_resin_before=bool(
daily_cfg.get("ley_line_craft_resin_before", True)
),
commission_use_current_party=bool(
daily_cfg.get("commission_use_current_party", True)
),
)
current_party_read = None
if prepared.requires_current_party:
reader_source = INTEGRATIONS_DIR / CURRENT_PARTY_SCRIPT_NAME
if not reader_source.is_dir():
raise CurrentPartyReadError(
f"项目内缺少当前队伍读取脚本: {reader_source}"
)
current_party_read = await asyncio.to_thread(
prepare_current_party_read,
self.config.bettergi_work_dir,
uuid.uuid4().hex,
)
except DailyAutomationError as exc:
self.logger.info(f"[自动每日] 参数或 BGI 配置校验失败: {exc}")
await self.broadcast(f"{uname}」自动每日无法启动:{exc}", tts=True)
return
except Exception as exc:
self.logger.exception("[自动每日] 准备配置失败")
await self.broadcast(f"{uname}」自动每日配置生成失败:{exc}", tts=True)
return
was_running = bool(state.get("current_group") or state.get("default_running"))
if was_running:
self.queue_mgr.interrupt_group("daily_replaced", status="cancelled")
self.log_monitor.set_current_group(None)
await self.runner.kill_bgi(reason="自动每日配置更新")
daily_updates = prepared.updates
if current_party_read is not None:
try:
party_name = await self._capture_current_party_name(
current_party_read,
daily_cfg.get("current_party_read_timeout_sec", 45),
)
party_update = await asyncio.to_thread(
prepare_commission_current_party_update,
self.config.bettergi_work_dir,
party_name,
)
daily_updates = (party_update, *prepared.updates)
except CurrentPartyReadError as exc:
self.logger.warning(f"[自动每日] 读取当前队伍失败: {exc}")
await self.broadcast(
f"{uname}」自动每日无法启动:读取当前队伍失败,{exc}",
tts=True,
)
return
except Exception as exc:
self.logger.exception("[自动每日] 当前队伍准备阶段异常")
await self.broadcast(
f"{uname}」自动每日无法启动:当前队伍准备失败,{exc}",
tts=True,
)
return
state = self.queue_mgr.state
if (
not self.queue_mgr.is_admin(effective_uid)
or state.get("login_status") != "logged_in"
):
self.logger.info("[自动每日] 读取当前队伍期间队首或登录状态已变化,取消启动")
await self.broadcast(f"{uname}」登录状态已变化,自动每日已取消", tts=True)
return
try:
await asyncio.to_thread(apply_json_updates, daily_updates)
except Exception as exc:
self.logger.error(f"[自动每日] 写入 BGI 配置失败: {exc}")
await self.broadcast(f"{uname}」写入自动每日配置失败:{exc}", tts=True)
return
run_id = uuid.uuid4().hex
result = self.queue_mgr.start_group(
effective_uid,
prepared.task_name,
run_id=run_id,
)
if not result.get("success"):
await self.broadcast(f"{uname}{result.get('msg', '自动每日无法启动')}", tts=True)
return
self.log_monitor.set_current_one_dragon(prepared.task_name, run_id)
ok = await self.runner.start_one_dragon(prepared.config_name)
if ok:
self.logger.info(
f"[自动每日] {uname}({uid}) 启动'{prepared.config_name}'"
f"模式={prepared.request.summary},每分钟扣{POINTS_PER_MINUTE}积分"
)
await self.broadcast(
f"{uname}」开始自动每日({prepared.request.summary}),"
f"每分钟扣{POINTS_PER_MINUTE}积分"
)
return
self.log_monitor.set_current_one_dragon(None)
self.logger.error("[自动每日] BetterGI 一条龙启动失败,进入自动重置流程")
await self.broadcast(f"{uname}」自动每日启动失败,系统将自动重置并重新扫码", tts=True)
if self.system:
await self.system._reset_wait_and_retry_login(
effective_uid,
uname,
"自动每日启动失败,正在关闭原神并启动扫码上号",
)
else:
await self._perform_reset("自动每日启动失败,正在关闭原神并启动扫码上号")
async def _cmd_switch_party(self, uid: int, uname: str, party_name: str):
async with self._bgi_operation_lock:
try:
update = await asyncio.to_thread(
prepare_switch_party_update,
self.config.bettergi_work_dir,
party_name,
)
except DailyAutomationError as exc:
await self.broadcast(f"{uname}」切换队伍无法启动:{exc}", tts=True)
return
await self._cmd_run_locked(
uid,
uname,
"切换队伍",
updates=(update,),
action_description=f"切换队伍为'{party_name.strip()}'",
)
async def _cmd_edit_party(self, uid: int, uname: str, argument: str):
async with self._bgi_operation_lock:
try:
update, members = await asyncio.to_thread(
prepare_edit_party_update,
self.config.bettergi_work_dir,
argument,
)
except DailyAutomationError as exc:
await self.broadcast(f"{uname}」修改队员无法启动:{exc}", tts=True)
return
await self._cmd_run_locked(
uid,
uname,
"修改队员",
updates=(update,),
action_description=f"将当前队员修改为{''.join(members)}",
)
def _is_level_one_user(self, uid: int) -> bool:
"""一级用户:config.json global.admin_uids 中配置的 UID。"""
return uid in self.config.admin_uids
@@ -6167,6 +6521,9 @@ class CommandHandler:
s = self._get_cmd_aliases_str("signin", "签到")
l = self._get_cmd_aliases_str("login", "上号")
r = self._get_cmd_aliases_str("run", "执行")
daily = self._get_cmd_aliases_str("daily", "自动每日")
switch_party = self._get_cmd_aliases_str("switch_party", "切换队伍")
edit_party = self._get_cmd_aliases_str("edit_party", "修改队员")
c_yes = self._get_cmd_aliases_str("confirm_yes", "")
c_no = self._get_cmd_aliases_str("confirm_no", "不是")
leave = self._get_cmd_aliases_str("leave", "退出")
@@ -6182,6 +6539,9 @@ class CommandHandler:
f" {l} - 队首触发扫码上号\n"
f" {c_yes}/{c_no} - 确认扫码账号是否正确\n"
f" {r} <组名> - 已确认账号后执行配置组(1积分=1分钟)\n"
f" {daily} [秘境/地脉/委托] - 执行自动每日一条龙\n"
f" {switch_party} <队伍名> - 切换到指定队伍\n"
f" {edit_party} <四名角色> - 修改当前队伍角色\n"
f" {song_cmd} <歌名> - 发送点歌\n"
f" {leave} - 退出队列(三级队首不可用)\n"
f" {pts} - 查询积分\n"
@@ -6191,7 +6551,8 @@ class CommandHandler:
)
self.logger.info(help_text)
await self.broadcast(
f"指令: {q}/{s}/{l}/{c_yes}/{c_no}/{r} 组名/{song_cmd} 歌名/{leave}/{reset}/{pts}/{ql}/{h}",
f"指令: {q}/{s}/{l}/{c_yes}/{c_no}/{r} 组名/{daily}/"
f"{switch_party} 队伍名/{edit_party} 四名角色/{song_cmd} 歌名/{leave}/{reset}/{pts}/{ql}/{h}",
tts=True
)
@@ -7517,17 +7878,17 @@ class QueueSystem:
await self.broadcast(f"请「{uname}」确认这是你的账号吗,回复是或不是")
async def _on_group_finished(self, group_name: str, run_id: str | None):
"""配置组执行完成回调;仅当前任务实例可结算一次。"""
"""配置组或一条龙执行完成回调;仅当前任务实例可结算一次。"""
state = self.queue_mgr.state
current_group = state.get("current_group")
current_run_id = state.get("current_group_run_id")
if not run_id or run_id != current_run_id or group_name != current_group:
self.logger.info(
f"[完成] 忽略迟到/重复配置组回调: group='{group_name}', run_id={run_id}, "
f"[完成] 忽略迟到/重复任务回调: group='{group_name}', run_id={run_id}, "
f"current_group='{current_group}', current_run_id={current_run_id}"
)
return
self.logger.info(f"[完成] 配置组 '{group_name}' 执行结束, run_id={run_id}")
self.logger.info(f"[完成] 任务 '{group_name}' 执行结束, run_id={run_id}")
start_time = state.get("group_start_time")
run_seconds = 0
if start_time:
@@ -7537,7 +7898,7 @@ class QueueSystem:
run_seconds = 0
admin_uid = state.get("current_admin_uid")
admin_points = self.user_mgr.get_points(admin_uid) if admin_uid else 0
# 积分已耗尽/扣到负分时,配置组自然结束后不再保留队列,直接进入默认薄荷。
# 积分已耗尽/扣到负分时,任务自然结束后不再保留队列,直接进入默认薄荷。
# 积分仍为正且3分钟内完成,才保留当前用户继续二级权限。
keep_current = (
not state.get("default_running")
@@ -7545,12 +7906,12 @@ class QueueSystem:
and run_seconds > 0
and run_seconds < 180
)
# 配置组自然完成后不主动 kill BGI;真正抢占 kill 放到下一个队首发送“上号”时执行。
# 任务自然完成后不主动 kill BGI;真正抢占 kill 放到下一个队首发送“上号”时执行。
self.log_monitor.set_current_group(None)
result = self.queue_mgr.group_finished(expected_run_id=run_id, keep_current=keep_current)
if not result.get("accepted"):
self.logger.info(
f"[完成] 配置组实例已被其他入口结算或替换,忽略: group='{group_name}', run_id={run_id}"
f"[完成] 任务实例已被其他入口结算或替换,忽略: group='{group_name}', run_id={run_id}"
)
return
finished_uid = result.get("finished_uid")
@@ -7561,10 +7922,10 @@ class QueueSystem:
).get("uname", "?")
if result.get("kept"):
self.logger.info(
f"[完成] {finished_uname}({finished_uid}) 配置组跑完,用时{run_seconds}秒,保留队列"
f"[完成] {finished_uname}({finished_uid}) 任务跑完,用时{run_seconds}秒,保留队列"
)
await self.broadcast(
f"{finished_uname}」的配置组'{group_name}'执行完毕,未满3分钟,保留队列"
f"{finished_uname}」的任务'{group_name}'执行完毕,未满3分钟,保留队列"
)
else:
self.logger.info(
+40 -1
View File
@@ -12,9 +12,16 @@
},
"bettergi": {
"_说明": "BetterGI.exe 的完整路径。work_dir 留空则自动取 exe 所在目录。",
"exe_path": "C:\\Program Files\\BetterGI\\BetterGI.exe",
"exe_path": "D:\\bgi-live\\BetterGI.exe",
"work_dir": ""
},
"daily": {
"one_dragon_template": "每日模板",
"managed_one_dragon_name": "直播系统自动每日",
"ley_line_craft_resin_before": true,
"commission_use_current_party": true,
"current_party_read_timeout_sec": 45
},
"global": {
"_说明": "default_cooldown: 冷却秒数防刷屏。admin_uids: 管理员UID绕过冷却。log_level: DEBUG/INFO/WARNING。restart_mode: gentle温和(执行中跳过)/aggressive激进(先杀BetterGI再重启,立即响应,适合直播)。",
"default_cooldown": 30,
@@ -249,6 +256,38 @@
"active_operator"
]
},
"daily": {
"enabled": true,
"aliases": [
"自动每日"
],
"allowed_roles": [
"super_admin",
"active_operator"
]
},
"switch_party": {
"enabled": true,
"aliases": [
"切换队伍",
"更换队伍"
],
"allowed_roles": [
"super_admin",
"active_operator"
]
},
"edit_party": {
"enabled": true,
"aliases": [
"修改队员",
"更换队员"
],
"allowed_roles": [
"super_admin",
"active_operator"
]
},
"leave": {
"enabled": true,
"aliases": [
+7
View File
@@ -0,0 +1,7 @@
{
"_说明": "键为 BetterGI 正式秘境名,值为可维护的俗称列表;修改后无需重启。",
"铭记之谷": [
"风本",
"少女套"
]
}
+179
View File
@@ -0,0 +1,179 @@
# 自动每日与队伍管理
本功能要求 BetterGI `0.63.0` 或更高版本,系统会通过以下命令行入口启动托管的一条龙配置:
```text
BetterGI.exe startOneDragon <配置名称>
```
## 弹幕指令
```text
自动每日
自动每日 秘境 <秘境正式名称或俗称>
自动每日 地脉 <经验|摩拉> <国家>
自动每日 委托
切换队伍 <队伍名称>
更换队伍 <队伍名称>
修改队员 <队员1> <队员2> <队员3> <队员4>
更换队员 <队员1> <队员2> <队员3> <队员4>
```
示例:
```text
自动每日
自动每日 秘境 风本
自动每日 秘境 少女套
自动每日 地脉 经验 蒙德
自动每日 地脉 摩拉 枫丹
自动每日 委托
切换队伍 永冻队
修改队员 神里绫华 申鹤 枫原万叶 珊瑚宫心海
修改队员 神里绫华、申鹤、枫原万叶、珊瑚宫心海
```
队员支持空格、英文或中文逗号、顿号、斜杠分隔。连续输入四个角色名时,只有能够唯一拆分为四名已知角色才会执行。角色必须存在于 BGI 的 `AutoSwitchRoles` 角色数据中,且四人不能重复。新版脚本同时支持 `combat_avatar.json` 中维护的角色别名。
## 每日流程
每日流程固定为:
```text
领取邮件 > 合成树脂 > 可选其他任务 > 领取尘歌壶奖励 > 领取每日奖励
```
- 直接发送 `自动每日` 时不插入其他任务。
- 秘境模式一直执行到树脂耗尽或 BGI 报错。
- 地脉模式默认在执行前合成树脂,然后一直执行到树脂耗尽或 BGI 报错。
- 委托模式执行配置组“每日委托”。
- 委托启用“使用当前队伍”时,正式任务启动前会先读取游戏当前队伍预设名,并写入 AutoCommissionNova 的战斗与元素采集队伍配置;该准备步骤不进入正式计费任务。
- 其他任务报错时,由 BetterGI 一条龙继续执行后面的尘歌壶和每日奖励任务。
- 一条龙完成后复用现有配置组的计费、三分钟保留队列和任务结算规则。
`daily.ley_line_craft_resin_before` 设为 `false` 时,只有地脉模式会跳过“合成树脂”;其他每日模式仍会执行该步骤。
## 项目配置
`config/config.json`
```json
{
"daily": {
"one_dragon_template": "默认配置",
"managed_one_dragon_name": "直播系统自动每日",
"ley_line_craft_resin_before": true,
"commission_use_current_party": true,
"current_party_read_timeout_sec": 45
}
}
```
| 字段 | 说明 |
|------|------|
| `one_dragon_template` | BGI `User/OneDragon` 中作为来源的一条龙配置名,不含 `.json` |
| `managed_one_dragon_name` | 系统每次覆盖生成并通过命令行启动的配置名 |
| `ley_line_craft_resin_before` | 地脉模式执行前是否插入“合成树脂” |
| `commission_use_current_party` | 委托模式是否读取游戏当前队伍名并覆盖 AutoCommissionNova 的战斗与元素采集队伍 |
| `current_party_read_timeout_sec` | 读取当前队伍名称的超时时间,限制为 10 至 120 秒 |
托管配置每次执行都会从模板重新生成。模板中的合成地区、冒险家协会地区、战斗队伍、好感队和尘歌壶设置会保留,系统会强制把完成动作设为“无”,防止任务结束后退出游戏或关机。
秘境俗称维护在 `config/domain_aliases.json`
```json
{
"铭记之谷": ["风本", "少女套"]
}
```
键必须是当前 BGI `AutoDomain/settings.json` 中存在的正式秘境名。值是俗称列表。同一俗称不能指向多个秘境;文件在每次指令执行时读取,修改后无需重启本项目。
## BetterGI 配置
### 1. 一条龙模板
在 BetterGI 中创建一条龙配置,名称与 `daily.one_dragon_template` 相同,默认是“默认配置”。在模板中配置:
- 合成浓缩树脂使用的地区。
- 领取每日奖励使用的冒险家协会地区。
- 自动秘境和自动地脉需要的战斗队伍、好感队及其他树脂选项。
- 尘歌壶进入方式和奖励领取设置。
模板的任务勾选和顺序不会直接采用,系统会按固定流程重新生成托管配置。
### 2. 自动秘境
- 安装并配置 BGI 的 `AutoDomain` 脚本。
- 在 BGI 自动战斗配置中选择可用战斗策略,并确保策略文件存在于 `User/AutoFight`
- 系统会关闭一条龙周计划覆盖并写入解析后的正式秘境名。
- 系统会永久将 `User/config.json``autoDomainConfig.specifyResinUse` 修正为 `false`,以树脂耗尽为停止条件。
### 3. 自动地脉花
- 在 BGI 中配置自动地脉花使用的战斗策略和队伍。
- 系统会把地脉类型和国家写入星期一至星期日的全部字段。
- 系统会开启树脂耗尽模式,并关闭“运行次数取小值”。
- 系统会永久将 `autoLeyLineOutcropConfig.isGoToSynthesizer` 修正为 `false`。是否在地脉前合成树脂只由本项目的 `ley_line_craft_resin_before` 控制。
支持国家:蒙德、璃月、稻妻、须弥、枫丹、纳塔、挪德卡莱。
### 4. 每日委托配置组
在 BetterGI 的配置组页面创建“每日委托”,加入并启用实际完成每日委托所需的项目。系统不会自动创建此配置组,缺失或没有可执行项目时会拒绝启动自动每日,且不会停止当前任务。
使用 `AutoCommissionNova` 时,首次必须在 BetterGI 中手动运行并完成配置面板,至少保存用户配置和战斗策略。启用“使用当前队伍”后,战斗队伍与元素采集队伍都可以留空;未启用时,这两个队伍名称都必须保存。确认生成以下文件后,再关闭“启动时显示配置面板”:
```text
User/JsScript/AutoCommissionNova/Data/user-config.json
```
系统会在启动“自动每日 委托”前校验该文件。启用当前队伍后,项目会自动同步自维护脚本:
```text
integrations/LiveCurrentParty
```
系统自动生成并覆盖托管配置组“直播系统读取当前队伍”,通过请求 ID 对应的 `status.json` 获取游戏当前队伍预设名,然后只修改:
```text
AutoCommissionNova/Data/user-config.json
party.global.battleTeamName
party.global.elementTeamName
```
不会修改 `AutoCommissionNova` 的任何源码,因此第三方脚本更新不会覆盖本项目逻辑。两个字段使用同一次 OCR 得到的当前队伍预设名。
### 5. 切换队伍配置组
在 BetterGI 创建配置组“切换队伍”,加入并只启用一个文件夹名为 `AcceleratedEditionSwitchParty` 的 JavaScript 项目。系统会在执行前更新该项目的 `jsScriptSettingsObject.partyName`
### 6. 修改队员配置组
在 BetterGI 创建配置组“修改队员”,加入并只启用一个文件夹名为 `AutoSwitchRoles` 的 JavaScript 项目。系统会按顺序读取:
```text
User/JsScript/AutoSwitchRoles/settings.json
User/JsScript/AutoSwitchRoles/combat_avatar.json
```
旧版脚本会从 `settings.json` 的下拉选项读取角色;`AutoSwitchRoles 6.7.0+` 的位置参数是文本框,系统会改为读取 `combat_avatar.json` 中的正式名称和别名。校验后更新配置组中的 `position1``position4`
## 权限与计费
`daily``switch_party``edit_party` 默认权限与 `run` 相同:一级超管和已完成上号的二级队首。可以在后台“规则”页修改启停状态、别名和允许角色。
三个新功能都复用现有排队和计费规则。切换队伍与修改队员分别执行配置组“切换队伍”和“修改队员”,并按普通配置组的三分钟完成规则结算。
## 排障
- 提示找不到一条龙模板:检查 `User/OneDragon/<模板名>.json` 是否存在,且模板名与配置完全一致。
- 提示秘境未知:检查 BGI `AutoDomain/settings.json``config/domain_aliases.json` 的正式名称。
- 提示战斗策略不存在:在 BetterGI 中重新选择策略,并确认对应文件位于 `User/AutoFight`
- 提示配置组缺少 JavaScript 项目:检查配置组名称、项目的 `folderName` 和启用状态。
- 提示 AutoCommissionNova 未完成首次配置:在 BGI 中打开配置面板并保存用户配置与战斗策略;关闭“使用当前队伍”时还需保存战斗队伍和元素采集队伍。
- 提示读取当前队伍失败:确认当前场景允许打开配队界面;若 OCR 返回多个候选,查看 BetterGI 日志中的“当前队伍名称候选”记录。
- 提示角色未知或有歧义:检查 `AutoSwitchRoles/settings.json``combat_avatar.json`,使用其中唯一对应的名称或别名,并用空格明确分隔四人。
- BGI 启动失败:确认版本为 `0.63.0+``bettergi.exe_path``bettergi.work_dir` 正确,并查看 BetterGI 日志。
- 一条龙执行中子任务报错:查看 BetterGI 日志确认后续“领取尘歌壶奖励”和“领取每日奖励”是否继续;本项目只在整条一条龙完成标记出现后结算。
+18 -1
View File
@@ -15,6 +15,9 @@ const COMMAND_ROLE_DEFAULTS = {
confirm_yes: ["super_admin", "pending_operator"],
confirm_no: ["super_admin", "pending_operator"],
run: ["super_admin", "active_operator"],
daily: ["super_admin", "active_operator"],
switch_party: ["super_admin", "active_operator"],
edit_party: ["super_admin", "active_operator"],
leave: ["super_admin", "active_operator", "viewer"],
reset: ["super_admin"],
points: ROLE_OPTIONS.map(([id]) => id),
@@ -29,6 +32,9 @@ const CMD_LABELS = {
confirm_yes: "确认是",
confirm_no: "确认不是",
run: "执行配置组",
daily: "自动每日",
switch_party: "切换队伍",
edit_party: "修改队员",
leave: "退出",
reset: "重置",
points: "积分",
@@ -36,7 +42,7 @@ const CMD_LABELS = {
help: "帮助"
};
const CMD_HAS_ARG = { run: true };
const CMD_HAS_ARG = { run: true, daily: true, switch_party: true, edit_party: true };
const clone = (value) => JSON.parse(JSON.stringify(value ?? {}));
const splitList = (value) => String(value || "").replaceAll("", ",").split(",").map((item) => item.trim()).filter(Boolean);
@@ -82,6 +88,12 @@ const ensureDraftShape = (draft) => {
draft.bilibili.cookie_auto_refresh_enabled ??= true;
draft.bilibili.cookie_check_interval_hours ??= 6;
draft.bettergi ||= {};
draft.daily ||= {};
draft.daily.one_dragon_template ||= "默认配置";
draft.daily.managed_one_dragon_name ||= "直播系统自动每日";
draft.daily.ley_line_craft_resin_before ??= true;
draft.daily.commission_use_current_party ??= true;
draft.daily.current_party_read_timeout_sec ??= 45;
draft.global ||= {};
draft.queue ||= {};
draft.frontend ||= {};
@@ -1261,6 +1273,11 @@ createApp({
<h2>BetterGI</h2>
<label>BetterGI.exe<input v-model="draft.bettergi.exe_path"></label>
<label>工作目录<input v-model="draft.bettergi.work_dir"></label>
<label>每日一条龙模板<input v-model="draft.daily.one_dragon_template"></label>
<label>托管一条龙名称<input v-model="draft.daily.managed_one_dragon_name"></label>
<label class="inline"><input v-model="draft.daily.ley_line_craft_resin_before" type="checkbox"> 地脉前合成树脂</label>
<label class="inline"><input v-model="draft.daily.commission_use_current_party" type="checkbox"> 委托战斗及采集均使用游戏当前队伍</label>
<label>读取当前队伍超时秒数<input v-model.number="draft.daily.current_party_read_timeout_sec" type="number" min="10" max="120"></label>
<label>超管 UID<input v-model="draft.global.admin_uidsText" @blur="draft.global.admin_uids = splitList(draft.global.admin_uidsText).map((item) => Number(item)).filter((item) => Number.isFinite(item))" placeholder="逗号分隔 UID"></label>
</div>
<div class="panel">
+154
View File
@@ -0,0 +1,154 @@
(async function () {
setGameMetrics(1920, 1080, 1);
const STATUS_FILE = "status.json";
const requestId = String(settings.requestId || "").trim();
let lastCandidates = [];
function writeStatus(state, data) {
const payload = Object.assign({
state: state,
request_id: requestId,
party_name: "",
candidates: lastCandidates,
message: "",
updated_at: new Date().toISOString()
}, data || {});
file.writeTextSync(STATUS_FILE, JSON.stringify(payload, null, 2));
}
function normalizeText(value) {
return String(value || "").replace(/\s+/g, "").trim();
}
function isPartyNameCandidate(text) {
if (!text || text.length > 20) {
return false;
}
const ignored = [
"队伍配置",
"快速编队",
"元素共鸣",
"调整队伍",
"当前队伍",
"部署",
"出战",
"详情"
];
if (ignored.some((label) => text === label || text.includes(label))) {
return false;
}
return !/^(esc|enter|space|l|f\d{1,2})$/i.test(text);
}
function isPartyPageOpen() {
const capture = captureGameRegion();
try {
const results = capture.findMulti(RecognitionObject.ocr(0, 0, 720, 180));
for (let index = 0; index < results.count; index++) {
const text = normalizeText(results[index].text);
if (text.includes("队伍配置")) {
return true;
}
}
return false;
} finally {
capture.dispose();
}
}
function recognizePartyNameCandidates() {
const capture = captureGameRegion();
try {
const results = capture.findMulti(RecognitionObject.ocr(0, 940, 520, 140));
const candidates = [];
for (let index = 0; index < results.count; index++) {
const result = results[index];
const text = normalizeText(result.text);
log.info(
"当前队伍名称候选位置:({x},{y},{w},{h}), 识别结果:{text}",
result.x,
result.y,
result.Width,
result.Height,
text
);
if (isPartyNameCandidate(text)) {
candidates.push({
text: text,
x: Number(result.x || 0),
y: Number(result.y || 0)
});
}
}
candidates.sort((left, right) => {
if (left.x !== right.x) {
return left.x - right.x;
}
return right.y - left.y;
});
return candidates.filter(
(candidate, index, values) =>
values.findIndex((value) => value.text === candidate.text) === index
);
} finally {
capture.dispose();
}
}
async function openPartyPageAndRead() {
for (let attempt = 0; attempt < 3; attempt++) {
keyPress("VK_L");
await sleep(2200);
for (let scan = 0; scan < 4; scan++) {
if (isPartyPageOpen()) {
const candidates = recognizePartyNameCandidates();
if (candidates.length > 0) {
return candidates;
}
}
await sleep(600);
}
keyPress("VK_ESCAPE");
await sleep(800);
await genshin.returnMainUi();
}
return [];
}
try {
if (!requestId) {
throw new Error("读取请求标识为空");
}
writeStatus("running");
await genshin.returnMainUi();
const candidates = await openPartyPageAndRead();
lastCandidates = candidates.map((candidate) => candidate.text);
if (candidates.length === 0) {
throw new Error("未识别到当前队伍名称,请确认游戏处于可打开配队界面的状态");
}
if (candidates.length > 1) {
throw new Error("识别到多个队伍名称候选:" + lastCandidates.join("、"));
}
const partyName = candidates[0].text;
await genshin.returnMainUi();
await sleep(500);
writeStatus("success", {
party_name: partyName,
candidates: lastCandidates
});
log.info("当前队伍名称读取成功: {name}", partyName);
} catch (error) {
try {
await genshin.returnMainUi();
} catch (_) {
}
const message = String(error && error.message ? error.message : error);
writeStatus("error", {
candidates: lastCandidates,
message: message
});
log.error("当前队伍名称读取失败: {message}", message);
}
})();
@@ -0,0 +1,17 @@
{
"manifest_version": 1,
"name": "直播系统读取当前队伍",
"version": "1.0.0",
"bgi_version": "0.63.0",
"description": "读取游戏当前队伍预设名称并通过状态文件返回给直播联动系统",
"authors": [
{
"name": "BGI直播项目"
}
],
"settings_ui": "settings.json",
"main": "main.js",
"saved_files": [
"status.json"
]
}
@@ -0,0 +1,8 @@
[
{
"name": "requestId",
"type": "input-text",
"label": "读取请求标识",
"default": ""
}
]
+6 -1
View File
@@ -4,7 +4,12 @@ cd /d "%~dp0"
set PYTHON=%~dp0.venv-tts\Scripts\python.exe
if not exist "%PYTHON%" (
echo Python virtual environment not found: %PYTHON%
set PYTHON=%~dp0.venv\Scripts\python.exe
)
if not exist "%PYTHON%" (
echo Python virtual environment not found.
echo Checked .venv-tts and .venv under: %~dp0
echo Please run setup first or ask WorkBuddy to repair dependencies.
pause
exit /b 1
+4 -1
View File
@@ -32,6 +32,8 @@ function Invoke-Checked([string]$File, [string[]]$Arguments) {
function Get-ProjectPython {
$candidates = @(
$env:LIVE_STREAMING_PYTHON,
(Join-Path $Root ".venv-tts\Scripts\python.exe"),
(Join-Path $Root ".venv\Scripts\python.exe"),
"E:\Programs\Anaconda3\envs\Live-streaming\python.exe",
"python"
) | Where-Object { $_ }
@@ -45,7 +47,7 @@ function Get-ProjectPython {
} catch {
}
}
throw "No usable Python found. Install Python 3.10+, set LIVE_STREAMING_PYTHON, or use the Live-streaming conda env."
throw "No usable Python found. Create .venv, install Python 3.10+, or set LIVE_STREAMING_PYTHON."
}
$Python = Get-ProjectPython
@@ -197,6 +199,7 @@ New-Item -ItemType Directory -Force -Path (Join-Path $AppDist "logs") | Out-Null
Copy-Item -LiteralPath (Join-Path $Root "web") -Destination (Join-Path $AppDist "web") -Recurse -Force
Copy-Item -LiteralPath (Join-Path $Root "config") -Destination (Join-Path $AppDist "config") -Recurse -Force
Copy-Item -LiteralPath (Join-Path $Root "integrations") -Destination (Join-Path $AppDist "integrations") -Recurse -Force
Copy-Item -LiteralPath (Join-Path $Root "docs") -Destination (Join-Path $AppDist "docs") -Recurse -Force
Copy-Item -LiteralPath (Join-Path $Root "vendor") -Destination (Join-Path $AppDist "vendor") -Recurse -Force
Copy-Item -LiteralPath (Join-Path $Root "README.md") -Destination (Join-Path $AppDist "README.md") -Force
+102
View File
@@ -0,0 +1,102 @@
import json
import tempfile
import unittest
from pathlib import Path
from app.bettergi_current_party import (
CURRENT_PARTY_SCRIPT_NAME,
CurrentPartyReadError,
clear_current_party_status,
prepare_current_party_read,
read_current_party_status,
)
class BetterGICurrentPartyTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name)
self.group_dir = self.root / "User" / "ScriptGroup"
self._write_json(
self.group_dir / "切换队伍.json",
{
"index": 2,
"name": "切换队伍",
"config": {"marker": "preserved"},
"projects": [{"name": "旧项目"}],
},
)
self._write_json(
self.group_dir / "其他.json",
{"index": 5, "name": "其他", "config": {}, "projects": []},
)
def tearDown(self):
self.temp_dir.cleanup()
@staticmethod
def _write_json(path: Path, data):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def test_prepare_reader_group_clones_config_and_uses_request_id(self):
prepared = prepare_current_party_read(self.root, "request-123")
update = prepared.updates[0]
self.assertEqual(update.data["index"], 6)
self.assertEqual(update.data["config"]["marker"], "preserved")
self.assertEqual(update.data["name"], "直播系统读取当前队伍")
project = update.data["projects"][0]
self.assertEqual(project["folderName"], CURRENT_PARTY_SCRIPT_NAME)
self.assertEqual(project["jsScriptSettingsObject"]["requestId"], "request-123")
self.assertEqual(
prepared.status_path,
self.root / "User" / "JsScript" / CURRENT_PARTY_SCRIPT_NAME / "status.json",
)
def test_status_reader_ignores_stale_and_running_results(self):
prepared = prepare_current_party_read(self.root, "request-123")
self._write_json(
prepared.status_path,
{"state": "success", "request_id": "old", "party_name": "旧队伍"},
)
self.assertIsNone(read_current_party_status(prepared.status_path, "request-123"))
self._write_json(
prepared.status_path,
{"state": "running", "request_id": "request-123"},
)
self.assertIsNone(read_current_party_status(prepared.status_path, "request-123"))
self._write_json(
prepared.status_path,
{
"state": "success",
"request_id": "request-123",
"party_name": "好感队",
"candidates": ["好感队"],
},
)
result = read_current_party_status(prepared.status_path, "request-123")
self.assertEqual(result.party_name, "好感队")
self.assertEqual(result.candidates, ("好感队",))
def test_status_reader_reports_script_error_and_can_be_cleared(self):
prepared = prepare_current_party_read(self.root, "request-123")
self._write_json(
prepared.status_path,
{
"state": "error",
"request_id": "request-123",
"message": "识别到多个候选",
},
)
with self.assertRaisesRegex(CurrentPartyReadError, "多个候选"):
read_current_party_status(prepared.status_path, "request-123")
clear_current_party_status(prepared.status_path)
self.assertFalse(prepared.status_path.exists())
if __name__ == "__main__":
unittest.main()
+503
View File
@@ -0,0 +1,503 @@
import json
import tempfile
import unittest
from pathlib import Path
from app.bettergi_daily import (
DAILY_MODE_COMMISSION,
DAILY_MODE_DOMAIN,
DAILY_MODE_LEY_LINE,
DAILY_MODE_NONE,
DailyAutomationError,
DomainAliasResolver,
apply_json_updates,
parse_daily_request,
prepare_commission_current_party_update,
prepare_daily_run,
prepare_edit_party_update,
prepare_switch_party_update,
resolve_party_members,
)
class BetterGIDailyTestCase(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name)
self.alias_path = self.root / "domain_aliases.json"
self._write_json(
self.alias_path,
{
"_说明": "test",
"铭记之谷": ["风本", "少女套"],
},
)
self._write_json(
self.root / "User" / "OneDragon" / "默认配置.json",
{
"Name": "默认配置",
"TaskEnabledList": {
"mail-template-id": True,
"serenitea-template-id": True,
"craft-template-id": True,
"domain-template-id": False,
"ley-line-template-id": False,
"daily-reward-template-id": True,
},
"TaskOrder": [
"mail-template-id",
"serenitea-template-id",
"craft-template-id",
"domain-template-id",
"ley-line-template-id",
"daily-reward-template-id",
],
"TaskDefinitions": {
"mail-template-id": "领取邮件",
"serenitea-template-id": "领取尘歌壶奖励",
"craft-template-id": "合成树脂",
"domain-template-id": "自动秘境",
"ley-line-template-id": "自动地脉花",
"daily-reward-template-id": "领取每日奖励",
},
"CraftingBenchCountry": "枫丹",
"AdventurersGuildCountry": "璃月",
"PartyName": "战斗队",
"DailyRewardPartyName": "好感队",
"SecretTreasureObjects": ["须臾树脂"],
"WeeklyDomainEnabled": True,
"DomainName": "旧秘境",
"CompletionAction": "关机",
},
)
self._write_json(
self.root / "User" / "config.json",
{
"marker": "preserved",
"autoFightConfig": {"strategyName": "策略A"},
"autoDomainConfig": {
"specifyResinUse": True,
"other": 1,
},
"autoLeyLineOutcropConfig": {
"isGoToSynthesizer": True,
"team": "战斗队",
"friendshipTeam": "",
"fightConfig": {"strategyName": "策略A"},
},
},
)
strategy = self.root / "User" / "AutoFight" / "策略A.txt"
strategy.parent.mkdir(parents=True, exist_ok=True)
strategy.write_text("战斗策略", encoding="utf-8")
self._write_json(
self.root / "User" / "JsScript" / "AutoDomain" / "settings.json",
[
{
"name": "domainName",
"type": "select",
"options": ["铭记之谷", "仲夏庭园"],
}
],
)
self._write_json(
self.root / "User" / "ScriptGroup" / "每日委托.json",
{"name": "每日委托", "projects": [{"name": "委托", "status": "Enabled"}]},
)
self._write_json(
self.root / "User" / "ScriptGroup" / "切换队伍.json",
{
"name": "切换队伍",
"projects": [
{
"folderName": "AcceleratedEditionSwitchParty",
"status": "Enabled",
"jsScriptSettingsObject": {"partyName": "旧队伍", "debug": True},
}
],
},
)
self._write_json(
self.root / "User" / "ScriptGroup" / "修改队员.json",
{
"name": "修改队员",
"projects": [
{
"folderName": "AutoSwitchRoles",
"status": "Enabled",
"jsScriptSettingsObject": {"position1": ""},
}
],
},
)
options = [
"水-单手剑-芙宁娜",
"冰-长枪-爱可菲",
"草-法器-纳西妲",
"雷-长枪-雷电将军",
"岩-长枪-钟离",
]
self._write_json(
self.root / "User" / "JsScript" / "AutoSwitchRoles" / "settings.json",
[
{"name": f"position{index}", "type": "select", "options": ["", *options]}
for index in range(1, 5)
],
)
def tearDown(self):
self.temp_dir.cleanup()
@staticmethod
def _write_json(path: Path, data):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def _prepare(
self,
argument: str,
*,
craft_before: bool = True,
commission_use_current_party: bool = False,
):
return prepare_daily_run(
self.root,
self.alias_path,
argument,
template_name="默认配置",
managed_name="直播系统自动每日",
ley_line_craft_resin_before=craft_before,
commission_use_current_party=commission_use_current_party,
)
def test_parse_daily_modes(self):
self.assertEqual(parse_daily_request("").mode, DAILY_MODE_NONE)
domain = parse_daily_request("秘境 风本")
self.assertEqual((domain.mode, domain.domain_name), (DAILY_MODE_DOMAIN, "风本"))
ley_line = parse_daily_request("地脉 经验 蒙德")
self.assertEqual(
(ley_line.mode, ley_line.ley_line_type, ley_line.ley_line_country),
(DAILY_MODE_LEY_LINE, "启示之花", "蒙德"),
)
self.assertEqual(parse_daily_request("委托").mode, DAILY_MODE_COMMISSION)
def test_parse_rejects_missing_or_unknown_arguments(self):
with self.assertRaisesRegex(DailyAutomationError, "需要指定秘境"):
parse_daily_request("秘境")
with self.assertRaisesRegex(DailyAutomationError, "地脉模式格式"):
parse_daily_request("地脉 经验")
with self.assertRaisesRegex(DailyAutomationError, "不支持的地脉国家"):
parse_daily_request("地脉 摩拉 天空岛")
with self.assertRaisesRegex(DailyAutomationError, "仅支持"):
parse_daily_request("探索")
def test_domain_alias_resolves_canonical_and_common_names(self):
resolver = DomainAliasResolver(self.alias_path, self.root)
self.assertEqual(resolver.resolve("风本"), "铭记之谷")
self.assertEqual(resolver.resolve("少女套"), "铭记之谷")
self.assertEqual(resolver.resolve("仲夏庭园"), "仲夏庭园")
def test_domain_alias_collision_is_rejected(self):
self._write_json(
self.alias_path,
{
"铭记之谷": ["风本"],
"仲夏庭园": ["风 本"],
},
)
with self.assertRaisesRegex(DailyAutomationError, "同时指向"):
DomainAliasResolver(self.alias_path, self.root).resolve("风本")
def test_domain_run_generates_exact_flow_and_corrects_bgi_config(self):
prepared = self._prepare("秘境 风本")
self.assertEqual(prepared.request.domain_name, "铭记之谷")
self.assertEqual(prepared.task_name, "自动每日(秘境:铭记之谷)")
updates = {update.path.name: update for update in prepared.updates}
corrected = updates["config.json"].data
self.assertFalse(corrected["autoDomainConfig"]["specifyResinUse"])
self.assertEqual(corrected["autoDomainConfig"]["other"], 1)
self.assertEqual(corrected["marker"], "preserved")
managed = updates["直播系统自动每日.json"].data
names = [managed["TaskDefinitions"][task_id] for task_id in managed["TaskOrder"]]
self.assertEqual(
names,
["领取邮件", "合成树脂", "自动秘境", "领取尘歌壶奖励", "领取每日奖励"],
)
self.assertEqual(
managed["TaskOrder"],
[
"mail-template-id",
"craft-template-id",
"domain-template-id",
"serenitea-template-id",
"daily-reward-template-id",
],
)
self.assertFalse(managed["WeeklyDomainEnabled"])
self.assertEqual(managed["DomainName"], "铭记之谷")
self.assertEqual(managed["CompletionAction"], "")
self.assertEqual(managed["PartyName"], "战斗队")
def test_no_mode_skips_only_the_other_task(self):
prepared = self._prepare("")
managed = prepared.updates[-1].data
names = [managed["TaskDefinitions"][task_id] for task_id in managed["TaskOrder"]]
self.assertEqual(
names,
["领取邮件", "合成树脂", "领取尘歌壶奖励", "领取每日奖励"],
)
def test_ley_line_run_sets_all_days_and_respects_craft_toggle(self):
prepared = self._prepare("地脉 摩拉 枫丹", craft_before=False)
updates = {update.path.name: update for update in prepared.updates}
corrected = updates["config.json"].data
self.assertFalse(corrected["autoLeyLineOutcropConfig"]["isGoToSynthesizer"])
managed = updates["直播系统自动每日.json"].data
names = [managed["TaskDefinitions"][task_id] for task_id in managed["TaskOrder"]]
self.assertEqual(
names,
["领取邮件", "自动地脉花", "领取尘歌壶奖励", "领取每日奖励"],
)
self.assertTrue(managed["LeyLineResinExhaustionMode"])
self.assertFalse(managed["LeyLineOpenModeCountMin"])
for day in ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"):
self.assertTrue(managed[f"LeyLineRun{day}"])
self.assertEqual(managed[f"LeyLine{day}Type"], "藏金之花")
self.assertEqual(managed[f"LeyLine{day}Country"], "枫丹")
def test_commission_is_added_as_a_configuration_group_task(self):
prepared = self._prepare("委托")
managed = prepared.updates[-1].data
names = [managed["TaskDefinitions"][task_id] for task_id in managed["TaskOrder"]]
self.assertEqual(
names,
["领取邮件", "合成树脂", "每日委托", "领取尘歌壶奖励", "领取每日奖励"],
)
commission_id = next(
task_id
for task_id, name in managed["TaskDefinitions"].items()
if name == "每日委托"
)
self.assertNotIn(
commission_id,
{
"mail-template-id",
"craft-template-id",
"serenitea-template-id",
"daily-reward-template-id",
},
)
def test_commission_requires_nonempty_group(self):
(self.root / "User" / "ScriptGroup" / "每日委托.json").unlink()
with self.assertRaisesRegex(DailyAutomationError, "每日委托配置组"):
self._prepare("委托")
def test_auto_commission_nova_requires_complete_first_run_config(self):
self._write_json(
self.root / "User" / "ScriptGroup" / "每日委托.json",
{
"name": "每日委托",
"projects": [
{
"folderName": "AutoCommissionNova",
"status": "Enabled",
}
],
},
)
with self.assertRaisesRegex(DailyAutomationError, "缺少 Data/user-config.json"):
self._prepare("委托")
user_config_path = (
self.root
/ "User"
/ "JsScript"
/ "AutoCommissionNova"
/ "Data"
/ "user-config.json"
)
self._write_json(
user_config_path,
{
"party": {
"global": {
"battleTeamName": "战斗队",
"elementTeamName": "",
"battleStrategy": "策略A",
}
}
},
)
with self.assertRaisesRegex(DailyAutomationError, "元素采集队伍"):
self._prepare("委托")
self._write_json(
user_config_path,
{
"party": {
"global": {
"battleTeamName": "战斗队",
"elementTeamName": "采集队",
"battleStrategy": "策略A",
}
}
},
)
self.assertEqual(self._prepare("委托").request.mode, DAILY_MODE_COMMISSION)
def test_auto_commission_nova_can_inject_current_party_for_battle_and_collection(self):
self._write_json(
self.root / "User" / "ScriptGroup" / "每日委托.json",
{
"name": "每日委托",
"projects": [
{
"folderName": "AutoCommissionNova",
"status": "Enabled",
}
],
},
)
user_config_path = (
self.root
/ "User"
/ "JsScript"
/ "AutoCommissionNova"
/ "Data"
/ "user-config.json"
)
self._write_json(
user_config_path,
{
"marker": "preserved",
"party": {
"global": {
"battleTeamName": "",
"elementTeamName": "",
"battleStrategy": "策略A",
}
},
},
)
prepared = self._prepare("委托", commission_use_current_party=True)
self.assertTrue(prepared.requires_current_party)
update = prepare_commission_current_party_update(self.root, "当前队伍")
self.assertEqual(
update.data["party"]["global"]["battleTeamName"],
"当前队伍",
)
self.assertEqual(
update.data["party"]["global"]["elementTeamName"],
"当前队伍",
)
self.assertEqual(update.data["marker"], "preserved")
def test_switch_party_updates_only_target_setting(self):
update = prepare_switch_party_update(self.root, "深渊队")
project = update.data["projects"][0]
self.assertEqual(project["jsScriptSettingsObject"]["partyName"], "深渊队")
self.assertTrue(project["jsScriptSettingsObject"]["debug"])
def test_edit_party_accepts_separators_and_contiguous_names(self):
separated, display = resolve_party_members(
self.root,
"芙宁娜 爱可菲 纳西妲 雷电将军",
)
self.assertEqual(display, ["芙宁娜", "爱可菲", "纳西妲", "雷电将军"])
contiguous, contiguous_display = resolve_party_members(
self.root,
"芙宁娜爱可菲纳西妲雷电将军",
)
self.assertEqual(contiguous, separated)
self.assertEqual(contiguous_display, display)
update, names = prepare_edit_party_update(
self.root,
"芙宁娜、爱可菲、纳西妲、雷电将军",
)
settings = update.data["projects"][0]["jsScriptSettingsObject"]
self.assertEqual(names, ("芙宁娜", "爱可菲", "纳西妲", "雷电将军"))
self.assertEqual(settings["position4"], "雷-长枪-雷电将军")
def test_edit_party_supports_auto_switch_roles_67_character_data(self):
self._write_json(
self.root / "User" / "JsScript" / "AutoSwitchRoles" / "settings.json",
[
{"name": f"position{index}", "type": "input-text", "default": ""}
for index in range(1, 5)
],
)
self._write_json(
self.root / "User" / "JsScript" / "AutoSwitchRoles" / "combat_avatar.json",
[
{"name": "神里绫华", "alias": ["绫华"]},
{"name": "申鹤", "alias": []},
{"name": "枫原万叶", "alias": ["万叶"]},
{"name": "珊瑚宫心海", "alias": ["心海"]},
],
)
update, names = prepare_edit_party_update(
self.root,
"绫华 申鹤 万叶 心海",
)
settings = update.data["projects"][0]["jsScriptSettingsObject"]
self.assertEqual(names, ("神里绫华", "申鹤", "枫原万叶", "珊瑚宫心海"))
self.assertEqual(
[settings[f"position{index}"] for index in range(1, 5)],
["神里绫华", "申鹤", "枫原万叶", "珊瑚宫心海"],
)
resolved, display = resolve_party_members(
self.root,
"神里绫华申鹤枫原万叶珊瑚宫心海",
)
self.assertEqual(resolved, list(display))
def test_edit_party_rejects_ambiguous_combat_avatar_alias(self):
self._write_json(
self.root / "User" / "JsScript" / "AutoSwitchRoles" / "settings.json",
[
{"name": f"position{index}", "type": "input-text", "default": ""}
for index in range(1, 5)
],
)
self._write_json(
self.root / "User" / "JsScript" / "AutoSwitchRoles" / "combat_avatar.json",
[
{"name": "角色甲", "alias": ["同名"]},
{"name": "角色乙", "alias": ["同名"]},
{"name": "角色丙", "alias": []},
{"name": "角色丁", "alias": []},
{"name": "角色戊", "alias": []},
],
)
with self.assertRaisesRegex(DailyAutomationError, "未知或有歧义"):
resolve_party_members(self.root, "同名 角色丙 角色丁 角色戊")
def test_edit_party_requires_four_distinct_known_members(self):
with self.assertRaisesRegex(DailyAutomationError, "必须是4人"):
resolve_party_members(self.root, "芙宁娜 爱可菲 纳西妲")
with self.assertRaisesRegex(DailyAutomationError, "不能重复"):
resolve_party_members(self.root, "芙宁娜 芙宁娜 纳西妲 雷电将军")
with self.assertRaisesRegex(DailyAutomationError, "未知"):
resolve_party_members(self.root, "芙宁娜 爱可菲 纳西妲 不存在")
def test_apply_json_updates_writes_managed_files(self):
prepared = self._prepare("秘境 风本")
apply_json_updates(prepared.updates)
bgi_config = json.loads((self.root / "User" / "config.json").read_text(encoding="utf-8"))
managed = json.loads(
(self.root / "User" / "OneDragon" / "直播系统自动每日.json").read_text(encoding="utf-8")
)
self.assertFalse(bgi_config["autoDomainConfig"]["specifyResinUse"])
self.assertEqual(managed["DomainName"], "铭记之谷")
if __name__ == "__main__":
unittest.main()
+43 -1
View File
@@ -1,8 +1,10 @@
import asyncio
import logging
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
from app.danmu_queue import BgiLogMonitor, WebServer
@@ -69,5 +71,45 @@ class BgiLogDisplayFormatTests(unittest.TestCase):
)
class BgiLogCompletionTests(unittest.IsolatedAsyncioTestCase):
async def test_configuration_group_marker_still_finishes_current_group(self):
monitor = BgiLogMonitor(".", logging.getLogger("test-bgi-group-log"))
callback = AsyncMock()
monitor.set_finish_callback(callback)
with patch.object(monitor, "_get_current_log_size", return_value=0):
monitor.set_current_group("薄荷", "group-run")
monitor._process_line('配置组 "薄荷" 执行结束')
await asyncio.sleep(0)
callback.assert_awaited_once_with("薄荷", "group-run")
self.assertIsNone(monitor._current_group)
async def test_one_dragon_marker_finishes_managed_daily(self):
monitor = BgiLogMonitor(".", logging.getLogger("test-bgi-one-dragon-log"))
callback = AsyncMock()
monitor.set_finish_callback(callback)
with patch.object(monitor, "_get_current_log_size", return_value=0):
monitor.set_current_one_dragon("自动每日(委托)", "daily-run")
monitor._process_line("一条龙和配置组任务结束")
await asyncio.sleep(0)
callback.assert_awaited_once_with("自动每日(委托)", "daily-run")
self.assertIsNone(monitor._current_group)
async def test_child_group_completion_does_not_finish_one_dragon_early(self):
monitor = BgiLogMonitor(".", logging.getLogger("test-bgi-child-group-log"))
callback = AsyncMock()
monitor.set_finish_callback(callback)
with patch.object(monitor, "_get_current_log_size", return_value=0):
monitor.set_current_one_dragon("自动每日", "daily-run")
monitor._process_line('配置组 "每日委托" 执行结束')
await asyncio.sleep(0)
callback.assert_not_awaited()
self.assertEqual(monitor._current_group, "自动每日")
if __name__ == "__main__":
unittest.main()
+353
View File
@@ -0,0 +1,353 @@
import asyncio
import logging
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, call, patch
from app.bettergi_current_party import (
CurrentPartyReadError,
CurrentPartyReadResult,
PreparedCurrentPartyRead,
)
from app.bettergi_daily import (
DailyAutomationError,
DailyRequest,
JsonUpdate,
PreparedDailyRun,
)
from app.danmu_queue import (
COMMAND_ALLOWED_ROLE_DEFAULTS,
BetterGIRunner,
CommandHandler,
)
class DailyCommandIntegrationTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name)
self.config = SimpleNamespace(
data={
"commands": {
"run": {"enabled": True, "aliases": ["执行", ""]},
"daily": {"enabled": True, "aliases": ["自动每日"]},
"switch_party": {
"enabled": True,
"aliases": ["切换队伍", "更换队伍"],
},
"edit_party": {
"enabled": True,
"aliases": ["修改队员", "更换队员"],
},
}
},
admin_uids=set(),
bettergi_work_dir=str(self.root),
daily_cfg={
"one_dragon_template": "默认配置",
"managed_one_dragon_name": "直播系统自动每日",
"ley_line_craft_resin_before": True,
"commission_use_current_party": True,
"current_party_read_timeout_sec": 45,
},
broadcast_cfg={"tts_categories": {"execution": True}},
music_monitor_cfg={"request_player": {}},
)
self.queue_mgr = MagicMock()
self.queue_mgr.state = {
"current_admin_uid": 123,
"login_status": "logged_in",
"current_group": None,
"default_running": False,
}
self.queue_mgr.is_admin.return_value = True
self.queue_mgr.start_group.return_value = {
"success": True,
"msg": "ok",
"run_id": "daily-run",
}
self.runner = MagicMock()
self.runner.kill_bgi = AsyncMock()
self.runner.start_groups = AsyncMock(return_value=True)
self.runner.start_one_dragon = AsyncMock(return_value=True)
self.runner.sync_js_script.return_value = True
self.log_monitor = MagicMock()
self.broadcaster = SimpleNamespace(broadcast=AsyncMock())
self.handler = CommandHandler(
self.config,
MagicMock(),
self.queue_mgr,
self.runner,
self.log_monitor,
MagicMock(),
logging.getLogger("test-daily-command"),
broadcaster=self.broadcaster,
)
def tearDown(self):
self.temp_dir.cleanup()
def _prepared_daily(self, *, requires_current_party: bool = False) -> PreparedDailyRun:
return PreparedDailyRun(
request=DailyRequest(mode="commission" if requires_current_party else "none"),
config_name="直播系统自动每日",
updates=(
JsonUpdate(
self.root / "User" / "OneDragon" / "直播系统自动每日.json",
{"Name": "直播系统自动每日"},
"test",
),
),
requires_current_party=requires_current_party,
)
async def test_daily_replaces_old_task_writes_config_and_starts_one_dragon(self):
self.queue_mgr.state["current_group"] = "旧任务"
prepared = self._prepared_daily()
with (
patch("app.danmu_queue.prepare_daily_run", return_value=prepared) as prepare,
patch("app.danmu_queue.apply_json_updates") as apply_updates,
patch("app.danmu_queue.uuid.uuid4", return_value=SimpleNamespace(hex="daily-run")),
):
await self.handler._cmd_daily(123, "测试用户", "")
prepare.assert_called_once()
self.queue_mgr.interrupt_group.assert_called_once_with(
"daily_replaced",
status="cancelled",
)
self.runner.kill_bgi.assert_awaited_once_with(reason="自动每日配置更新")
apply_updates.assert_called_once_with(prepared.updates)
self.queue_mgr.start_group.assert_called_once_with(
123,
"自动每日",
run_id="daily-run",
)
self.log_monitor.set_current_one_dragon.assert_called_once_with(
"自动每日",
"daily-run",
)
self.runner.start_one_dragon.assert_awaited_once_with("直播系统自动每日")
async def test_daily_validation_failure_does_not_stop_current_task(self):
self.queue_mgr.state["current_group"] = "旧任务"
with patch(
"app.danmu_queue.prepare_daily_run",
side_effect=DailyAutomationError("未找到一条龙模板"),
):
await self.handler._cmd_daily(123, "测试用户", "秘境 风本")
self.queue_mgr.interrupt_group.assert_not_called()
self.runner.kill_bgi.assert_not_awaited()
self.runner.start_one_dragon.assert_not_awaited()
async def test_daily_commission_reads_current_party_before_starting(self):
prepared = self._prepared_daily(requires_current_party=True)
reader_update = JsonUpdate(
self.root / "User" / "ScriptGroup" / "直播系统读取当前队伍.json",
{"projects": []},
"reader",
)
reader = PreparedCurrentPartyRead(
request_id="daily-run",
group_name="直播系统读取当前队伍",
status_path=self.root / "status.json",
updates=(reader_update,),
)
party_update = JsonUpdate(
self.root / "User" / "JsScript" / "AutoCommissionNova" / "Data" / "user-config.json",
{
"party": {
"global": {
"battleTeamName": "好感队",
"elementTeamName": "好感队",
}
}
},
"party",
)
capture = AsyncMock(return_value="好感队")
with (
patch("app.danmu_queue.prepare_daily_run", return_value=prepared) as prepare,
patch("app.danmu_queue.prepare_current_party_read", return_value=reader),
patch(
"app.danmu_queue.prepare_commission_current_party_update",
return_value=party_update,
) as prepare_party,
patch("app.danmu_queue.apply_json_updates") as apply_updates,
patch.object(self.handler, "_capture_current_party_name", capture),
patch("app.danmu_queue.uuid.uuid4", return_value=SimpleNamespace(hex="daily-run")),
):
await self.handler._cmd_daily(123, "测试用户", "委托")
self.assertTrue(prepare.call_args.kwargs["commission_use_current_party"])
capture.assert_awaited_once_with(reader, 45)
prepare_party.assert_called_once_with(str(self.root), "好感队")
apply_updates.assert_called_once_with((party_update, *prepared.updates))
self.runner.start_one_dragon.assert_awaited_once_with("直播系统自动每日")
async def test_daily_commission_reader_failure_does_not_start_billed_task(self):
prepared = self._prepared_daily(requires_current_party=True)
reader = PreparedCurrentPartyRead(
request_id="daily-run",
group_name="直播系统读取当前队伍",
status_path=self.root / "status.json",
updates=(),
)
with (
patch("app.danmu_queue.prepare_daily_run", return_value=prepared),
patch("app.danmu_queue.prepare_current_party_read", return_value=reader),
patch.object(
self.handler,
"_capture_current_party_name",
new=AsyncMock(side_effect=CurrentPartyReadError("未识别到队伍名称")),
),
patch("app.danmu_queue.apply_json_updates") as apply_updates,
patch("app.danmu_queue.uuid.uuid4", return_value=SimpleNamespace(hex="daily-run")),
):
await self.handler._cmd_daily(123, "测试用户", "委托")
apply_updates.assert_not_called()
self.queue_mgr.start_group.assert_not_called()
self.runner.start_one_dragon.assert_not_awaited()
async def test_current_party_capture_syncs_group_and_waits_for_matching_status(self):
reader_update = JsonUpdate(
self.root / "User" / "ScriptGroup" / "直播系统读取当前队伍.json",
{"projects": []},
"reader",
)
reader = PreparedCurrentPartyRead(
request_id="request-123",
group_name="直播系统读取当前队伍",
status_path=self.root / "status.json",
updates=(reader_update,),
)
with (
patch("app.danmu_queue.apply_json_updates") as apply_updates,
patch(
"app.danmu_queue.read_current_party_status",
return_value=CurrentPartyReadResult("好感队", ("好感队",)),
),
patch("app.danmu_queue.asyncio.sleep", new=AsyncMock()),
):
party_name = await self.handler._capture_current_party_name(reader, 45)
self.assertEqual(party_name, "好感队")
self.runner.sync_js_script.assert_called_once_with("LiveCurrentParty")
apply_updates.assert_called_once_with(reader.updates)
self.runner.start_groups.assert_awaited_once_with(["直播系统读取当前队伍"])
async def test_daily_requires_confirmed_login_before_preparing_files(self):
self.queue_mgr.state["login_status"] = "confirming"
with patch("app.danmu_queue.prepare_daily_run") as prepare:
await self.handler._cmd_daily(123, "测试用户", "")
prepare.assert_not_called()
self.runner.kill_bgi.assert_not_awaited()
async def test_daily_start_failure_clears_monitor_and_enters_reset_flow(self):
prepared = self._prepared_daily()
self.runner.start_one_dragon.return_value = False
reset = AsyncMock()
self.handler.system = SimpleNamespace(_reset_wait_and_retry_login=reset)
with (
patch("app.danmu_queue.prepare_daily_run", return_value=prepared),
patch("app.danmu_queue.apply_json_updates"),
patch("app.danmu_queue.uuid.uuid4", return_value=SimpleNamespace(hex="daily-run")),
):
await self.handler._cmd_daily(123, "测试用户", "")
self.log_monitor.set_current_one_dragon.assert_has_calls(
[call("自动每日", "daily-run"), call(None)]
)
reset.assert_awaited_once_with(
123,
"测试用户",
"自动每日启动失败,正在关闭原神并启动扫码上号",
)
async def test_switch_party_updates_group_then_reuses_group_execution(self):
update = JsonUpdate(
self.root / "User" / "ScriptGroup" / "切换队伍.json",
{"projects": []},
"test",
)
with (
patch("app.danmu_queue.prepare_switch_party_update", return_value=update),
patch("app.danmu_queue.apply_json_updates") as apply_updates,
patch("app.danmu_queue.uuid.uuid4", return_value=SimpleNamespace(hex="party-run")),
patch.object(
self.handler,
"_resolve_group_name",
return_value={
"success": True,
"name": "切换队伍",
"matched": False,
"suggestions": [],
},
),
patch.object(self.handler, "_group_uses_nahida_collect", return_value=False),
):
await self.handler._cmd_switch_party(123, "测试用户", "永冻队")
apply_updates.assert_called_once_with((update,))
self.queue_mgr.start_group.assert_called_once_with(
123,
"切换队伍",
run_id="party-run",
)
self.runner.start_groups.assert_awaited_once_with(["切换队伍"])
def test_execution_command_aliases_support_no_space_arguments(self):
self.assertEqual(self.handler._split_command_text("执行薄荷"), ("执行", "薄荷"))
self.assertEqual(
self.handler._split_command_text("自动每日秘境 风本"),
("自动每日", "秘境 风本"),
)
self.assertEqual(
self.handler._split_command_text("更换队伍永冻队"),
("更换队伍", "永冻队"),
)
self.assertEqual(
self.handler._split_command_text("修改队员芙宁娜纳西妲钟离雷电将军"),
("修改队员", "芙宁娜纳西妲钟离雷电将军"),
)
def test_new_command_permissions_match_run(self):
for key in ("daily", "switch_party", "edit_party"):
self.assertEqual(
COMMAND_ALLOWED_ROLE_DEFAULTS[key],
COMMAND_ALLOWED_ROLE_DEFAULTS["run"],
)
class BetterGIRunnerOneDragonTests(unittest.IsolatedAsyncioTestCase):
async def test_start_one_dragon_uses_supported_cli_shape(self):
runner = BetterGIRunner(
r"C:\BetterGI\BetterGI.exe",
r"C:\BetterGI",
logging.getLogger("test-one-dragon-runner"),
)
create_process = AsyncMock(return_value=SimpleNamespace())
with (
patch("app.danmu_queue.asyncio.create_subprocess_exec", create_process),
patch("app.danmu_queue.asyncio.sleep", new=AsyncMock()),
):
result = await runner.start_one_dragon("直播系统自动每日")
self.assertTrue(result)
create_process.assert_awaited_once_with(
r"C:\BetterGI\BetterGI.exe",
"startOneDragon",
"直播系统自动每日",
cwd=r"C:\BetterGI",
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
if __name__ == "__main__":
unittest.main()
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>直播联动后台</title>
<script type="module" crossorigin src="/admin/assets/index-CIcFYlv1.js"></script>
<script type="module" crossorigin src="/admin/assets/index-BdYZGJMQ.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-BB8VhdaG.css">
</head>
<body>