diff --git a/API_CONFIG_REFACTOR.md b/API_CONFIG_REFACTOR.md
new file mode 100644
index 00000000..8c137373
--- /dev/null
+++ b/API_CONFIG_REFACTOR.md
@@ -0,0 +1,270 @@
+# 配置重构 API 迁移说明
+
+本文档记录 2026-04 配置重构相关 API 的破坏性变更,供前端组统一适配。
+
+## 总体规则
+
+1. 旧的 `/get`、`/add`、`/update`、`/delete`、`/time/*`、`/item/*` 兼容路由已移除,只保留新的 REST 风格路径。
+2. 创建接口的返回字段统一为 `id`,不再返回 `emulatorId`、`queueId`、`timeSetId`、`queueItemId`、`webhookId` 这类资源专属字段。
+3. 单资源查询统一返回:
+
+```json
+{
+ "code": 200,
+ "status": "success",
+ "message": "操作成功",
+ "data": {}
+}
+```
+
+4. 集合查询统一返回:
+
+```json
+{
+ "code": 200,
+ "status": "success",
+ "message": "操作成功",
+ "index": [],
+ "data": {}
+}
+```
+
+5. 创建接口统一返回:
+
+```json
+{
+ "code": 200,
+ "status": "success",
+ "message": "操作成功",
+ "id": "资源ID",
+ "data": {}
+}
+```
+
+6. 更新接口的请求体直接传 `Patch` 数据,不再包一层 `{ "data": ... }`。
+7. 排序接口统一为 `PATCH .../order`,请求体统一为:
+
+```json
+{
+ "indexList": ["id1", "id2"]
+}
+```
+
+## 模拟器 API
+
+旧接口:
+`POST /api/emulator/get`
+`POST /api/emulator/add`
+`POST /api/emulator/update`
+`POST /api/emulator/delete`
+`POST /api/emulator/order`
+`POST /api/emulator/operate`
+`POST /api/emulator/status`
+`POST /api/emulator/emulator/search`
+
+新接口:
+`GET /api/emulator`
+`POST /api/emulator`
+`PATCH /api/emulator/order`
+`GET /api/emulator/detected`
+`GET /api/emulator/status`
+`GET /api/emulator/{emulator_id}`
+`PATCH /api/emulator/{emulator_id}`
+`DELETE /api/emulator/{emulator_id}`
+`GET /api/emulator/{emulator_id}/status`
+`POST /api/emulator/{emulator_id}/actions/{action}`
+
+动作接口说明:
+`action` 取值为 `open`、`close`、`show`
+请求体只保留:
+
+```json
+{
+ "index": "0"
+}
+```
+
+## 调度队列 API
+
+旧接口:
+`POST /api/queue/add`
+`POST /api/queue/get`
+`POST /api/queue/update`
+`POST /api/queue/delete`
+`POST /api/queue/order`
+`POST /api/queue/time/get`
+`POST /api/queue/time/add`
+`POST /api/queue/time/update`
+`POST /api/queue/time/delete`
+`POST /api/queue/time/order`
+`POST /api/queue/item/get`
+`POST /api/queue/item/add`
+`POST /api/queue/item/update`
+`POST /api/queue/item/delete`
+`POST /api/queue/item/order`
+
+新接口:
+`GET /api/queue`
+`POST /api/queue`
+`PATCH /api/queue/order`
+`GET /api/queue/{queue_id}`
+`PATCH /api/queue/{queue_id}`
+`DELETE /api/queue/{queue_id}`
+`GET /api/queue/{queue_id}/times`
+`POST /api/queue/{queue_id}/times`
+`PATCH /api/queue/{queue_id}/times/order`
+`GET /api/queue/{queue_id}/times/{time_set_id}`
+`PATCH /api/queue/{queue_id}/times/{time_set_id}`
+`DELETE /api/queue/{queue_id}/times/{time_set_id}`
+`GET /api/queue/{queue_id}/items`
+`POST /api/queue/{queue_id}/items`
+`PATCH /api/queue/{queue_id}/items/order`
+`GET /api/queue/{queue_id}/items/{queue_item_id}`
+`PATCH /api/queue/{queue_id}/items/{queue_item_id}`
+`DELETE /api/queue/{queue_id}/items/{queue_item_id}`
+
+关键变化:
+单个队列、单个定时项、单个队列项不再返回 `index + data map`,只返回单个 `data`。
+嵌套资源更新和删除不再在 body 中重复传 `queueId`、`timeSetId`、`queueItemId`。
+WebSocket 命令 `queue.get` 现在只对应“查询单个队列”,如果需要拉取全部队列,请改走 `GET /api/queue`。
+
+## 全局设置 API
+
+旧接口:
+`POST /api/setting/get`
+`POST /api/setting/update`
+`POST /api/setting/test_notify`
+`POST /api/setting/webhook/get`
+`POST /api/setting/webhook/add`
+`POST /api/setting/webhook/update`
+`POST /api/setting/webhook/delete`
+`POST /api/setting/webhook/order`
+`POST /api/setting/webhook/test`
+
+新接口:
+`GET /api/setting`
+`PATCH /api/setting`
+`POST /api/setting/actions/test-notify`
+`GET /api/setting/webhooks`
+`POST /api/setting/webhooks`
+`PATCH /api/setting/webhooks/order`
+`POST /api/setting/webhooks/test`
+`GET /api/setting/webhooks/{webhook_id}`
+`PATCH /api/setting/webhooks/{webhook_id}`
+`DELETE /api/setting/webhooks/{webhook_id}`
+
+关键变化:
+`PATCH /api/setting` 请求体直接传 `GlobalConfigPatch`。
+Webhook 单项查询返回单个 `data`,不再返回 `index + data map`。
+Webhook 测试接口请求体直接传 `WebhookPatch`。
+
+## 工具设置 API
+
+旧接口:
+`POST /api/tools/get`
+`POST /api/tools/update`
+
+新接口:
+`GET /api/tools`
+`PATCH /api/tools`
+
+关键变化:
+`PATCH /api/tools` 请求体直接传 `ToolsConfigPatch`。
+
+## 脚本管理 API
+
+旧接口:
+`POST /api/scripts/add`
+`POST /api/scripts/get`
+`POST /api/scripts/update`
+`POST /api/scripts/delete`
+`POST /api/scripts/order`
+`POST /api/scripts/import/file`
+`POST /api/scripts/export/file`
+`POST /api/scripts/import/web`
+`POST /api/scripts/Upload/web`
+`POST /api/scripts/user/get`
+`POST /api/scripts/user/add`
+`POST /api/scripts/user/update`
+`POST /api/scripts/user/delete`
+`POST /api/scripts/user/order`
+`POST /api/scripts/user/infrastructure`
+`POST /api/scripts/user/combox/infrastructure`
+`POST /api/scripts/webhook/get`
+`POST /api/scripts/webhook/add`
+`POST /api/scripts/webhook/update`
+`POST /api/scripts/webhook/delete`
+`POST /api/scripts/webhook/order`
+
+新接口:
+`GET /api/scripts`
+`POST /api/scripts`
+`PATCH /api/scripts/order`
+`GET /api/scripts/{script_id}`
+`PATCH /api/scripts/{script_id}`
+`DELETE /api/scripts/{script_id}`
+`POST /api/scripts/{script_id}/actions/import-file`
+`POST /api/scripts/{script_id}/actions/export-file`
+`POST /api/scripts/{script_id}/actions/import-web`
+`POST /api/scripts/{script_id}/actions/upload-web`
+`GET /api/scripts/{script_id}/users`
+`POST /api/scripts/{script_id}/users`
+`PATCH /api/scripts/{script_id}/users/order`
+`GET /api/scripts/{script_id}/users/{user_id}`
+`PATCH /api/scripts/{script_id}/users/{user_id}`
+`DELETE /api/scripts/{script_id}/users/{user_id}`
+`POST /api/scripts/{script_id}/users/{user_id}/actions/import-infrastructure`
+`GET /api/scripts/{script_id}/users/{user_id}/infrastructure-options`
+`GET /api/scripts/{script_id}/users/{user_id}/webhooks`
+`POST /api/scripts/{script_id}/users/{user_id}/webhooks`
+`PATCH /api/scripts/{script_id}/users/{user_id}/webhooks/order`
+`GET /api/scripts/{script_id}/users/{user_id}/webhooks/{webhook_id}`
+`PATCH /api/scripts/{script_id}/users/{user_id}/webhooks/{webhook_id}`
+`DELETE /api/scripts/{script_id}/users/{user_id}/webhooks/{webhook_id}`
+
+关键变化:
+脚本创建请求中的复制来源字段从 `scriptId` 改为 `copyFromId`。
+脚本、用户、脚本用户下的 Webhook 都已经切到单资源 REST 返回,不再混用旧的 body 包装。
+脚本级和用户级 patch 校验不再手写维护,而是基于运行期配置模型直接派生。
+
+## 共享 Contract 变更
+
+新增通用响应模型:
+`ResourceCollectionOut[Index, Data]`
+`ResourceItemOut[Data]`
+`ResourceCreateOut[Data]`
+`IndexOrderPatch`
+
+这意味着后续新增配置资源时,应优先复用通用响应模型,不要再重复定义新的 `XXXGetOut`、`XXXCreateOut`、`XXXReorderIn` 壳子。
+
+## Contract 生成规则
+
+配置相关 `Read/Patch` contract 现在优先通过运行期 `PydanticConfigBase` 模型派生生成。
+
+当前已经接入:
+`EmulatorConfig`
+`QueueConfig`
+`TimeSet`
+`QueueItem`
+`Webhook`
+`GlobalConfig`
+`ToolsConfig`
+`GeneralConfig`
+`GeneralUserConfig`
+`MaaConfig`
+`MaaUserConfig`
+`MaaPlanConfig`
+`SrcConfig`
+`SrcUserConfig`
+`MaaEndConfig`
+`MaaEndUserConfig`
+
+规则说明:
+读模型保留运行期模型字段和虚拟字段。
+补丁模型自动把字段转为可选,并跳过虚拟字段。
+脚本类读模型额外保留静态 `type` 字段,用于前端按 discriminator 分发。
+
+## 额外说明
+
+脚本相关创建接口和 Webhook 创建接口的返回字段都已统一为 `id`。
+如果前端依赖旧的 `scriptId`、`userId`、`webhookId` 返回字段,需要同步调整读取逻辑。
diff --git a/app/MaaFW/ArknightWin32.py b/app/MaaFW/ArknightWin32.py
index 33ed6413..a83dc3e4 100644
--- a/app/MaaFW/ArknightWin32.py
+++ b/app/MaaFW/ArknightWin32.py
@@ -29,7 +29,6 @@
import win32gui
import pyautogui
import pygetwindow
-from loguru import logger
from pynput import keyboard
from maa.tasker import Tasker
@@ -48,9 +47,7 @@
class _ArknightWin32Toolkit:
-
def __init__(self):
-
self.arknights_hwnd = -1
self.arknights_window = None
@@ -64,7 +61,6 @@ def __init__(self):
self.original_nice = self.p.nice()
async def init(self) -> None:
-
pyautogui.PAUSE = 0
pyautogui.FAILSAFE = False
@@ -103,7 +99,6 @@ async def scheduled_task(self) -> None:
new_hwnd = win32gui.FindWindow(None, "明日方舟")
if self.arknights_hwnd != new_hwnd:
-
self.arknights_hwnd = new_hwnd
if new_hwnd == 0:
@@ -209,7 +204,6 @@ async def click_pause_button(self) -> None:
pyautogui.moveTo(cur_x, cur_y)
def get_pause_position(self):
-
if not self.arknights_window:
raise RuntimeError("未连接到明日方舟窗口")
@@ -232,9 +226,7 @@ def get_pause_position(self):
@MaaFWManager.resource.custom_action("PlaySelectDeployed[ArknightsPC]")
class PlaySelectDeployed(CustomAction):
-
def run(self, context: Context, argv: CustomAction.RunArg) -> bool:
-
logger.info("开始执行战斗时选中已部署干员动作")
try:
@@ -257,9 +249,7 @@ def run(self, context: Context, argv: CustomAction.RunArg) -> bool:
@MaaFWManager.resource.custom_action("PauseSelectDeployed[ArknightsPC]")
class PauseSelectDeployed(CustomAction):
-
def run(self, context: Context, argv: CustomAction.RunArg) -> bool:
-
logger.info("开始执行暂停时选中已部署干员动作")
try:
@@ -284,9 +274,7 @@ def run(self, context: Context, argv: CustomAction.RunArg) -> bool:
@MaaFWManager.resource.custom_action("PlaySkill[ArknightsPC]")
class PlaySkill(CustomAction):
-
def run(self, context: Context, argv: CustomAction.RunArg) -> bool:
-
logger.info("开始执行战斗时释放技能动作")
try:
x, y = ArknightWin32Toolkit.get_pause_position()
@@ -306,9 +294,7 @@ def run(self, context: Context, argv: CustomAction.RunArg) -> bool:
@MaaFWManager.resource.custom_action("PauseSkill[ArknightsPC]")
class PauseSkill(CustomAction):
-
def run(self, context: Context, argv: CustomAction.RunArg) -> bool:
-
logger.info("开始执行暂停时释放技能动作")
try:
@@ -332,9 +318,7 @@ def run(self, context: Context, argv: CustomAction.RunArg) -> bool:
@MaaFWManager.resource.custom_action("PlayRetreat[ArknightsPC]")
class PlayRetreat(CustomAction):
-
def run(self, context: Context, argv: CustomAction.RunArg) -> bool:
-
logger.info("开始执行战斗时撤退干员动作")
try:
@@ -355,9 +339,7 @@ def run(self, context: Context, argv: CustomAction.RunArg) -> bool:
@MaaFWManager.resource.custom_action("PauseRetreat[ArknightsPC]")
class PauseRetreat(CustomAction):
-
def run(self, context: Context, argv: CustomAction.RunArg) -> bool:
-
logger.info("开始执行暂停时撤退干员动作")
try:
@@ -381,9 +363,7 @@ def run(self, context: Context, argv: CustomAction.RunArg) -> bool:
@MaaFWManager.resource.custom_action("NextFrame-0.2x[ArknightsPC]")
class NextFrame_0_2x(CustomAction):
-
def run(self, context: Context, argv: CustomAction.RunArg) -> bool:
-
logger.info("开始执行0.2倍速下一帧动作")
try:
@@ -406,9 +386,7 @@ def run(self, context: Context, argv: CustomAction.RunArg) -> bool:
@MaaFWManager.resource.custom_action("NextFrame-1x[ArknightsPC]")
class NextFrame_1x(CustomAction):
-
def run(self, context: Context, argv: CustomAction.RunArg) -> bool:
-
logger.info("开始执行1倍速下一帧动作")
try:
@@ -431,9 +409,7 @@ def run(self, context: Context, argv: CustomAction.RunArg) -> bool:
@MaaFWManager.resource.custom_action("NextFrame-2x[ArknightsPC]")
class NextFrame_2x(CustomAction):
-
def run(self, context: Context, argv: CustomAction.RunArg) -> bool:
-
logger.info("开始执行2倍速下一帧动作")
try:
diff --git a/app/__init__.py b/app/__init__.py
index 377b82eb..651424bb 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -21,10 +21,6 @@
# Contact: DLmaster_361@163.com
-from .api import *
-from .core import *
-from .models import *
-from .services import *
-from .utils import *
+from . import api, core, models, services, utils
__all__ = ["api", "core", "models", "services", "utils"]
diff --git a/app/api/__init__.py b/app/api/__init__.py
index 87a59e8e..13313e4d 100644
--- a/app/api/__init__.py
+++ b/app/api/__init__.py
@@ -30,11 +30,11 @@
from .dispatch import router as dispatch_router
from .history import router as history_router
from .tools import router as tools_router
+from .plugins import router as plugins_router
from .setting import router as setting_router
from .update import router as update_router
from .ocr import router as ocr_router
-from .websocket import router as ws_router
-from .plugins import router as plugins_router
+from .ws_debug import router as ws_debug_router
__all__ = [
"core_router",
@@ -46,9 +46,9 @@
"dispatch_router",
"history_router",
"tools_router",
+ "plugins_router",
"setting_router",
"update_router",
"ocr_router",
- "ws_router",
- "plugins_router",
+ "ws_debug_router",
]
diff --git a/app/api/common.py b/app/api/common.py
new file mode 100644
index 00000000..9335b324
--- /dev/null
+++ b/app/api/common.py
@@ -0,0 +1,7 @@
+from app.contracts.common_contract import ComboBoxItem, ComboBoxOut, OutBase
+
+__all__ = [
+ "OutBase",
+ "ComboBoxItem",
+ "ComboBoxOut",
+]
diff --git a/app/api/core.py b/app/api/core.py
index 221cc7ff..73f6ff9e 100644
--- a/app/api/core.py
+++ b/app/api/core.py
@@ -24,14 +24,15 @@
import os
import time
import asyncio
+from typing import Any, cast
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.core import Config, Broadcast, TaskManager
from app.services import System
-from app.models.schema import *
+from app.contracts.common_contract import OutBase
+from app.models.shared import WebSocketMessage
from app.api.ws_command import ws_command
from app.utils import get_logger
-from app.utils.websocket import ws_client_manager
router = APIRouter(prefix="/api/core", tags=["核心信息"])
logger = get_logger("DEV")
@@ -46,58 +47,114 @@ def is_backend_dev_mode() -> bool:
@router.websocket("/ws")
async def connect_websocket(websocket: WebSocket):
-
if Config.websocket is not None:
await websocket.close(code=1000, reason="已有连接")
return
await websocket.accept()
- Config.websocket = None
-
- async def on_message(data: dict):
- await Broadcast.put(data)
+ Config.websocket = websocket
+ last_pong = time.monotonic()
+ last_ping = time.monotonic()
+ data: dict[str, Any] = {}
- async def on_disconnect():
- Config.websocket = None
-
- session = await ws_client_manager.openwsr(
- name=ws_client_manager.MAIN_CLIENT_NAME,
- websocket=websocket,
- ping_interval=15.0,
- ping_timeout=30.0,
- on_message=on_message,
- on_disconnect=on_disconnect,
- )
-
- Config.websocket = session
asyncio.create_task(TaskManager.start_startup_queue())
- await session.wait_closed()
+ while True:
+ try:
+ payload = await asyncio.wait_for(websocket.receive_json(), timeout=15.0)
+ if not isinstance(payload, dict):
+ continue
+ data = cast(dict[str, Any], payload)
+ if data.get("type") == "Signal" and "Pong" in data.get("data", {}):
+ last_pong = time.monotonic()
+ elif data.get("type") == "Signal" and "Ping" in data.get("data", {}):
+ await websocket.send_json(
+ WebSocketMessage(
+ id="Main", type="Signal", data={"Pong": "无描述"}
+ ).model_dump()
+ )
+ else:
+ await Broadcast.put(data)
+
+ except asyncio.TimeoutError:
+ if last_pong < last_ping:
+ await websocket.close(code=1000, reason="Ping超时")
+ break
+ await websocket.send_json(
+ WebSocketMessage(
+ id="Main", type="Signal", data={"Ping": "无描述"}
+ ).model_dump()
+ )
+ last_ping = time.monotonic()
+
+ except WebSocketDisconnect:
+ break
+
+ Config.websocket = None
if is_backend_dev_mode():
logger.warning("后端开发模式下检测到 WS 断链,跳过 KillSelf 自动退出")
else:
await System.set_power("KillSelf", from_frontend=True)
+@router.websocket("/ws/web")
+async def connect_websocket_web(websocket: WebSocket):
+ """Web 端 WebSocket 连接:支持多连接,断开不影响后端生命周期"""
+ await websocket.accept()
+ Config.web_connections.add(websocket)
+ logger.info(f"Web 客户端已连接,当前 Web 连接数: {len(Config.web_connections)}")
+
+ last_pong = time.monotonic()
+ last_ping = time.monotonic()
+
+ while True:
+ try:
+ payload = await asyncio.wait_for(websocket.receive_json(), timeout=30.0)
+ if not isinstance(payload, dict):
+ continue
+ data = cast(dict[str, Any], payload)
+ if data.get("type") == "Signal" and "Pong" in data.get("data", {}):
+ last_pong = time.monotonic()
+ elif data.get("type") == "Signal" and "Ping" in data.get("data", {}):
+ await websocket.send_json(
+ WebSocketMessage(
+ id="Main", type="Signal", data={"Pong": "无描述"}
+ ).model_dump()
+ )
+ else:
+ await Broadcast.put(data)
+
+ except asyncio.TimeoutError:
+ if last_pong < last_ping:
+ await websocket.close(code=1000, reason="Ping超时")
+ break
+ await websocket.send_json(
+ WebSocketMessage(
+ id="Main", type="Signal", data={"Ping": "无描述"}
+ ).model_dump()
+ )
+ last_ping = time.monotonic()
+
+ except WebSocketDisconnect:
+ break
+
+ except Exception:
+ break
+
+ Config.web_connections.discard(websocket)
+ logger.info(f"Web 客户端已断开,剩余 Web 连接数: {len(Config.web_connections)}")
+
+
@ws_command("core.close")
@router.post(
"/close",
summary="关闭后端程序",
response_model=OutBase,
- status_code=200,
)
async def close() -> OutBase:
"""关闭后端程序"""
- try:
- if Config.websocket is not None:
- await Config.websocket.close(code=1000, reason="正常关闭")
- if is_backend_dev_mode():
- logger.warning("后端开发模式下忽略 /api/core/close 的 KillSelf 请求")
- return OutBase(message="开发模式下已忽略关闭请求")
- await System.set_power("KillSelf", from_frontend=True)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+ if Config.websocket is not None:
+ await Config.websocket.close(code=1000, reason="正常关闭")
+ await System.set_power("KillSelf", from_frontend=True)
return OutBase()
diff --git a/app/api/dispatch.py b/app/api/dispatch.py
index e8c220ab..edc9a902 100644
--- a/app/api/dispatch.py
+++ b/app/api/dispatch.py
@@ -25,7 +25,14 @@
from app.core import Config, TaskManager
from app.services import System
-from app.models.schema import *
+from app.contracts.common_contract import OutBase
+from app.contracts.dispatch_contract import (
+ DispatchIn,
+ PowerIn,
+ PowerOut,
+ TaskCreateIn,
+ TaskCreateOut,
+)
router = APIRouter(prefix="/api/dispatch", tags=["任务调度"])
@@ -35,16 +42,9 @@
tags=["Action"],
summary="添加任务",
response_model=TaskCreateOut,
- status_code=200,
)
async def add_task(task: TaskCreateIn = Body(...)) -> TaskCreateOut:
-
- try:
- task_id = await TaskManager.add_task(task.mode, task.taskId)
- except Exception as e:
- return TaskCreateOut(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}", taskId=""
- )
+ task_id = await TaskManager.add_task(task.mode, task.taskId)
return TaskCreateOut(taskId=str(task_id))
@@ -53,16 +53,9 @@ async def add_task(task: TaskCreateIn = Body(...)) -> TaskCreateOut:
tags=["Action"],
summary="中止任务",
response_model=OutBase,
- status_code=200,
)
async def stop_task(task: DispatchIn = Body(...)) -> OutBase:
-
- try:
- await TaskManager.stop_task(task.taskId)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+ await TaskManager.stop_task(task.taskId)
return OutBase()
@@ -71,19 +64,9 @@ async def stop_task(task: DispatchIn = Body(...)) -> OutBase:
tags=["Get"],
summary="获取电源标志",
response_model=PowerOut,
- status_code=200,
)
async def get_power() -> PowerOut:
-
- try:
- signal = Config.power_sign
- except Exception as e:
- return PowerOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- signal="NoAction",
- )
+ signal = Config.power_sign
return PowerOut(signal=signal)
@@ -92,16 +75,9 @@ async def get_power() -> PowerOut:
tags=["Action"],
summary="设置电源标志",
response_model=OutBase,
- status_code=200,
)
async def set_power(task: PowerIn = Body(...)) -> OutBase:
-
- try:
- Config.power_sign = task.signal
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+ Config.power_sign = task.signal
return OutBase()
@@ -110,14 +86,7 @@ async def set_power(task: PowerIn = Body(...)) -> OutBase:
tags=["Action"],
summary="取消电源任务",
response_model=OutBase,
- status_code=200,
)
async def cancel_power_task() -> OutBase:
-
- try:
- await System.cancel_power_task()
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+ await System.cancel_power_task()
return OutBase()
diff --git a/app/api/emulator.py b/app/api/emulator.py
index b70ed51e..32890735 100644
--- a/app/api/emulator.py
+++ b/app/api/emulator.py
@@ -21,180 +21,161 @@
# Contact: DLmaster_361@163.com
-from fastapi import APIRouter, Body
+from typing import Annotated, Literal
+
+from fastapi import APIRouter, Body, Path
+
+
from app.core import Config, EmulatorManager
-from app.models.schema import (
+from app.contracts.common_contract import (
+ IndexOrderPatch,
OutBase,
- EmulatorConfig,
- EmulatorGetIn,
- EmulatorGetOut,
+ dump_writable_data,
+ project_model,
+ project_model_list,
+ project_model_map,
+)
+from app.contracts.emulator_contract import (
+ EmulatorActionBody,
EmulatorConfigIndexItem,
EmulatorCreateOut,
- EmulatorUpdateIn,
- EmulatorDeleteIn,
- EmulatorReorderIn,
- EmulatorOperateIn,
- EmulatorStatusOut,
+ EmulatorDetailOut,
+ EmulatorDeviceStatusOut,
+ EmulatorGetOut,
+ EmulatorRead,
EmulatorSearchOut,
EmulatorSearchResult,
+ EmulatorStatusOut,
)
router = APIRouter(prefix="/api/emulator", tags=["模拟器管理"])
+EmulatorIdPath = Annotated[str, Path(description="模拟器 ID")]
+EmulatorActionPath = Annotated[
+ Literal["open", "close", "show"],
+ Path(description="模拟器动作"),
+]
-@router.post(
- "/get",
+
+@router.get(
+ "",
tags=["Get"],
- summary="查询模拟器配置",
+ summary="查询全部模拟器配置",
response_model=EmulatorGetOut,
- status_code=200,
)
-async def get_emulator(emulator: EmulatorGetIn = Body(...)) -> EmulatorGetOut:
- try:
- index, data = await Config.get_emulator(emulator.emulatorId)
- index = [EmulatorConfigIndexItem(**_) for _ in index]
- data = {uid: EmulatorConfig(**cfg) for uid, cfg in data.items()}
- except Exception as e:
- return EmulatorGetOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- index=[],
- data={},
- )
- return EmulatorGetOut(index=index, data=data)
+async def list_emulators() -> EmulatorGetOut:
+ index, data = await Config.get_emulator(None)
+ return EmulatorGetOut(
+ index=project_model_list(EmulatorConfigIndexItem, index),
+ data=project_model_map(EmulatorRead, data),
+ )
@router.post(
- "/add",
+ "",
tags=["Add"],
- summary="添加模拟器项",
+ summary="创建模拟器配置",
response_model=EmulatorCreateOut,
- status_code=200,
)
-async def add_emulator() -> EmulatorCreateOut:
- try:
- uid, config = await Config.add_emulator()
- data = EmulatorConfig(**(await config.toDict()))
- except Exception as e:
- return EmulatorCreateOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- emulatorId="",
- data=EmulatorConfig(**{}),
- )
- return EmulatorCreateOut(emulatorId=str(uid), data=data)
+async def create_emulator() -> EmulatorCreateOut:
+ uid, config = await Config.add_emulator()
+ return EmulatorCreateOut(
+ id=str(uid),
+ data=project_model(EmulatorRead, await config.toDict()),
+ )
-@router.post(
- "/update",
+@router.patch(
+ "/order",
tags=["Update"],
- summary="更新模拟器项",
+ summary="重新排序模拟器",
response_model=OutBase,
- status_code=200,
)
-async def update_emulator(emulator: EmulatorUpdateIn = Body(...)) -> OutBase:
- try:
- await Config.update_emulator(
- emulator.emulatorId, emulator.data.model_dump(exclude_unset=True)
- )
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def reorder_emulator(body: IndexOrderPatch = Body(...)) -> OutBase:
+ await Config.reorder_emulator(body.index_list)
return OutBase()
-@router.post(
- "/delete",
- tags=["Delete"],
- summary="删除模拟器项",
- response_model=OutBase,
- status_code=200,
+@router.get(
+ "/detected",
+ tags=["Get"],
+ summary="搜索已安装的模拟器",
+ response_model=EmulatorSearchOut,
)
-async def delete_emulator(emulator: EmulatorDeleteIn = Body(...)) -> OutBase:
- try:
- await Config.del_emulator(emulator.emulatorId)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
- return OutBase()
+async def detect_emulators() -> EmulatorSearchOut:
+ from app.utils import search_all_emulators
+ emulators = await search_all_emulators()
+ return EmulatorSearchOut(data=project_model_list(EmulatorSearchResult, emulators))
-@router.post(
- "/order",
+
+@router.get(
+ "/status",
+ tags=["Get"],
+ summary="查询全部模拟器状态",
+ response_model=EmulatorStatusOut,
+)
+async def get_emulator_statuses() -> EmulatorStatusOut:
+ return EmulatorStatusOut(data=await EmulatorManager.get_status(None))
+
+
+@router.get(
+ "/{emulator_id}",
+ tags=["Get"],
+ summary="查询单个模拟器配置",
+ response_model=EmulatorDetailOut,
+)
+async def get_emulator(emulator_id: EmulatorIdPath) -> EmulatorDetailOut:
+ _, data = await Config.get_emulator(emulator_id)
+ projected = project_model_map(EmulatorRead, data)
+ return EmulatorDetailOut(data=projected[emulator_id])
+
+
+@router.patch(
+ "/{emulator_id}",
tags=["Update"],
- summary="重新排序模拟器项",
+ summary="更新模拟器配置",
response_model=OutBase,
- status_code=200,
)
-async def reorder_emulator(emulator: EmulatorReorderIn = Body(...)) -> OutBase:
- try:
- await Config.reorder_emulator(emulator.indexList)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def update_emulator(
+ emulator_id: EmulatorIdPath, data: EmulatorRead = Body(...)
+) -> OutBase:
+ await Config.update_emulator(emulator_id, dump_writable_data(data))
return OutBase()
-@router.post(
- "/operate",
- tags=["Action"],
- summary="操作模拟器",
+@router.delete(
+ "/{emulator_id}",
+ tags=["Delete"],
+ summary="删除模拟器配置",
response_model=OutBase,
- status_code=200,
)
-async def operation_emulator(emulator: EmulatorOperateIn = Body(...)) -> OutBase:
- try:
- await EmulatorManager.operate_emulator(
- emulator.operate, emulator.emulatorId, emulator.index
- )
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def delete_emulator(emulator_id: EmulatorIdPath) -> OutBase:
+ await Config.del_emulator(emulator_id)
return OutBase()
-@router.post(
- "/status",
+@router.get(
+ "/{emulator_id}/status",
tags=["Get"],
- summary="查询模拟器状态",
- response_model=EmulatorStatusOut,
- status_code=200,
+ summary="查询单个模拟器状态",
+ response_model=EmulatorDeviceStatusOut,
)
-async def get_status(emulator: EmulatorGetIn = Body(...)) -> EmulatorStatusOut:
- try:
- data = await EmulatorManager.get_status(emulator.emulatorId)
- except Exception as e:
- return EmulatorStatusOut(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data={}
- )
- return EmulatorStatusOut(data=data)
+async def get_emulator_status(emulator_id: EmulatorIdPath) -> EmulatorDeviceStatusOut:
+ statuses = await EmulatorManager.get_status(emulator_id)
+ return EmulatorDeviceStatusOut(data=statuses.get(emulator_id, {}))
@router.post(
- "/emulator/search",
- tags=["Get"],
- summary="搜索已安装的模拟器",
- response_model=EmulatorSearchOut,
- status_code=200,
+ "/{emulator_id}/actions/{action}",
+ tags=["Action"],
+ summary="执行模拟器动作",
+ response_model=OutBase,
)
-async def search_emulators() -> EmulatorSearchOut:
- """自动搜索系统中已安装的模拟器"""
- try:
- from app.utils import search_all_emulators
-
- emulators = await search_all_emulators()
- results = [EmulatorSearchResult(**emulator) for emulator in emulators]
- except Exception as e:
- return EmulatorSearchOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- emulators=[],
- )
- return EmulatorSearchOut(emulators=results)
+async def operate_emulator(
+ emulator_id: EmulatorIdPath,
+ action: EmulatorActionPath,
+ body: EmulatorActionBody = Body(...),
+) -> OutBase:
+ await EmulatorManager.operate_emulator(action, emulator_id, body.index)
+ return OutBase()
diff --git a/app/api/history.py b/app/api/history.py
index ea70a8db..663786b3 100644
--- a/app/api/history.py
+++ b/app/api/history.py
@@ -24,44 +24,53 @@
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Body
+from pydantic import TypeAdapter
from app.core import Config
-from app.models.schema import *
+from app.contracts.history_contract import (
+ HistoryData,
+ HistoryDataGetIn,
+ HistoryDataGetOut,
+ HistoryIndexItem,
+ HistorySearchIn,
+ HistorySearchOut,
+)
router = APIRouter(prefix="/api/history", tags=["历史记录"])
+HISTORY_INDEX_ADAPTER: TypeAdapter[list[HistoryIndexItem]] = TypeAdapter(
+ list[HistoryIndexItem]
+)
+
+
+def _build_history_data(raw: dict[str, object]) -> HistoryData:
+ data = dict(raw)
+ index_data = data.get("index", [])
+ data["index"] = []
+ if isinstance(index_data, list):
+ data["index"] = HISTORY_INDEX_ADAPTER.validate_python(index_data)
+ return HistoryData.model_validate(data)
+
@router.post(
"/search",
tags=["Get"],
summary="搜索历史记录总览信息",
response_model=HistorySearchOut,
- status_code=200,
)
async def search_history(history: HistorySearchIn) -> HistorySearchOut:
-
- try:
- data = await Config.search_history(
- history.mode,
- datetime.strptime(history.start_date, "%Y-%m-%d").date(),
- datetime.strptime(history.end_date, "%Y-%m-%d").date(),
- )
- for date, users in data.items():
- for user, records in users.items():
- record = await Config.merge_statistic_info(records)
- # 安全检查:确保 index 字段存在
- if "index" not in record:
- record["index"] = []
- record["index"] = [HistoryIndexItem(**_) for _ in record["index"]]
- record = HistoryData(**record)
- data[date][user] = record
- except Exception as e:
- return HistorySearchOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- data={},
- )
+ raw_data = await Config.search_history(
+ history.mode,
+ datetime.strptime(history.start_date, "%Y-%m-%d").date(),
+ datetime.strptime(history.end_date, "%Y-%m-%d").date(),
+ )
+ data: dict[str, dict[str, HistoryData]] = {}
+ for date, users in raw_data.items():
+ current_users: dict[str, HistoryData] = {}
+ for user, records in users.items():
+ record = await Config.merge_statistic_info(records)
+ current_users[user] = _build_history_data(record)
+ data[date] = current_users
return HistorySearchOut(data=data)
@@ -70,21 +79,11 @@ async def search_history(history: HistorySearchIn) -> HistorySearchOut:
tags=["Get"],
summary="从指定文件内获取历史记录数据",
response_model=HistoryDataGetOut,
- status_code=200,
)
async def get_history_data(history: HistoryDataGetIn = Body(...)) -> HistoryDataGetOut:
-
- try:
- path = Path(history.jsonPath)
- data = await Config.merge_statistic_info([path])
- data.pop("index", None)
- data["log_content"] = path.with_suffix(".log").read_text(encoding="utf-8")
- data = HistoryData(**data)
- except Exception as e:
- return HistoryDataGetOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- data=HistoryData(**{}),
- )
+ path = Path(history.jsonPath)
+ raw_data = await Config.merge_statistic_info([path])
+ raw_data.pop("index", None)
+ raw_data["log_content"] = path.with_suffix(".log").read_text(encoding="utf-8")
+ data = _build_history_data(raw_data)
return HistoryDataGetOut(data=data)
diff --git a/app/api/info.py b/app/api/info.py
index 60684002..a55cd065 100644
--- a/app/api/info.py
+++ b/app/api/info.py
@@ -21,34 +21,51 @@
# Contact: DLmaster_361@163.com
+from typing import Any, cast
+
from fastapi import APIRouter, Body
+from pydantic import Field, TypeAdapter
from app.core import Config
-from app.models.schema import *
+from app.contracts.common_contract import (
+ ApiModel,
+ ComboBoxItem,
+ ComboBoxOut,
+ InfoOut,
+ OutBase,
+)
+from app.contracts.info_contract import (
+ GetStageIn,
+ NoticeOut,
+ VersionOut,
+)
router = APIRouter(prefix="/api/info", tags=["信息获取"])
+class EmulatorIdBody(ApiModel):
+ emulatorId: str = Field(..., description="模拟器 ID")
+
+
+COMBOBOX_ITEMS_ADAPTER: TypeAdapter[list[ComboBoxItem]] = TypeAdapter(
+ list[ComboBoxItem]
+)
+
+
+def _to_combobox_items(raw_data: object) -> list[ComboBoxItem]:
+ return COMBOBOX_ITEMS_ADAPTER.validate_python(
+ raw_data if isinstance(raw_data, list) else []
+ )
+
+
@router.post(
"/version",
tags=["Get"],
summary="获取后端git版本信息",
response_model=VersionOut,
- status_code=200,
)
async def get_git_version() -> VersionOut:
-
- try:
- is_latest, commit_hash, commit_time = await Config.get_git_version()
- except Exception as e:
- return VersionOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- if_need_update=False,
- current_time="unknown",
- current_hash="unknown",
- )
+ is_latest, commit_hash, commit_time = await Config.get_git_version()
return VersionOut(
if_need_update=not is_latest,
current_time=commit_time,
@@ -61,23 +78,12 @@ async def get_git_version() -> VersionOut:
tags=["Get"],
summary="获取关卡号下拉框信息",
response_model=ComboBoxOut,
- status_code=200,
)
async def get_stage_combox(
- stage: GetStageIn = Body(..., description="关卡号类型")
+ stage: GetStageIn = Body(..., description="关卡号类型"),
) -> ComboBoxOut:
-
- try:
- raw_data = await Config.get_stage_info(stage.type)
- data = (
- [ComboBoxItem(**item) for item in raw_data if isinstance(item, dict)]
- if raw_data
- else []
- )
- except Exception as e:
- return ComboBoxOut(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data=[]
- )
+ raw_data = cast(object, await Config.get_stage_info(stage.type))
+ data = _to_combobox_items(raw_data)
return ComboBoxOut(data=data)
@@ -86,17 +92,10 @@ async def get_stage_combox(
tags=["Get"],
summary="获取脚本下拉框信息",
response_model=ComboBoxOut,
- status_code=200,
)
async def get_script_combox() -> ComboBoxOut:
-
- try:
- raw_data = await Config.get_script_combox()
- data = [ComboBoxItem(**item) for item in raw_data] if raw_data else []
- except Exception as e:
- return ComboBoxOut(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data=[]
- )
+ raw_data = await Config.get_script_combox()
+ data = _to_combobox_items(raw_data)
return ComboBoxOut(data=data)
@@ -105,17 +104,10 @@ async def get_script_combox() -> ComboBoxOut:
tags=["Get"],
summary="获取可选任务下拉框信息",
response_model=ComboBoxOut,
- status_code=200,
)
async def get_task_combox() -> ComboBoxOut:
-
- try:
- raw_data = await Config.get_task_combox()
- data = [ComboBoxItem(**item) for item in raw_data] if raw_data else []
- except Exception as e:
- return ComboBoxOut(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data=[]
- )
+ raw_data = await Config.get_task_combox()
+ data = _to_combobox_items(raw_data)
return ComboBoxOut(data=data)
@@ -124,17 +116,10 @@ async def get_task_combox() -> ComboBoxOut:
tags=["Get"],
summary="获取可选计划下拉框信息",
response_model=ComboBoxOut,
- status_code=200,
)
async def get_plan_combox() -> ComboBoxOut:
-
- try:
- raw_data = await Config.get_plan_combox()
- data = [ComboBoxItem(**item) for item in raw_data] if raw_data else []
- except Exception as e:
- return ComboBoxOut(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data=[]
- )
+ raw_data = await Config.get_plan_combox()
+ data = _to_combobox_items(raw_data)
return ComboBoxOut(data=data)
@@ -143,17 +128,10 @@ async def get_plan_combox() -> ComboBoxOut:
tags=["Get"],
summary="获取可选模拟器下拉框信息",
response_model=ComboBoxOut,
- status_code=200,
)
async def get_emulator_combox() -> ComboBoxOut:
-
- try:
- raw_data = await Config.get_emulator_combox()
- data = [ComboBoxItem(**item) for item in raw_data] if raw_data else []
- except Exception as e:
- return ComboBoxOut(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data=[]
- )
+ raw_data = await Config.get_emulator_combox()
+ data = _to_combobox_items(raw_data)
return ComboBoxOut(data=data)
@@ -162,18 +140,14 @@ async def get_emulator_combox() -> ComboBoxOut:
tags=["Get"],
summary="获取可选模拟器多开实例下拉框信息",
response_model=ComboBoxOut,
- status_code=200,
)
async def get_emulator_devices_combox(
- emulator: EmulatorDeleteIn = Body(...),
+ emulator: EmulatorIdBody = Body(...),
) -> ComboBoxOut:
- try:
- raw_data = await Config.get_emulator_devices_combox(emulator.emulatorId)
- data = [ComboBoxItem(**item) for item in raw_data] if raw_data else []
- except Exception as e:
- return ComboBoxOut(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data=[]
- )
+ raw_data = cast(
+ object, await Config.get_emulator_devices_combox(emulator.emulatorId)
+ )
+ data = _to_combobox_items(raw_data)
return ComboBoxOut(data=data)
@@ -182,20 +156,9 @@ async def get_emulator_devices_combox(
tags=["Get"],
summary="获取通知信息",
response_model=NoticeOut,
- status_code=200,
)
async def get_notice_info() -> NoticeOut:
-
- try:
- if_need_show, data = await Config.get_notice()
- except Exception as e:
- return NoticeOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- if_need_show=False,
- data={},
- )
+ if_need_show, data = await Config.get_notice()
return NoticeOut(if_need_show=if_need_show, data=data)
@@ -204,16 +167,9 @@ async def get_notice_info() -> NoticeOut:
tags=["Action"],
summary="确认通知",
response_model=OutBase,
- status_code=200,
)
async def confirm_notice() -> OutBase:
-
- try:
- await Config.set("Data", "IfShowNotice", False)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+ await Config.set("Data", "IfShowNotice", False)
return OutBase()
@@ -236,16 +192,9 @@ async def confirm_notice() -> OutBase:
tags=["Get"],
summary="获取配置分享中心的配置信息",
response_model=InfoOut,
- status_code=200,
)
async def get_web_config() -> InfoOut:
-
- try:
- data = await Config.get_web_config()
- except Exception as e:
- return InfoOut(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data={}
- )
+ data = await Config.get_web_config()
return InfoOut(data={"WebConfig": data})
@@ -254,17 +203,10 @@ async def get_web_config() -> InfoOut:
tags=["Get"],
summary="信息总览",
response_model=InfoOut,
- status_code=200,
)
async def get_overview() -> InfoOut:
- try:
- stage = await Config.get_stage_info("Info")
- proxy = await Config.get_proxy_overview()
- except Exception as e:
- return InfoOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- data={"Stage": [], "Proxy": []},
- )
+ raw_stage = cast(object, await Config.get_stage_info("Info"))
+ stage = cast(dict[str, Any], raw_stage if isinstance(raw_stage, dict) else {})
+
+ proxy = await Config.get_proxy_overview()
return InfoOut(data={"Stage": stage, "Proxy": proxy})
diff --git a/app/api/ocr.py b/app/api/ocr.py
index 3a6066d9..23449dc0 100644
--- a/app/api/ocr.py
+++ b/app/api/ocr.py
@@ -21,24 +21,43 @@
# Contact: DLmaster_361@163.com
-from fastapi import APIRouter, Body
-from pydantic import BaseModel, Field
-from typing import Optional
+from fastapi import APIRouter, Body, HTTPException
+from pydantic import Field
+from typing import NoReturn, Optional
import base64
from io import BytesIO
from app.utils.OCR.OCRtool import OCRTool
from app.utils import get_logger
-from app.models.schema import OutBase
+from app.contracts.common_contract import ApiModel, OutBase
logger = get_logger("OCR API")
router = APIRouter(prefix="/api/ocr", tags=["OCR识别"])
+def _raise_ocr_http_error(prefix: str, exc: Exception) -> NoReturn:
+ logger.error(f"{prefix}: {type(exc).__name__}: {exc}")
+ raise HTTPException(status_code=500, detail=f"{prefix}: {exc}") from exc
+
+
# ========== 截图相关模型 ==========
-class OCRScreenshotIn(BaseModel):
+class OCRWindowIn(ApiModel):
window_title: str = Field(..., description="窗口标题(用于查找窗口)")
+
+
+class OCRRetryIn(OCRWindowIn):
+ interval: float = Field(default=0, description="截图间隔时间(秒)", ge=0)
+ retry_times: int = Field(default=1, description="重复截图次数", ge=1)
+
+
+class OCRRetryThresholdIn(OCRRetryIn):
+ threshold: float = Field(
+ default=0.8, description="图像匹配阈值,范围 0-1", ge=0, le=1
+ )
+
+
+class OCRScreenshotIn(OCRWindowIn):
should_preprocess: bool = Field(
default=True,
description="是否预处理图片区域,True时排除边框和标题栏,False时使用完整窗口",
@@ -59,7 +78,7 @@ class OCRScreenshotOut(OutBase):
image_height: int = Field(..., description="截图高度")
-class ADBScreenshotIn(BaseModel):
+class ADBScreenshotIn(ApiModel):
adb_path: str = Field(..., description="ADB 可执行文件的路径")
serial: str = Field(
..., description="设备序列号,格式如 '127.0.0.1:5555' 或 'emulator-5554'"
@@ -78,34 +97,16 @@ class ADBScreenshotOut(OutBase):
# ========== 测试相关模型 ==========
-class CheckImageIn(BaseModel):
- window_title: str = Field(..., description="窗口标题(用于查找窗口)")
+class CheckImageIn(OCRRetryThresholdIn):
image_path: str = Field(..., description="要查找的图片路径")
- interval: float = Field(default=0, description="截图间隔时间(秒)", ge=0)
- retry_times: int = Field(default=1, description="重复截图次数", ge=1)
- threshold: float = Field(
- default=0.8, description="图像匹配阈值,范围 0-1", ge=0, le=1
- )
-class CheckImageAnyIn(BaseModel):
- window_title: str = Field(..., description="窗口标题(用于查找窗口)")
+class CheckImageAnyIn(OCRRetryThresholdIn):
image_paths: list[str] = Field(..., description="要查找的图片路径列表")
- interval: float = Field(default=0, description="截图间隔时间(秒)", ge=0)
- retry_times: int = Field(default=1, description="重复截图次数", ge=1)
- threshold: float = Field(
- default=0.8, description="图像匹配阈值,范围 0-1", ge=0, le=1
- )
-class CheckImageAllIn(BaseModel):
- window_title: str = Field(..., description="窗口标题(用于查找窗口)")
+class CheckImageAllIn(OCRRetryThresholdIn):
image_paths: list[str] = Field(..., description="要查找的图片路径列表")
- interval: float = Field(default=0, description="截图间隔时间(秒)", ge=0)
- retry_times: int = Field(default=1, description="重复截图次数", ge=1)
- threshold: float = Field(
- default=0.8, description="图像匹配阈值,范围 0-1", ge=0, le=1
- )
class CheckImageOut(OutBase):
@@ -113,21 +114,12 @@ class CheckImageOut(OutBase):
attempts: int = Field(..., description="实际尝试次数")
-class ClickImageIn(BaseModel):
- window_title: str = Field(..., description="窗口标题(用于查找窗口)")
+class ClickImageIn(OCRRetryThresholdIn):
image_path: str = Field(..., description="要查找并点击的图片路径")
- interval: float = Field(default=0, description="截图间隔时间(秒)", ge=0)
- retry_times: int = Field(default=1, description="重复截图次数", ge=1)
- threshold: float = Field(
- default=0.8, description="图像匹配阈值,范围 0-1", ge=0, le=1
- )
-class ClickTextIn(BaseModel):
- window_title: str = Field(..., description="窗口标题(用于查找窗口)")
+class ClickTextIn(OCRRetryIn):
text: str = Field(..., description="要查找并点击的文字内容")
- interval: float = Field(default=0, description="截图间隔时间(秒)", ge=0)
- retry_times: int = Field(default=1, description="重复截图次数", ge=1)
class ClickOut(OutBase):
@@ -141,7 +133,6 @@ class ClickOut(OutBase):
tags=["Get"],
summary="获取窗口截图",
response_model=OCRScreenshotOut,
- status_code=200,
)
async def get_screenshot(params: OCRScreenshotIn = Body(...)) -> OCRScreenshotOut:
"""
@@ -158,12 +149,8 @@ async def get_screenshot(params: OCRScreenshotIn = Body(...)) -> OCRScreenshotOu
Returns:
OCRScreenshotOut: 包含Base64编码的截图和区域信息
"""
- try:
- # 初始化OCRTool
- ocr_tool = OCRTool(
- width=params.aspect_ratio_width, height=params.aspect_ratio_height
- )
+ async def _success() -> OCRScreenshotOut:
# 获取截图区域(如果没有提供自定义区域)
if params.region is None:
region = OCRTool.get_screenshot_region(
@@ -179,7 +166,6 @@ async def get_screenshot(params: OCRScreenshotIn = Body(...)) -> OCRScreenshotOu
region=region,
)
- # 将PIL Image转换为Base64
buffer = BytesIO()
screenshot_image.save(buffer, format="PNG")
image_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
@@ -187,8 +173,6 @@ async def get_screenshot(params: OCRScreenshotIn = Body(...)) -> OCRScreenshotOu
logger.info(f"成功截取窗口 [{params.window_title}] 的截图,区域: {region}")
return OCRScreenshotOut(
- code=200,
- status="success",
message="截图成功",
image_base64=image_base64,
region=region,
@@ -196,17 +180,10 @@ async def get_screenshot(params: OCRScreenshotIn = Body(...)) -> OCRScreenshotOu
image_height=screenshot_image.height,
)
+ try:
+ return await _success()
except Exception as e:
- logger.error(f"截图失败: {type(e).__name__}: {str(e)}")
- return OCRScreenshotOut(
- code=500,
- status="error",
- message=f"截图失败: {type(e).__name__}: {str(e)}",
- image_base64="",
- region=(0, 0, 0, 0),
- image_width=0,
- image_height=0,
- )
+ _raise_ocr_http_error("截图失败", e)
@router.post(
@@ -214,7 +191,6 @@ async def get_screenshot(params: OCRScreenshotIn = Body(...)) -> OCRScreenshotOu
tags=["Get"],
summary="通过ADB获取设备截图",
response_model=ADBScreenshotOut,
- status_code=200,
)
async def get_screenshot_adb(params: ADBScreenshotIn = Body(...)) -> ADBScreenshotOut:
"""
@@ -234,65 +210,35 @@ async def get_screenshot_adb(params: ADBScreenshotIn = Body(...)) -> ADBScreensh
ADBScreenshotOut: 包含Base64编码的截图和设备信息
"""
try:
- # 使用 OCRTool 通过 ADB 获取截图
screenshot_image = OCRTool.get_screenshot_with_adb(
adb_path=params.adb_path,
serial=params.serial,
use_screencap=params.use_screencap,
)
-
- # 将PIL Image转换为Base64
- buffer = BytesIO()
- screenshot_image.save(buffer, format="PNG")
- image_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
-
- logger.info(
- f"成功通过 ADB 截取设备 [{params.serial}] 的截图,尺寸: {screenshot_image.size}"
- )
-
- return ADBScreenshotOut(
- code=200,
- status="success",
- message="ADB 截图成功",
- image_base64=image_base64,
- image_width=screenshot_image.width,
- image_height=screenshot_image.height,
- serial=params.serial,
- )
-
except FileNotFoundError as e:
logger.error(f"ADB 文件未找到: {str(e)}")
- return ADBScreenshotOut(
- code=404,
- status="error",
- message=f"ADB 文件未找到: {str(e)}",
- image_base64="",
- image_width=0,
- image_height=0,
- serial=params.serial,
- )
+ raise HTTPException(status_code=400, detail=f"ADB 文件未找到: {str(e)}") from e
except RuntimeError as e:
logger.error(f"ADB 截图运行时错误: {str(e)}")
- return ADBScreenshotOut(
- code=500,
- status="error",
- message=f"ADB 截图失败: {str(e)}",
- image_base64="",
- image_width=0,
- image_height=0,
- serial=params.serial,
- )
- except Exception as e:
- logger.error(f"ADB 截图失败: {type(e).__name__}: {str(e)}")
- return ADBScreenshotOut(
- code=500,
- status="error",
- message=f"ADB 截图失败: {type(e).__name__}: {str(e)}",
- image_base64="",
- image_width=0,
- image_height=0,
- serial=params.serial,
- )
+ raise HTTPException(status_code=500, detail=f"ADB 截图失败: {str(e)}") from e
+
+ buffer = BytesIO()
+ screenshot_image.save(buffer, format="PNG")
+ image_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
+
+ logger.info(
+ f"成功通过 ADB 截取设备 [{params.serial}] 的截图,尺寸: {screenshot_image.size}"
+ )
+
+ return ADBScreenshotOut(
+ code=200,
+ status="success",
+ message="ADB 截图成功",
+ image_base64=image_base64,
+ image_width=screenshot_image.width,
+ image_height=screenshot_image.height,
+ serial=params.serial,
+ )
# ========== 测试接口:检查图像 ==========
@@ -301,7 +247,6 @@ async def get_screenshot_adb(params: ADBScreenshotIn = Body(...)) -> ADBScreensh
tags=["Get"],
summary="检查是否存在指定图像",
response_model=CheckImageOut,
- status_code=200,
)
async def check_image(params: CheckImageIn = Body(...)) -> CheckImageOut:
"""
@@ -318,7 +263,8 @@ async def check_image(params: CheckImageIn = Body(...)) -> CheckImageOut:
Returns:
CheckImageOut: 包含查找结果和尝试次数
"""
- try:
+
+ async def _success() -> CheckImageOut:
# 设置全局窗口标题
OCRTool.set_title(params.window_title)
@@ -333,22 +279,15 @@ async def check_image(params: CheckImageIn = Body(...)) -> CheckImageOut:
logger.info(f"图像检查完成: {params.image_path}, 结果: {found}")
return CheckImageOut(
- code=200,
- status="success",
message=f"图像检查完成,{'找到' if found else '未找到'}图像",
found=found,
attempts=params.retry_times,
)
+ try:
+ return await _success()
except Exception as e:
- logger.error(f"图像检查失败: {type(e).__name__}: {str(e)}")
- return CheckImageOut(
- code=500,
- status="error",
- message=f"图像检查失败: {type(e).__name__}: {str(e)}",
- found=False,
- attempts=0,
- )
+ _raise_ocr_http_error("图像检查失败", e)
@router.post(
@@ -356,7 +295,6 @@ async def check_image(params: CheckImageIn = Body(...)) -> CheckImageOut:
tags=["Get"],
summary="检查是否存在任意一个指定图像",
response_model=CheckImageOut,
- status_code=200,
)
async def check_image_any(params: CheckImageAnyIn = Body(...)) -> CheckImageOut:
"""
@@ -373,7 +311,8 @@ async def check_image_any(params: CheckImageAnyIn = Body(...)) -> CheckImageOut:
Returns:
CheckImageOut: 包含查找结果和尝试次数
"""
- try:
+
+ async def _success() -> CheckImageOut:
# 设置全局窗口标题
OCRTool.set_title(params.window_title)
@@ -388,22 +327,15 @@ async def check_image_any(params: CheckImageAnyIn = Body(...)) -> CheckImageOut:
logger.info(f"多图像检查(ANY)完成: {params.image_paths}, 结果: {found}")
return CheckImageOut(
- code=200,
- status="success",
message=f"多图像检查完成,{'找到任意一个' if found else '未找到任何'}图像",
found=found,
attempts=params.retry_times,
)
+ try:
+ return await _success()
except Exception as e:
- logger.error(f"多图像检查(ANY)失败: {type(e).__name__}: {str(e)}")
- return CheckImageOut(
- code=500,
- status="error",
- message=f"多图像检查失败: {type(e).__name__}: {str(e)}",
- found=False,
- attempts=0,
- )
+ _raise_ocr_http_error("多图像检查失败", e)
@router.post(
@@ -411,7 +343,6 @@ async def check_image_any(params: CheckImageAnyIn = Body(...)) -> CheckImageOut:
tags=["Get"],
summary="检查是否存在所有指定图像",
response_model=CheckImageOut,
- status_code=200,
)
async def check_image_all(params: CheckImageAllIn = Body(...)) -> CheckImageOut:
"""
@@ -428,7 +359,8 @@ async def check_image_all(params: CheckImageAllIn = Body(...)) -> CheckImageOut:
Returns:
CheckImageOut: 包含查找结果和尝试次数
"""
- try:
+
+ async def _success() -> CheckImageOut:
# 设置全局窗口标题
OCRTool.set_title(params.window_title)
@@ -443,22 +375,15 @@ async def check_image_all(params: CheckImageAllIn = Body(...)) -> CheckImageOut:
logger.info(f"多图像检查(ALL)完成: {params.image_paths}, 结果: {found}")
return CheckImageOut(
- code=200,
- status="success",
message=f"多图像检查完成,{'找到所有' if found else '未找到所有'}图像",
found=found,
attempts=params.retry_times,
)
+ try:
+ return await _success()
except Exception as e:
- logger.error(f"多图像检查(ALL)失败: {type(e).__name__}: {str(e)}")
- return CheckImageOut(
- code=500,
- status="error",
- message=f"多图像检查失败: {type(e).__name__}: {str(e)}",
- found=False,
- attempts=0,
- )
+ _raise_ocr_http_error("多图像检查失败", e)
# ========== 测试接口:点击操作 ==========
@@ -467,7 +392,6 @@ async def check_image_all(params: CheckImageAllIn = Body(...)) -> CheckImageOut:
tags=["Action"],
summary="点击指定图像位置",
response_model=ClickOut,
- status_code=200,
)
async def click_image(params: ClickImageIn = Body(...)) -> ClickOut:
"""
@@ -484,7 +408,8 @@ async def click_image(params: ClickImageIn = Body(...)) -> ClickOut:
Returns:
ClickOut: 包含点击结果和尝试次数
"""
- try:
+
+ async def _success() -> ClickOut:
# 设置全局窗口标题
OCRTool.set_title(params.window_title)
@@ -499,22 +424,15 @@ async def click_image(params: ClickImageIn = Body(...)) -> ClickOut:
logger.info(f"图像点击完成: {params.image_path}, 结果: {success}")
return ClickOut(
- code=200,
- status="success",
message=f"图像点击{'成功' if success else '失败'}",
success=success,
attempts=params.retry_times,
)
+ try:
+ return await _success()
except Exception as e:
- logger.error(f"图像点击失败: {type(e).__name__}: {str(e)}")
- return ClickOut(
- code=500,
- status="error",
- message=f"图像点击失败: {type(e).__name__}: {str(e)}",
- success=False,
- attempts=0,
- )
+ _raise_ocr_http_error("图像点击失败", e)
@router.post(
@@ -522,7 +440,6 @@ async def click_image(params: ClickImageIn = Body(...)) -> ClickOut:
tags=["Action"],
summary="点击指定文字位置",
response_model=ClickOut,
- status_code=200,
)
async def click_text(params: ClickTextIn = Body(...)) -> ClickOut:
"""
@@ -538,7 +455,8 @@ async def click_text(params: ClickTextIn = Body(...)) -> ClickOut:
Returns:
ClickOut: 包含点击结果和尝试次数
"""
- try:
+
+ async def _success() -> ClickOut:
# 设置全局窗口标题
OCRTool.set_title(params.window_title)
@@ -550,19 +468,12 @@ async def click_text(params: ClickTextIn = Body(...)) -> ClickOut:
logger.info(f"文字点击完成: '{params.text}', 结果: {success}")
return ClickOut(
- code=200,
- status="success",
message=f"文字点击{'成功' if success else '失败'}",
success=success,
attempts=params.retry_times,
)
+ try:
+ return await _success()
except Exception as e:
- logger.error(f"文字点击失败: {type(e).__name__}: {str(e)}")
- return ClickOut(
- code=500,
- status="error",
- message=f"文字点击失败: {type(e).__name__}: {str(e)}",
- success=False,
- attempts=0,
- )
+ _raise_ocr_http_error("文字点击失败", e)
diff --git a/app/api/plan.py b/app/api/plan.py
index dd2571ec..aefa49e7 100644
--- a/app/api/plan.py
+++ b/app/api/plan.py
@@ -21,110 +21,100 @@
# Contact: DLmaster_361@163.com
-from fastapi import APIRouter, Body
+from typing import Annotated
+
+from fastapi import APIRouter, Body, Path
from app.core import Config
-from app.models.schema import *
+from app.contracts.common_contract import (
+ IndexOrderPatch,
+ OutBase,
+ dump_writable_data,
+ project_model,
+ project_model_list,
+ project_model_map,
+)
+from app.contracts.plan_contract import (
+ MaaPlanRead,
+ PlanCreateIn,
+ PlanCreateOut,
+ PlanDetailOut,
+ PlanGetOut,
+ PlanIndexItem,
+ PlanUpdateBody,
+)
router = APIRouter(prefix="/api/plan", tags=["计划管理"])
+PlanIdPath = Annotated[str, Path(description="计划 ID")]
+
@router.post(
- "/add",
+ "",
tags=["Add"],
- summary="添加计划表",
+ summary="创建计划表",
response_model=PlanCreateOut,
- status_code=200,
)
-async def add_plan(plan: PlanCreateIn = Body(...)) -> PlanCreateOut:
-
- try:
- uid, config = await Config.add_plan(plan.type)
- data = MaaPlanConfig(**(await config.toDict()))
- except Exception as e:
- return PlanCreateOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- planId="",
- data=MaaPlanConfig(**{}),
- )
- return PlanCreateOut(planId=str(uid), data=data)
+async def create_plan(plan: PlanCreateIn = Body(...)) -> PlanCreateOut:
+ uid, config = await Config.add_plan(plan.type)
+ data = project_model(MaaPlanRead, await config.toDict())
+ return PlanCreateOut(id=str(uid), data=data)
-@router.post(
- "/get",
+@router.get(
+ "",
tags=["Get"],
- summary="查询计划表",
+ summary="查询全部计划表",
response_model=PlanGetOut,
- status_code=200,
)
-async def get_plan(plan: PlanGetIn = Body(...)) -> PlanGetOut:
-
- try:
- index, data = await Config.get_plan(plan.planId)
- index = [PlanIndexItem(**_) for _ in index]
- data = {uid: MaaPlanConfig(**cfg) for uid, cfg in data.items()}
- except Exception as e:
- return PlanGetOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- index=[],
- data={},
- )
- return PlanGetOut(index=index, data=data)
+async def list_plans() -> PlanGetOut:
+ index, data = await Config.get_plan(None)
+ return PlanGetOut(
+ index=project_model_list(PlanIndexItem, index),
+ data=project_model_map(MaaPlanRead, data),
+ )
-@router.post(
- "/update",
+@router.get(
+ "/{plan_id}",
+ tags=["Get"],
+ summary="查询单个计划表",
+ response_model=PlanDetailOut,
+)
+async def get_plan(plan_id: PlanIdPath) -> PlanDetailOut:
+ _, data = await Config.get_plan(plan_id)
+ projected = project_model_map(MaaPlanRead, data)
+ return PlanDetailOut(data=projected[plan_id])
+
+
+@router.patch(
+ "/{plan_id}",
tags=["Update"],
- summary="更新计划表配置信息",
+ summary="更新计划表",
response_model=OutBase,
- status_code=200,
)
-async def update_plan(plan: PlanUpdateIn = Body(...)) -> OutBase:
-
- try:
- await Config.update_plan(plan.planId, plan.data.model_dump(exclude_unset=True))
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def update_plan(plan_id: PlanIdPath, body: PlanUpdateBody = Body(...)) -> OutBase:
+ await Config.update_plan(plan_id, dump_writable_data(body.data))
return OutBase()
-@router.post(
- "/delete",
+@router.delete(
+ "/{plan_id}",
tags=["Delete"],
summary="删除计划表",
response_model=OutBase,
- status_code=200,
)
-async def delete_plan(plan: PlanDeleteIn = Body(...)) -> OutBase:
-
- try:
- await Config.del_plan(plan.planId)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def delete_plan(plan_id: PlanIdPath) -> OutBase:
+ await Config.del_plan(plan_id)
return OutBase()
-@router.post(
+@router.patch(
"/order",
tags=["Update"],
summary="重新排序计划表",
response_model=OutBase,
- status_code=200,
)
-async def reorder_plan(plan: PlanReorderIn = Body(...)) -> OutBase:
-
- try:
- await Config.reorder_plan(plan.indexList)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def reorder_plan(body: IndexOrderPatch = Body(...)) -> OutBase:
+ await Config.reorder_plan(body.index_list)
return OutBase()
diff --git a/app/api/plugins.py b/app/api/plugins.py
index 4a3fdec0..af056a9c 100644
--- a/app/api/plugins.py
+++ b/app/api/plugins.py
@@ -2,60 +2,57 @@
# Copyright © 2025-2026 AUTO-MAS Team
import os
-from pathlib import Path
-from typing import Any, Dict, List, Optional
+from typing import Any, Optional
from fastapi import APIRouter, Body
from pydantic import BaseModel, Field
+from app.contracts.common_contract import OutBase
from app.core.plugins import PluginConfigStore, PluginManager
-from app.models.schema import OutBase
-if os.getenv("AUTO_MAS_DEV") == "1":
- from scripts.dev_stub_generator import (
- generate_plugin_context_stubs,
- is_dev_stub_generation_enabled,
- )
-else:
- def is_dev_stub_generation_enabled() -> bool:
- """判断是否允许生成开发期类型提示。
+DEV_STUB_ENABLED = os.getenv("AUTO_MAS_DEV") == "1"
- Returns:
- bool: 在非开发模式下恒为 False。
- """
- return False
+def _dev_stub_generation_disabled() -> bool:
+ return False
- def generate_plugin_context_stubs() -> Dict[str, Any]:
- """非开发模式下的兜底实现。
- Returns:
- Dict[str, Any]: 不返回有效结果,调用时将抛出异常。
+def _raise_dev_stub_generation_disabled() -> dict[str, Any]:
+ raise RuntimeError("当前未启用插件上下文类型提示生成")
- Raises:
- RuntimeError: 当 AUTO_MAS_DEV 不为 "1" 时禁止生成类型提示。
- """
- raise RuntimeError("当前非开发模式,未加载 dev_stub_generator")
+
+is_dev_stub_generation_enabled = _dev_stub_generation_disabled
+generate_plugin_context_stubs = _raise_dev_stub_generation_disabled
+
+if DEV_STUB_ENABLED:
+ from scripts.dev_stub_generator import (
+ generate_plugin_context_stubs,
+ is_dev_stub_generation_enabled,
+ )
router = APIRouter(prefix="/api/plugins", tags=["插件实例"])
config_store = PluginConfigStore()
+def _empty_plugin_instance_models() -> list["PluginInstanceModel"]:
+ return []
+
+
class PluginInstanceModel(BaseModel):
id: str = Field(..., description="实例ID")
plugin: str = Field(..., description="插件名")
enabled: bool = Field(default=True, description="是否启用")
name: str = Field(..., description="实例名称")
- config: Dict[str, Any] = Field(default_factory=dict, description="插件配置")
+ config: dict[str, Any] = Field(default_factory=dict, description="插件配置")
class PluginRuntimeStateModel(BaseModel):
instance_id: str = Field(..., description="实例ID")
plugin: str = Field(..., description="插件名")
status: str = Field(default="configured", description="运行状态")
- generation: int = Field(default=0, description="实例代际(每次重载成功后递增)")
+ generation: int = Field(default=0, description="实例代际")
lifecycle_phase: str = Field(default="configured", description="生命周期阶段")
lifecycle_updated_at: Optional[str] = Field(default=None, description="生命周期阶段更新时间")
reload_count: int = Field(default=0, description="成功重载次数")
@@ -72,12 +69,11 @@ class PluginRuntimeStateModel(BaseModel):
class PluginsGetOut(OutBase):
- version: int = Field(default=1, description="配置版本")
- discovered_plugins: List[str] = Field(default_factory=list, description="已发现插件")
- schemas: Dict[str, Dict[str, Any]] = Field(default_factory=dict, description="插件Schema映射")
- schema_errors: Dict[str, str] = Field(default_factory=dict, description="插件Schema加载错误")
- instances: List[PluginInstanceModel] = Field(default_factory=list, description="插件实例列表")
- runtime_states: Dict[str, PluginRuntimeStateModel] = Field(
+ discovered_plugins: list[str] = Field(default_factory=list, description="已发现插件")
+ schemas: dict[str, dict[str, Any]] = Field(default_factory=dict, description="插件Schema映射")
+ schema_errors: dict[str, str] = Field(default_factory=dict, description="插件Schema加载错误")
+ instances: list[PluginInstanceModel] = Field(default_factory=_empty_plugin_instance_models, description="插件实例列表")
+ runtime_states: dict[str, PluginRuntimeStateModel] = Field(
default_factory=dict,
description="插件实例运行态",
)
@@ -87,11 +83,11 @@ class PluginAddIn(BaseModel):
plugin: str = Field(..., description="插件名")
name: Optional[str] = Field(default=None, description="实例名称")
enabled: bool = Field(default=True, description="是否启用")
- config: Dict[str, Any] = Field(default_factory=dict, description="插件配置")
+ config: dict[str, Any] = Field(default_factory=dict, description="插件配置")
-class PluginAddOut(OutBase):
- instance: Optional[PluginInstanceModel] = Field(default=None, description="新增实例")
+class PluginMutationOut(OutBase):
+ instance: Optional[PluginInstanceModel] = Field(default=None, description="当前实例")
class PluginUpdateIn(BaseModel):
@@ -99,7 +95,7 @@ class PluginUpdateIn(BaseModel):
plugin: Optional[str] = Field(default=None, description="插件名")
name: Optional[str] = Field(default=None, description="实例名称")
enabled: Optional[bool] = Field(default=None, description="是否启用")
- config: Optional[Dict[str, Any]] = Field(default=None, description="插件配置")
+ config: Optional[dict[str, Any]] = Field(default=None, description="插件配置")
class PluginDeleteIn(BaseModel):
@@ -115,38 +111,38 @@ class PluginReloadPluginIn(BaseModel):
class PluginDevRebuildCtxStubIn(BaseModel):
- force: bool = Field(default=False, description="是否在非开发模式下强制生成")
+ force: bool = Field(default=False, description="是否强制重建")
class PluginDevRebuildCtxStubOut(OutBase):
output_dir: Optional[str] = Field(default=None, description="生成目录")
- changed_files: List[str] = Field(default_factory=list, description="已更新文件")
- unchanged_files: List[str] = Field(default_factory=list, description="未变更文件")
+ changed_files: list[str] = Field(default_factory=list, description="已更新文件")
+ unchanged_files: list[str] = Field(default_factory=list, description="未变更文件")
class PluginPackageIn(BaseModel):
package: str = Field(..., description="PyPI 包名")
-async def _discover_plugins(plugins_dir: Path) -> Dict[str, Any]:
- """发现插件(先自动安装本地 editable,再统一按 Entry Point 发现)。"""
- PluginManager.plugins_dir = plugins_dir
- PluginManager.loader.plugins_dir = plugins_dir
+async def _discover_plugins() -> dict[str, Any]:
return await PluginManager.discover_plugins()
-def _build_instances(root: Dict[str, Any]) -> List[PluginInstanceModel]:
- instances: List[PluginInstanceModel] = []
- for item in root.get("instances", []):
- if not isinstance(item, dict):
- continue
- instances.append(PluginInstanceModel(**item))
- return instances
+def _to_instance_model(instance: PluginConfigStore.PluginInstance) -> PluginInstanceModel:
+ return PluginInstanceModel(
+ id=instance.id,
+ plugin=instance.plugin,
+ enabled=instance.enabled,
+ name=instance.name,
+ config=instance.config,
+ )
-def _build_schemas(discovered: Dict[str, Any]) -> tuple[Dict[str, Dict[str, Any]], Dict[str, str]]:
- schemas: Dict[str, Dict[str, Any]] = {}
- errors: Dict[str, str] = {}
+def _build_schemas(
+ discovered: dict[str, Any],
+) -> tuple[dict[str, dict[str, Any]], dict[str, str]]:
+ schemas: dict[str, dict[str, Any]] = {}
+ errors: dict[str, str] = {}
for plugin_name, plugin_source in discovered.items():
plugin_path = getattr(plugin_source, "path", None)
try:
@@ -157,21 +153,32 @@ def _build_schemas(discovered: Dict[str, Any]) -> tuple[Dict[str, Dict[str, Any]
return schemas, errors
-def _build_runtime_states(root: Dict[str, Any]) -> Dict[str, PluginRuntimeStateModel]:
- """构建插件实例运行态快照。
+def _build_runtime_states(
+ instances: list[PluginConfigStore.PluginInstance],
+) -> dict[str, PluginRuntimeStateModel]:
+ result: dict[str, PluginRuntimeStateModel] = {}
+ records = getattr(PluginManager.loader, "records", {})
- 优先返回运行中记录(来自 PluginLoader),若实例尚未加载则返回 configured 状态。
- """
- result: Dict[str, PluginRuntimeStateModel] = {}
+ for instance in instances:
+ record = records.get(instance.id)
+ if record is None:
+ result[instance.id] = PluginRuntimeStateModel(
+ instance_id=instance.id,
+ plugin=instance.plugin,
+ status="configured",
+ generation=0,
+ lifecycle_phase="configured",
+ )
+ continue
- records = getattr(PluginManager.loader, "records", {})
- for instance_id, record in records.items():
- result[str(instance_id)] = PluginRuntimeStateModel(
+ result[instance.id] = PluginRuntimeStateModel(
instance_id=str(record.instance_id),
plugin=str(record.plugin_name),
status=str(record.status),
generation=int(getattr(record, "generation", 0) or 0),
- lifecycle_phase=str(getattr(record, "lifecycle_phase", record.status) or record.status),
+ lifecycle_phase=str(
+ getattr(record, "lifecycle_phase", record.status) or record.status
+ ),
lifecycle_updated_at=getattr(record, "lifecycle_updated_at", None),
reload_count=int(getattr(record, "reload_count", 0) or 0),
last_reload_reason=getattr(record, "last_reload_reason", None),
@@ -186,21 +193,6 @@ def _build_runtime_states(root: Dict[str, Any]) -> Dict[str, PluginRuntimeStateM
last_error_at=record.last_error_at,
)
- for item in root.get("instances", []):
- if not isinstance(item, dict):
- continue
- instance_id = str(item.get("id") or "")
- if not instance_id or instance_id in result:
- continue
- plugin_name = str(item.get("plugin") or "")
- result[instance_id] = PluginRuntimeStateModel(
- instance_id=instance_id,
- plugin=plugin_name,
- status="configured",
- generation=0,
- lifecycle_phase="configured",
- )
-
return result
@@ -213,28 +205,21 @@ def _build_runtime_states(root: Dict[str, Any]) -> Dict[str, PluginRuntimeStateM
)
async def get_plugins() -> PluginsGetOut:
try:
- plugins_dir = Path.cwd() / "plugins"
- discovered = await _discover_plugins(plugins_dir)
- root = await config_store.get_root(
- plugins_dir,
- discovered,
- auto_create_missing=False,
- )
+ discovered = await _discover_plugins()
+ instances = await config_store.load_instances()
schemas, schema_errors = _build_schemas(discovered)
return PluginsGetOut(
- version=int(root.get("version", 1)),
discovered_plugins=list(discovered.keys()),
schemas=schemas,
schema_errors=schema_errors,
- instances=_build_instances(root),
- runtime_states=_build_runtime_states(root),
+ instances=[_to_instance_model(instance) for instance in instances],
+ runtime_states=_build_runtime_states(instances),
)
except Exception as e:
return PluginsGetOut(
code=500,
status="error",
message=f"{type(e).__name__}: {str(e)}",
- version=1,
discovered_plugins=[],
schemas={},
schema_errors={},
@@ -296,17 +281,6 @@ async def reload_plugin_by_name(data: PluginReloadPluginIn = Body(...)) -> OutBa
status_code=200,
)
async def install_plugin_package(data: PluginPackageIn = Body(...)) -> OutBase:
- """下载安装指定插件包。
-
- Args:
- data (PluginPackageIn): 包名参数。
-
- Returns:
- OutBase: 统一响应对象。
-
- Raises:
- 无。接口内部会捕获异常并转换为统一错误响应。
- """
try:
await PluginManager.install_plugin_package(data.package)
return OutBase(message=f"插件包下载安装成功: {data.package}")
@@ -322,17 +296,6 @@ async def install_plugin_package(data: PluginPackageIn = Body(...)) -> OutBase:
status_code=200,
)
async def uninstall_plugin_package(data: PluginPackageIn = Body(...)) -> OutBase:
- """卸载指定插件包。
-
- Args:
- data (PluginPackageIn): 包名参数。
-
- Returns:
- OutBase: 统一响应对象。
-
- Raises:
- 无。接口内部会捕获异常并转换为统一错误响应。
- """
try:
await PluginManager.uninstall_plugin_package(data.package)
return OutBase(message=f"插件包卸载成功: {data.package}")
@@ -350,25 +313,25 @@ async def uninstall_plugin_package(data: PluginPackageIn = Body(...)) -> OutBase
async def rebuild_plugin_ctx_stub(
data: PluginDevRebuildCtxStubIn = Body(...),
) -> PluginDevRebuildCtxStubOut:
- """手动触发插件上下文 .pyi 重建。
-
- 该接口用于插件开发阶段快速刷新类型提示,便于 IDE 立即获得最新签名。
-
- Args:
- data (PluginDevRebuildCtxStubIn): 重建参数。
-
- Returns:
- PluginDevRebuildCtxStubOut: 重建结果摘要。
-
- Raises:
- 无。接口内部会捕获异常并转换为统一错误响应。
- """
try:
+ if not DEV_STUB_ENABLED:
+ return PluginDevRebuildCtxStubOut(
+ code=403,
+ status="error",
+ message="当前非开发模式,禁止生成插件上下文类型提示",
+ output_dir=None,
+ changed_files=[],
+ unchanged_files=[],
+ )
+
if not data.force and not is_dev_stub_generation_enabled():
return PluginDevRebuildCtxStubOut(
code=403,
status="error",
- message="当前非开发模式,请设置 AUTO_MAS_DEV=1 或传 force=true",
+ message="当前未启用插件上下文类型提示生成",
+ output_dir=None,
+ changed_files=[],
+ unchanged_files=[],
)
result = generate_plugin_context_stubs()
@@ -398,46 +361,28 @@ async def rebuild_plugin_ctx_stub(
"/add",
tags=["Add"],
summary="新增插件实例",
- response_model=PluginAddOut,
+ response_model=PluginMutationOut,
status_code=200,
)
-async def add_plugin_instance(data: PluginAddIn = Body(...)) -> PluginAddOut:
+async def add_plugin_instance(data: PluginAddIn = Body(...)) -> PluginMutationOut:
try:
- plugins_dir = Path.cwd() / "plugins"
- discovered = await _discover_plugins(plugins_dir)
-
- if data.plugin not in discovered:
- raise ValueError(f"未发现插件: {data.plugin}")
-
- # 先校验配置是否合法(包含默认值注入)
- plugin_path = getattr(discovered[data.plugin], "path", None)
- effective_config = config_store.load_effective_config(
- data.plugin,
- plugin_path,
- data.config,
+ discovered = await _discover_plugins()
+ instance = await config_store.create_instance(
+ plugin_name=data.plugin,
+ name=data.name,
+ enabled=data.enabled,
+ raw_config=data.config,
+ discovered_plugins=discovered,
)
+ if PluginManager.started and instance.enabled:
+ await PluginManager.reload_instance(instance.id)
- root = await config_store.get_root(
- plugins_dir,
- discovered,
- auto_create_missing=False,
+ return PluginMutationOut(
+ message=f"插件实例创建成功: {instance.id}",
+ instance=_to_instance_model(instance),
)
- instance = {
- "id": config_store.generate_instance_id(data.plugin),
- "plugin": data.plugin,
- "enabled": data.enabled,
- "name": data.name or f"{data.plugin} 实例",
- "config": effective_config,
- }
- root.setdefault("instances", []).append(instance)
- await config_store.save_root(plugins_dir, root)
-
- if PluginManager.started and data.enabled:
- await PluginManager.reload_instance(instance["id"])
-
- return PluginAddOut(instance=PluginInstanceModel(**instance))
except Exception as e:
- return PluginAddOut(
+ return PluginMutationOut(
code=500,
status="error",
message=f"{type(e).__name__}: {str(e)}",
@@ -449,56 +394,41 @@ async def add_plugin_instance(data: PluginAddIn = Body(...)) -> PluginAddOut:
"/update",
tags=["Update"],
summary="更新插件实例",
- response_model=OutBase,
+ response_model=PluginMutationOut,
status_code=200,
)
-async def update_plugin_instance(data: PluginUpdateIn = Body(...)) -> OutBase:
+async def update_plugin_instance(data: PluginUpdateIn = Body(...)) -> PluginMutationOut:
try:
- plugins_dir = Path.cwd() / "plugins"
- discovered = await _discover_plugins(plugins_dir)
- root = await config_store.get_root(
- plugins_dir,
- discovered,
- auto_create_missing=False,
- )
-
- instances = root.get("instances", [])
- target = None
- for item in instances:
- if isinstance(item, dict) and item.get("id") == data.instanceId:
- target = item
- break
-
- if target is None:
- raise ValueError(f"未找到插件实例: {data.instanceId}")
-
- next_plugin = data.plugin if data.plugin is not None else target.get("plugin")
- if not isinstance(next_plugin, str) or next_plugin not in discovered:
- raise ValueError(f"未发现插件: {next_plugin}")
-
- next_config = data.config if data.config is not None else target.get("config", {})
- plugin_path = getattr(discovered[next_plugin], "path", None)
- effective_config = config_store.load_effective_config(
- next_plugin,
- plugin_path,
- next_config,
+ discovered = await _discover_plugins()
+ previous, current = await config_store.update_instance(
+ data.instanceId,
+ plugin_name=data.plugin,
+ name=data.name,
+ enabled=data.enabled,
+ raw_config=data.config,
+ discovered_plugins=discovered,
)
- target["plugin"] = next_plugin
- target["config"] = effective_config
- if data.name is not None:
- target["name"] = data.name
- if data.enabled is not None:
- target["enabled"] = data.enabled
-
- await config_store.save_root(plugins_dir, root)
-
if PluginManager.started:
- await PluginManager.reload_instance(data.instanceId)
+ if previous.id != current.id:
+ await PluginManager.loader.unload_instance(previous.id)
- return OutBase()
+ if current.enabled:
+ await PluginManager.reload_instance(current.id)
+ else:
+ await PluginManager.loader.unload_instance(current.id)
+
+ return PluginMutationOut(
+ message=f"插件实例更新成功: {current.id}",
+ instance=_to_instance_model(current),
+ )
except Exception as e:
- return OutBase(code=500, status="error", message=f"{type(e).__name__}: {str(e)}")
+ return PluginMutationOut(
+ code=500,
+ status="error",
+ message=f"{type(e).__name__}: {str(e)}",
+ instance=None,
+ )
@router.post(
@@ -510,29 +440,10 @@ async def update_plugin_instance(data: PluginUpdateIn = Body(...)) -> OutBase:
)
async def delete_plugin_instance(data: PluginDeleteIn = Body(...)) -> OutBase:
try:
- plugins_dir = Path.cwd() / "plugins"
- discovered = await _discover_plugins(plugins_dir)
- root = await config_store.get_root(
- plugins_dir,
- discovered,
- auto_create_missing=False,
- )
-
- old_instances = root.get("instances", [])
- new_instances = [
- item
- for item in old_instances
- if not (isinstance(item, dict) and item.get("id") == data.instanceId)
- ]
-
- if len(new_instances) == len(old_instances):
- raise ValueError(f"未找到插件实例: {data.instanceId}")
-
+ instance = await config_store.delete_instance(data.instanceId)
if PluginManager.started:
- await PluginManager.loader.unload_instance(data.instanceId)
+ await PluginManager.loader.unload_instance(instance.id)
- root["instances"] = new_instances
- await config_store.save_root(plugins_dir, root)
- return OutBase()
+ return OutBase(message=f"插件实例删除成功: {instance.id}")
except Exception as e:
return OutBase(code=500, status="error", message=f"{type(e).__name__}: {str(e)}")
diff --git a/app/api/queue.py b/app/api/queue.py
index 51848ff0..1fc9f11c 100644
--- a/app/api/queue.py
+++ b/app/api/queue.py
@@ -21,303 +21,279 @@
# Contact: DLmaster_361@163.com
-from fastapi import APIRouter, Body
+from typing import Annotated
+
+from fastapi import APIRouter, Body, Path
-from app.core import Config
-from app.models.schema import *
from app.api.ws_command import ws_command
+from app.core import Config
+from app.contracts.common_contract import (
+ IndexOrderPatch,
+ OutBase,
+ dump_writable_data,
+ project_model,
+ project_model_list,
+ project_model_map,
+)
+from app.contracts.queue_contract import (
+ QueueCreateOut,
+ QueueDetailOut,
+ QueueGetOut,
+ QueueIndexItem,
+ QueueItemCreateOut,
+ QueueItemDetailOut,
+ QueueItemGetOut,
+ QueueItemIndexItem,
+ QueueItemRead,
+ QueueRead,
+ TimeSetCreateOut,
+ TimeSetDetailOut,
+ TimeSetGetOut,
+ TimeSetIndexItem,
+ TimeSetRead,
+)
router = APIRouter(prefix="/api/queue", tags=["调度队列管理"])
+QueueIdPath = Annotated[str, Path(description="队列 ID")]
+TimeSetIdPath = Annotated[str, Path(description="时间设置 ID")]
+QueueItemIdPath = Annotated[str, Path(description="队列项 ID")]
+
+
+@router.get(
+ "",
+ tags=["Get"],
+ summary="查询全部调度队列",
+ response_model=QueueGetOut,
+)
+async def list_queues() -> QueueGetOut:
+ index, data = await Config.get_queue(None)
+ return QueueGetOut(
+ index=project_model_list(QueueIndexItem, index),
+ data=project_model_map(QueueRead, data),
+ )
+
@ws_command("queue.add")
@router.post(
- "/add",
+ "",
tags=["Add"],
- summary="添加调度队列",
+ summary="创建调度队列",
response_model=QueueCreateOut,
- status_code=200,
)
-async def add_queue() -> QueueCreateOut:
-
- try:
- uid, config = await Config.add_queue()
- data = QueueConfig(**(await config.toDict()))
- except Exception as e:
- return QueueCreateOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- queueId="",
- data=QueueConfig(**{}),
- )
- return QueueCreateOut(queueId=str(uid), data=data)
+async def create_queue() -> QueueCreateOut:
+ uid, config = await Config.add_queue()
+ return QueueCreateOut(
+ id=str(uid),
+ data=project_model(QueueRead, await config.toDict()),
+ )
+
+
+@router.patch(
+ "/order",
+ tags=["Update"],
+ summary="重新排序调度队列",
+ response_model=OutBase,
+)
+async def reorder_queue(body: IndexOrderPatch = Body(...)) -> OutBase:
+ await Config.reorder_queue(body.index_list)
+ return OutBase()
@ws_command("queue.get")
-@router.post(
- "/get",
+@router.get(
+ "/{queue_id}",
tags=["Get"],
- summary="查询调度队列配置信息",
- response_model=QueueGetOut,
- status_code=200,
+ summary="查询单个调度队列",
+ response_model=QueueDetailOut,
)
-async def get_queues(queue: QueueGetIn = Body(...)) -> QueueGetOut:
-
- try:
- index, config = await Config.get_queue(queue.queueId)
- index = [QueueIndexItem(**_) for _ in index]
- data = {uid: QueueConfig(**cfg) for uid, cfg in config.items()}
- except Exception as e:
- return QueueGetOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- index=[],
- data={},
- )
- return QueueGetOut(index=index, data=data)
+async def get_queue(queue_id: QueueIdPath) -> QueueDetailOut:
+ _, data = await Config.get_queue(queue_id)
+ projected = project_model_map(QueueRead, data)
+ return QueueDetailOut(data=projected[queue_id])
-@router.post(
- "/update",
+@router.patch(
+ "/{queue_id}",
tags=["Update"],
- summary="更新调度队列配置信息",
+ summary="更新调度队列",
response_model=OutBase,
- status_code=200,
)
-async def update_queue(queue: QueueUpdateIn = Body(...)) -> OutBase:
-
- try:
- await Config.update_queue(
- queue.queueId, queue.data.model_dump(exclude_unset=True)
- )
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def update_queue(queue_id: QueueIdPath, data: QueueRead = Body(...)) -> OutBase:
+ await Config.update_queue(queue_id, dump_writable_data(data))
return OutBase()
-@router.post(
- "/delete",
+@router.delete(
+ "/{queue_id}",
tags=["Delete"],
summary="删除调度队列",
response_model=OutBase,
- status_code=200,
)
-async def delete_queue(queue: QueueDeleteIn = Body(...)) -> OutBase:
-
- try:
- await Config.del_queue(queue.queueId)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def delete_queue(queue_id: QueueIdPath) -> OutBase:
+ await Config.del_queue(queue_id)
return OutBase()
-@router.post(
- "/order",
- tags=["Update"],
- summary="重新排序",
- response_model=OutBase,
- status_code=200,
-)
-async def reorder_queue(script: QueueReorderIn = Body(...)) -> OutBase:
-
- try:
- await Config.reorder_queue(script.indexList)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
- return OutBase()
-
-
-@router.post(
- "/time/get",
+@router.get(
+ "/{queue_id}/times",
tags=["Get"],
- summary="查询定时项",
+ summary="查询队列下的全部定时项",
response_model=TimeSetGetOut,
- status_code=200,
)
-async def get_time_set(time: TimeSetGetIn = Body(...)) -> TimeSetGetOut:
-
- try:
- index, data = await Config.get_time_set(time.queueId, time.timeSetId)
- index = [TimeSetIndexItem(**_) for _ in index]
- data = {uid: TimeSet(**cfg) for uid, cfg in data.items()}
- except Exception as e:
- return TimeSetGetOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- index=[],
- data={},
- )
- return TimeSetGetOut(index=index, data=data)
+async def list_time_sets(queue_id: QueueIdPath) -> TimeSetGetOut:
+ index, data = await Config.get_time_set(queue_id, None)
+ return TimeSetGetOut(
+ index=project_model_list(TimeSetIndexItem, index),
+ data=project_model_map(TimeSetRead, data),
+ )
@router.post(
- "/time/add",
+ "/{queue_id}/times",
tags=["Add"],
- summary="添加定时项",
+ summary="创建定时项",
response_model=TimeSetCreateOut,
- status_code=200,
)
-async def add_time_set(time: QueueSetInBase = Body(...)) -> TimeSetCreateOut:
-
- uid, config = await Config.add_time_set(time.queueId)
- data = TimeSet(**(await config.toDict()))
- return TimeSetCreateOut(timeSetId=str(uid), data=data)
+async def create_time_set(queue_id: QueueIdPath) -> TimeSetCreateOut:
+ uid, config = await Config.add_time_set(queue_id)
+ return TimeSetCreateOut(
+ id=str(uid),
+ data=project_model(TimeSetRead, await config.toDict()),
+ )
-@router.post(
- "/time/update",
+@router.patch(
+ "/{queue_id}/times/order",
tags=["Update"],
- summary="更新定时项",
+ summary="重新排序定时项",
response_model=OutBase,
- status_code=200,
)
-async def update_time_set(time: TimeSetUpdateIn = Body(...)) -> OutBase:
-
- try:
- await Config.update_time_set(
- time.queueId, time.timeSetId, time.data.model_dump(exclude_unset=True)
- )
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def reorder_time_sets(
+ queue_id: QueueIdPath, body: IndexOrderPatch = Body(...)
+) -> OutBase:
+ await Config.reorder_time_set(queue_id, body.index_list)
return OutBase()
-@router.post(
- "/time/delete",
- tags=["Delete"],
- summary="删除定时项",
+@router.get(
+ "/{queue_id}/times/{time_set_id}",
+ tags=["Get"],
+ summary="查询单个定时项",
+ response_model=TimeSetDetailOut,
+)
+async def get_time_set(
+ queue_id: QueueIdPath, time_set_id: TimeSetIdPath
+) -> TimeSetDetailOut:
+ _, data = await Config.get_time_set(queue_id, time_set_id)
+ projected = project_model_map(TimeSetRead, data)
+ return TimeSetDetailOut(data=projected[time_set_id])
+
+
+@router.patch(
+ "/{queue_id}/times/{time_set_id}",
+ tags=["Update"],
+ summary="更新定时项",
response_model=OutBase,
- status_code=200,
)
-async def delete_time_set(time: TimeSetDeleteIn = Body(...)) -> OutBase:
-
- try:
- await Config.del_time_set(time.queueId, time.timeSetId)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def update_time_set(
+ queue_id: QueueIdPath,
+ time_set_id: TimeSetIdPath,
+ data: TimeSetRead = Body(...),
+) -> OutBase:
+ await Config.update_time_set(queue_id, time_set_id, dump_writable_data(data))
return OutBase()
-@router.post(
- "/time/order",
- tags=["Update"],
- summary="重新排序定时项",
+@router.delete(
+ "/{queue_id}/times/{time_set_id}",
+ tags=["Delete"],
+ summary="删除定时项",
response_model=OutBase,
- status_code=200,
)
-async def reorder_time_set(time: TimeSetReorderIn = Body(...)) -> OutBase:
-
- try:
- await Config.reorder_time_set(time.queueId, time.indexList)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def delete_time_set(queue_id: QueueIdPath, time_set_id: TimeSetIdPath) -> OutBase:
+ await Config.del_time_set(queue_id, time_set_id)
return OutBase()
-@router.post(
- "/item/get",
+@router.get(
+ "/{queue_id}/items",
tags=["Get"],
- summary="查询队列项",
+ summary="查询队列下的全部队列项",
response_model=QueueItemGetOut,
- status_code=200,
)
-async def get_item(item: QueueItemGetIn = Body(...)) -> QueueItemGetOut:
-
- try:
- index, data = await Config.get_queue_item(item.queueId, item.queueItemId)
- index = [QueueItemIndexItem(**_) for _ in index]
- data = {uid: QueueItem(**cfg) for uid, cfg in data.items()}
- except Exception as e:
- return QueueItemGetOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- index=[],
- data={},
- )
- return QueueItemGetOut(index=index, data=data)
+async def list_queue_items(queue_id: QueueIdPath) -> QueueItemGetOut:
+ index, data = await Config.get_queue_item(queue_id, None)
+ return QueueItemGetOut(
+ index=project_model_list(QueueItemIndexItem, index),
+ data=project_model_map(QueueItemRead, data),
+ )
@router.post(
- "/item/add",
+ "/{queue_id}/items",
tags=["Add"],
- summary="添加队列项",
+ summary="创建队列项",
response_model=QueueItemCreateOut,
- status_code=200,
)
-async def add_item(item: QueueSetInBase = Body(...)) -> QueueItemCreateOut:
-
- uid, config = await Config.add_queue_item(item.queueId)
- data = QueueItem(**(await config.toDict()))
- return QueueItemCreateOut(queueItemId=str(uid), data=data)
+async def create_queue_item(queue_id: QueueIdPath) -> QueueItemCreateOut:
+ uid, config = await Config.add_queue_item(queue_id)
+ return QueueItemCreateOut(
+ id=str(uid),
+ data=project_model(QueueItemRead, await config.toDict()),
+ )
-@router.post(
- "/item/update",
+@router.patch(
+ "/{queue_id}/items/order",
tags=["Update"],
- summary="更新队列项",
+ summary="重新排序队列项",
response_model=OutBase,
- status_code=200,
)
-async def update_item(item: QueueItemUpdateIn = Body(...)) -> OutBase:
-
- try:
- await Config.update_queue_item(
- item.queueId, item.queueItemId, item.data.model_dump(exclude_unset=True)
- )
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def reorder_queue_items(
+ queue_id: QueueIdPath, body: IndexOrderPatch = Body(...)
+) -> OutBase:
+ await Config.reorder_queue_item(queue_id, body.index_list)
return OutBase()
-@router.post(
- "/item/delete",
- tags=["Delete"],
- summary="删除队列项",
+@router.get(
+ "/{queue_id}/items/{queue_item_id}",
+ tags=["Get"],
+ summary="查询单个队列项",
+ response_model=QueueItemDetailOut,
+)
+async def get_queue_item(
+ queue_id: QueueIdPath, queue_item_id: QueueItemIdPath
+) -> QueueItemDetailOut:
+ _, data = await Config.get_queue_item(queue_id, queue_item_id)
+ projected = project_model_map(QueueItemRead, data)
+ return QueueItemDetailOut(data=projected[queue_item_id])
+
+
+@router.patch(
+ "/{queue_id}/items/{queue_item_id}",
+ tags=["Update"],
+ summary="更新队列项",
response_model=OutBase,
- status_code=200,
)
-async def delete_item(item: QueueItemDeleteIn = Body(...)) -> OutBase:
-
- try:
- await Config.del_queue_item(item.queueId, item.queueItemId)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def update_queue_item(
+ queue_id: QueueIdPath,
+ queue_item_id: QueueItemIdPath,
+ data: QueueItemRead = Body(...),
+) -> OutBase:
+ await Config.update_queue_item(queue_id, queue_item_id, dump_writable_data(data))
return OutBase()
-@router.post(
- "/item/order",
- tags=["Update"],
- summary="重新排序队列项",
+@router.delete(
+ "/{queue_id}/items/{queue_item_id}",
+ tags=["Delete"],
+ summary="删除队列项",
response_model=OutBase,
- status_code=200,
)
-async def reorder_item(item: QueueItemReorderIn = Body(...)) -> OutBase:
-
- try:
- await Config.reorder_queue_item(item.queueId, item.indexList)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def delete_queue_item(
+ queue_id: QueueIdPath, queue_item_id: QueueItemIdPath
+) -> OutBase:
+ await Config.del_queue_item(queue_id, queue_item_id)
return OutBase()
diff --git a/app/api/scripts.py b/app/api/scripts.py
index 76b82232..b3664b36 100644
--- a/app/api/scripts.py
+++ b/app/api/scripts.py
@@ -22,464 +22,412 @@
import uuid
-from fastapi import APIRouter, Body
+from typing import Annotated
+
+from fastapi import APIRouter, Body, Path
+from pydantic import TypeAdapter
from app.core import Config
-from app.models.schema import *
+from app.contracts.common_contract import (
+ ComboBoxItem,
+ ComboBoxOut,
+ IndexOrderPatch,
+ OutBase,
+ dump_writable_data,
+ project_model,
+ project_model_list,
+ project_model_map,
+)
+from app.contracts.scripts_contract import (
+ ScriptPatchBody,
+ InfrastructureImportBody,
+ ScriptCreateIn,
+ ScriptCreateOut,
+ ScriptDetailOut,
+ ScriptFileBody,
+ ScriptGetOut,
+ ScriptIndexItem,
+ ScriptUploadBody,
+ ScriptUrlBody,
+ UserPatchBody,
+ UserCreateOut,
+ UserDetailOut,
+ UserGetOut,
+ UserIndexItem,
+ project_script_model,
+ project_script_model_map,
+ project_user_model,
+ project_user_model_map,
+ script_contract_type_from_runtime,
+ user_contract_type_from_script,
+ dump_script_patch_data,
+ dump_user_patch_data,
+)
+from app.contracts.setting_contract import (
+ WebhookCreateOut,
+ WebhookDetailOut,
+ WebhookGetOut,
+ WebhookIndexItem,
+ WebhookRead,
+)
+
+COMBOBOX_ITEMS_ADAPTER: TypeAdapter[list[ComboBoxItem]] = TypeAdapter(
+ list[ComboBoxItem]
+)
router = APIRouter(prefix="/api/scripts", tags=["脚本管理"])
+ScriptIdPath = Annotated[str, Path(description="脚本 ID")]
+UserIdPath = Annotated[str, Path(description="用户 ID")]
+WebhookIdPath = Annotated[str, Path(description="Webhook ID")]
-SCRIPT_BOOK = {
- "MaaConfig": MaaConfig,
- "SrcConfig": SrcConfig,
- "MaaEndConfig": MaaEndConfig,
- "GeneralConfig": GeneralConfig,
-}
-USER_BOOK = {
- "MaaConfig": MaaUserConfig,
- "SrcConfig": SrcUserConfig,
- "MaaEndConfig": MaaEndUserConfig,
- "GeneralConfig": GeneralUserConfig,
-}
+
+@router.get(
+ "",
+ tags=["Get"],
+ summary="查询全部脚本",
+ response_model=ScriptGetOut,
+)
+async def list_scripts() -> ScriptGetOut:
+ index, data = await Config.get_script(None)
+ script_index = project_model_list(ScriptIndexItem, index)
+ return ScriptGetOut(
+ index=script_index, data=project_script_model_map(script_index, data)
+ )
@router.post(
- "/add",
+ "",
tags=["Add"],
- summary="添加脚本",
+ summary="创建脚本",
response_model=ScriptCreateOut,
- status_code=200,
)
-async def add_script(script: ScriptCreateIn = Body(...)) -> ScriptCreateOut:
-
- try:
- uid, config = await Config.add_script(script.type, script.scriptId)
- data = SCRIPT_BOOK[type(config).__name__](**(await config.toDict()))
- except Exception as e:
- return ScriptCreateOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- scriptId="",
- data=GeneralConfig(**{}),
- )
- return ScriptCreateOut(scriptId=str(uid), data=data)
+async def create_script(script: ScriptCreateIn = Body(...)) -> ScriptCreateOut:
+ uid, config = await Config.add_script(script.type, script.copyFromId)
+ data = project_script_model(
+ script_contract_type_from_runtime(type(config).__name__),
+ await config.toDict(),
+ )
+ return ScriptCreateOut(id=str(uid), data=data)
-@router.post(
- "/get",
+@router.patch(
+ "/order",
+ tags=["Update"],
+ summary="重新排序脚本",
+ response_model=OutBase,
+)
+async def reorder_scripts(body: IndexOrderPatch = Body(...)) -> OutBase:
+ await Config.reorder_script(body.index_list)
+ return OutBase()
+
+
+@router.get(
+ "/{script_id}",
tags=["Get"],
- summary="查询脚本配置信息",
- response_model=ScriptGetOut,
- status_code=200,
+ summary="查询单个脚本",
+ response_model=ScriptDetailOut,
)
-async def get_script(script: ScriptGetIn = Body(...)) -> ScriptGetOut:
-
- try:
- index, data = await Config.get_script(script.scriptId)
- index = [ScriptIndexItem(**_) for _ in index]
- data = {
- uid: SCRIPT_BOOK[next((_.type for _ in index if _.uid == uid), "General")](
- **cfg
- )
- for uid, cfg in data.items()
- }
- except Exception as e:
- return ScriptGetOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- index=[],
- data={},
- )
- return ScriptGetOut(index=index, data=data)
+async def get_script(script_id: ScriptIdPath) -> ScriptDetailOut:
+ index, data = await Config.get_script(script_id)
+ script_index = project_model_list(ScriptIndexItem, index)
+ projected = project_script_model_map(script_index, data)
+ return ScriptDetailOut(data=projected[script_id])
-@router.post(
- "/update",
+@router.patch(
+ "/{script_id}",
tags=["Update"],
- summary="更新脚本配置信息",
+ summary="更新脚本配置",
response_model=OutBase,
- status_code=200,
)
-async def update_script(script: ScriptUpdateIn = Body(...)) -> OutBase:
-
- try:
- await Config.update_script(
- script.scriptId, script.data.model_dump(exclude_unset=True)
- )
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def update_script(
+ script_id: ScriptIdPath,
+ body: ScriptPatchBody = Body(...),
+) -> OutBase:
+ script_type = script_contract_type_from_runtime(
+ type(Config.ScriptConfig[uuid.UUID(script_id)]).__name__
+ )
+ await Config.update_script(
+ script_id, dump_script_patch_data(script_type, body.data)
+ )
return OutBase()
-@router.post(
- "/delete",
+@router.delete(
+ "/{script_id}",
tags=["Delete"],
summary="删除脚本",
response_model=OutBase,
- status_code=200,
)
-async def delete_script(script: ScriptDeleteIn = Body(...)) -> OutBase:
-
- try:
- await Config.del_script(script.scriptId)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def delete_script(script_id: ScriptIdPath) -> OutBase:
+ await Config.del_script(script_id)
return OutBase()
@router.post(
- "/order",
- tags=["Update"],
- summary="重新排序脚本",
- response_model=OutBase,
- status_code=200,
-)
-async def reorder_script(script: ScriptReorderIn = Body(...)) -> OutBase:
-
- try:
- await Config.reorder_script(script.indexList)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
- return OutBase()
-
-
-@router.post(
- "/import/file",
- tags=["Update"],
- summary="从文件加载脚本配置",
+ "/{script_id}/actions/import-file",
+ tags=["Action"],
+ summary="从文件导入脚本配置",
response_model=OutBase,
- status_code=200,
)
-async def import_script_from_file(script: ScriptFileIn = Body(...)) -> OutBase:
-
- try:
- await Config.import_script_from_file(script.scriptId, script.jsonFile)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def import_script_from_file(
+ script_id: ScriptIdPath, body: ScriptFileBody = Body(...)
+) -> OutBase:
+ await Config.import_script_from_file(script_id, body.path)
return OutBase()
@router.post(
- "/export/file",
+ "/{script_id}/actions/export-file",
tags=["Action"],
summary="导出脚本配置到文件",
response_model=OutBase,
- status_code=200,
)
-async def export_script_to_file(script: ScriptFileIn = Body(...)) -> OutBase:
-
- try:
- await Config.export_script_to_file(script.scriptId, script.jsonFile)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def export_script_to_file(
+ script_id: ScriptIdPath, body: ScriptFileBody = Body(...)
+) -> OutBase:
+ await Config.export_script_to_file(script_id, body.path)
return OutBase()
@router.post(
- "/import/web",
- tags=["Update"],
- summary="从网络加载脚本配置",
+ "/{script_id}/actions/import-web",
+ tags=["Action"],
+ summary="从网络导入脚本配置",
response_model=OutBase,
- status_code=200,
)
-async def import_script_from_web(script: ScriptUrlIn = Body(...)) -> OutBase:
-
- try:
- await Config.import_script_from_web(script.scriptId, script.url)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def import_script_from_web(
+ script_id: ScriptIdPath, body: ScriptUrlBody = Body(...)
+) -> OutBase:
+ await Config.import_script_from_web(script_id, body.url)
return OutBase()
@router.post(
- "/Upload/web",
+ "/{script_id}/actions/upload-web",
tags=["Action"],
summary="上传脚本配置到网络",
response_model=OutBase,
- status_code=200,
)
-async def upload_script_to_web(script: ScriptUploadIn = Body(...)) -> OutBase:
-
- try:
- await Config.upload_script_to_web(
- script.scriptId, script.config_name, script.author, script.description
- )
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def upload_script_to_web(
+ script_id: ScriptIdPath, body: ScriptUploadBody = Body(...)
+) -> OutBase:
+ await Config.upload_script_to_web(
+ script_id, body.config_name, body.author, body.description
+ )
return OutBase()
-@router.post(
- "/user/get",
+@router.get(
+ "/{script_id}/users",
tags=["Get"],
- summary="查询用户",
+ summary="查询脚本下的全部用户",
response_model=UserGetOut,
- status_code=200,
)
-async def get_user(user: UserGetIn = Body(...)) -> UserGetOut:
-
- try:
- index, data = await Config.get_user(user.scriptId, user.userId)
- index = [UserIndexItem(**_) for _ in index]
- data = {
- uid: USER_BOOK[
- type(Config.ScriptConfig[uuid.UUID(user.scriptId)]).__name__
- ](**cfg)
- for uid, cfg in data.items()
- }
- except Exception as e:
- return UserGetOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- index=[],
- data={},
- )
- return UserGetOut(index=index, data=data)
+async def list_users(script_id: ScriptIdPath) -> UserGetOut:
+ index, data = await Config.get_user(script_id, None)
+ user_index = project_model_list(UserIndexItem, index)
+ return UserGetOut(index=user_index, data=project_user_model_map(user_index, data))
@router.post(
- "/user/add",
+ "/{script_id}/users",
tags=["Add"],
- summary="添加用户",
+ summary="创建用户",
response_model=UserCreateOut,
- status_code=200,
)
-async def add_user(user: UserInBase = Body(...)) -> UserCreateOut:
-
- try:
- uid, config = await Config.add_user(user.scriptId)
- data = USER_BOOK[type(Config.ScriptConfig[uuid.UUID(user.scriptId)]).__name__](
- **(await config.toDict())
- )
- except Exception as e:
- return UserCreateOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- userId="",
- data=GeneralUserConfig(**{}),
- )
- return UserCreateOut(userId=str(uid), data=data)
-
-
-@router.post(
- "/user/update",
+async def create_user(script_id: ScriptIdPath) -> UserCreateOut:
+ uid, config = await Config.add_user(script_id)
+ script_type = script_contract_type_from_runtime(
+ type(Config.ScriptConfig[uuid.UUID(script_id)]).__name__
+ )
+ user_type = user_contract_type_from_script(script_type)
+ data = project_user_model(user_type, await config.toDict())
+ return UserCreateOut(id=str(uid), data=data)
+
+
+@router.patch(
+ "/{script_id}/users/order",
tags=["Update"],
- summary="更新用户配置信息",
+ summary="重新排序用户",
response_model=OutBase,
- status_code=200,
)
-async def update_user(user: UserUpdateIn = Body(...)) -> OutBase:
-
- try:
- await Config.update_user(
- user.scriptId, user.userId, user.data.model_dump(exclude_unset=True)
- )
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def reorder_users(
+ script_id: ScriptIdPath, body: IndexOrderPatch = Body(...)
+) -> OutBase:
+ await Config.reorder_user(script_id, body.index_list)
return OutBase()
-@router.post(
- "/user/delete",
- tags=["Delete"],
- summary="删除用户",
+@router.get(
+ "/{script_id}/users/{user_id}",
+ tags=["Get"],
+ summary="查询单个用户",
+ response_model=UserDetailOut,
+)
+async def get_user(script_id: ScriptIdPath, user_id: UserIdPath) -> UserDetailOut:
+ index, data = await Config.get_user(script_id, user_id)
+ user_index = project_model_list(UserIndexItem, index)
+ projected = project_user_model_map(user_index, data)
+ return UserDetailOut(data=projected[user_id])
+
+
+@router.patch(
+ "/{script_id}/users/{user_id}",
+ tags=["Update"],
+ summary="更新用户配置",
response_model=OutBase,
- status_code=200,
)
-async def delete_user(user: UserDeleteIn = Body(...)) -> OutBase:
-
- try:
- await Config.del_user(user.scriptId, user.userId)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def update_user(
+ script_id: ScriptIdPath,
+ user_id: UserIdPath,
+ body: UserPatchBody = Body(...),
+) -> OutBase:
+ script_type = script_contract_type_from_runtime(
+ type(Config.ScriptConfig[uuid.UUID(script_id)]).__name__
+ )
+ user_type = user_contract_type_from_script(script_type)
+ await Config.update_user(
+ script_id, user_id, dump_user_patch_data(user_type, body.data)
+ )
return OutBase()
-@router.post(
- "/user/order",
- tags=["Update"],
- summary="重新排序用户",
+@router.delete(
+ "/{script_id}/users/{user_id}",
+ tags=["Delete"],
+ summary="删除用户",
response_model=OutBase,
- status_code=200,
)
-async def reorder_user(user: UserReorderIn = Body(...)) -> OutBase:
-
- try:
- await Config.reorder_user(user.scriptId, user.indexList)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def delete_user(script_id: ScriptIdPath, user_id: UserIdPath) -> OutBase:
+ await Config.del_user(script_id, user_id)
return OutBase()
@router.post(
- "/user/infrastructure",
- tags=["Update"],
+ "/{script_id}/users/{user_id}/actions/import-infrastructure",
+ tags=["Action"],
summary="导入基建配置文件",
response_model=OutBase,
- status_code=200,
)
-async def import_infrastructure(user: UserSetIn = Body(...)) -> OutBase:
-
- try:
- await Config.set_infrastructure(user.scriptId, user.userId, user.jsonFile)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def import_infrastructure(
+ script_id: ScriptIdPath,
+ user_id: UserIdPath,
+ body: InfrastructureImportBody = Body(...),
+) -> OutBase:
+ await Config.set_infrastructure(script_id, user_id, body.path)
return OutBase()
-@router.post(
- "/user/combox/infrastructure",
+@router.get(
+ "/{script_id}/users/{user_id}/infrastructure-options",
tags=["Get"],
summary="用户自定义基建排班可选项",
response_model=ComboBoxOut,
- status_code=200,
)
-async def get_user_combox_infrastructure(user: UserDeleteIn = Body(...)) -> ComboBoxOut:
-
- try:
- raw_data = await Config.get_user_combox_infrastructure(
- user.scriptId, user.userId
- )
- data = [ComboBoxItem(**item) for item in raw_data] if raw_data else []
- except Exception as e:
- return ComboBoxOut(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}", data=[]
- )
+async def get_user_infrastructure_options(
+ script_id: ScriptIdPath, user_id: UserIdPath
+) -> ComboBoxOut:
+ raw_data = await Config.get_user_combox_infrastructure(script_id, user_id)
+ data = COMBOBOX_ITEMS_ADAPTER.validate_python(raw_data or [])
return ComboBoxOut(data=data)
-@router.post(
- "/webhook/get",
+@router.get(
+ "/{script_id}/users/{user_id}/webhooks",
tags=["Get"],
- summary="查询 webhook 配置",
+ summary="查询用户下的全部 Webhook",
response_model=WebhookGetOut,
- status_code=200,
)
-async def get_webhook(webhook: WebhookGetIn = Body(...)) -> WebhookGetOut:
-
- try:
- index, data = await Config.get_webhook(
- webhook.scriptId, webhook.userId, webhook.webhookId
- )
- index = [WebhookIndexItem(**_) for _ in index]
- data = {uid: Webhook(**cfg) for uid, cfg in data.items()}
- except Exception as e:
- return WebhookGetOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- index=[],
- data={},
- )
- return WebhookGetOut(index=index, data=data)
+async def list_user_webhooks(
+ script_id: ScriptIdPath, user_id: UserIdPath
+) -> WebhookGetOut:
+ index, data = await Config.get_webhook(script_id, user_id, None)
+ return WebhookGetOut(
+ index=project_model_list(WebhookIndexItem, index),
+ data=project_model_map(WebhookRead, data),
+ )
@router.post(
- "/webhook/add",
+ "/{script_id}/users/{user_id}/webhooks",
tags=["Add"],
- summary="添加webhook项",
+ summary="创建用户 Webhook",
response_model=WebhookCreateOut,
- status_code=200,
)
-async def add_webhook(webhook: WebhookInBase = Body(...)) -> WebhookCreateOut:
-
- try:
- uid, config = await Config.add_webhook(webhook.scriptId, webhook.userId)
- data = Webhook(**(await config.toDict()))
- except Exception as e:
- return WebhookCreateOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- webhookId="",
- data=Webhook(**{}),
- )
- return WebhookCreateOut(webhookId=str(uid), data=data)
-
-
-@router.post(
- "/webhook/update",
+async def create_user_webhook(
+ script_id: ScriptIdPath, user_id: UserIdPath
+) -> WebhookCreateOut:
+ uid, config = await Config.add_webhook(script_id, user_id)
+ return WebhookCreateOut(
+ id=str(uid),
+ data=project_model(WebhookRead, await config.toDict()),
+ )
+
+
+@router.patch(
+ "/{script_id}/users/{user_id}/webhooks/order",
tags=["Update"],
- summary="更新webhook项",
+ summary="重新排序用户 Webhook",
response_model=OutBase,
- status_code=200,
)
-async def update_webhook(webhook: WebhookUpdateIn = Body(...)) -> OutBase:
-
- try:
- await Config.update_webhook(
- webhook.scriptId,
- webhook.userId,
- webhook.webhookId,
- webhook.data.model_dump(exclude_unset=True),
- )
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def reorder_user_webhooks(
+ script_id: ScriptIdPath,
+ user_id: UserIdPath,
+ body: IndexOrderPatch = Body(...),
+) -> OutBase:
+ await Config.reorder_webhook(script_id, user_id, body.index_list)
return OutBase()
-@router.post(
- "/webhook/delete",
- tags=["Delete"],
- summary="删除webhook项",
+@router.get(
+ "/{script_id}/users/{user_id}/webhooks/{webhook_id}",
+ tags=["Get"],
+ summary="查询单个用户 Webhook",
+ response_model=WebhookDetailOut,
+)
+async def get_user_webhook(
+ script_id: ScriptIdPath,
+ user_id: UserIdPath,
+ webhook_id: WebhookIdPath,
+) -> WebhookDetailOut:
+ _, data = await Config.get_webhook(script_id, user_id, webhook_id)
+ projected = project_model_map(WebhookRead, data)
+ return WebhookDetailOut(data=projected[webhook_id])
+
+
+@router.patch(
+ "/{script_id}/users/{user_id}/webhooks/{webhook_id}",
+ tags=["Update"],
+ summary="更新用户 Webhook",
response_model=OutBase,
- status_code=200,
)
-async def delete_webhook(webhook: WebhookDeleteIn = Body(...)) -> OutBase:
-
- try:
- await Config.del_webhook(webhook.scriptId, webhook.userId, webhook.webhookId)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def update_user_webhook(
+ script_id: ScriptIdPath,
+ user_id: UserIdPath,
+ webhook_id: WebhookIdPath,
+ data: WebhookRead = Body(...),
+) -> OutBase:
+ await Config.update_webhook(
+ script_id,
+ user_id,
+ webhook_id,
+ dump_writable_data(data),
+ )
return OutBase()
-@router.post(
- "/webhook/order",
- tags=["Update"],
- summary="重新排序webhook项",
+@router.delete(
+ "/{script_id}/users/{user_id}/webhooks/{webhook_id}",
+ tags=["Delete"],
+ summary="删除用户 Webhook",
response_model=OutBase,
- status_code=200,
)
-async def reorder_webhook(webhook: WebhookReorderIn = Body(...)) -> OutBase:
-
- try:
- await Config.reorder_webhook(
- webhook.scriptId, webhook.userId, webhook.indexList
- )
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def delete_user_webhook(
+ script_id: ScriptIdPath,
+ user_id: UserIdPath,
+ webhook_id: WebhookIdPath,
+) -> OutBase:
+ await Config.del_webhook(script_id, user_id, webhook_id)
return OutBase()
diff --git a/app/api/setting.py b/app/api/setting.py
index 36a864b7..9b75bb09 100644
--- a/app/api/setting.py
+++ b/app/api/setting.py
@@ -21,207 +21,158 @@
# Contact: DLmaster_361@163.com
-from fastapi import APIRouter, Body
+from typing import Annotated
+
+from fastapi import APIRouter, Body, Path
+
+
from app.core import Config
-from app.services import Notify
-from app.models.schema import (
- SettingGetOut,
- GlobalConfig,
+from app.models import Webhook as WebhookConfig
+from app.contracts.common_contract import (
+ IndexOrderPatch,
OutBase,
- SettingUpdateIn,
+ dump_writable_data,
+ project_model,
+ project_model_list,
+ project_model_map,
+)
+from app.contracts.setting_contract import (
+ GlobalConfigRead,
+ SettingGetOut,
+ WebhookCreateOut,
+ WebhookDetailOut,
WebhookGetOut,
WebhookIndexItem,
- Webhook,
- WebhookGetIn,
- WebhookCreateOut,
- WebhookUpdateIn,
- WebhookDeleteIn,
- WebhookReorderIn,
- WebhookTestIn,
+ WebhookRead,
)
-from app.models.config import Webhook as WebhookConfig
+from app.services import Notify
router = APIRouter(prefix="/api/setting", tags=["全局设置"])
+WebhookIdPath = Annotated[str, Path(description="Webhook ID")]
-@router.post(
- "/get",
+
+@router.get(
+ "",
tags=["Get"],
- summary="查询配置",
+ summary="查询全局配置",
response_model=SettingGetOut,
- status_code=200,
)
-async def get_scripts() -> SettingGetOut:
- """查询配置"""
-
- try:
- data = await Config.get_setting()
- except Exception as e:
- return SettingGetOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- data=GlobalConfig(**{}),
- )
- return SettingGetOut(data=GlobalConfig(**data))
+async def get_setting() -> SettingGetOut:
+ return SettingGetOut(
+ data=project_model(GlobalConfigRead, await Config.get_setting())
+ )
-@router.post(
- "/update",
+@router.patch(
+ "",
tags=["Update"],
- summary="更新配置",
+ summary="更新全局配置",
response_model=OutBase,
- status_code=200,
)
-async def update_script(script: SettingUpdateIn = Body(...)) -> OutBase:
- """更新配置"""
-
- try:
- data = script.data.model_dump(exclude_unset=True)
- await Config.update_setting(data)
-
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def update_setting(data: GlobalConfigRead = Body(...)) -> OutBase:
+ await Config.update_setting(dump_writable_data(data))
return OutBase()
@router.post(
- "/test_notify",
+ "/actions/test-notify",
tags=["Action"],
summary="测试通知",
response_model=OutBase,
- status_code=200,
)
async def test_notify() -> OutBase:
- """测试通知"""
-
- try:
- await Notify.send_test_notification()
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+ await Notify.send_test_notification()
return OutBase()
-@router.post(
- "/webhook/get",
+@router.get(
+ "/webhooks",
tags=["Get"],
- summary="查询 webhook 配置",
+ summary="查询全部全局 Webhook 配置",
response_model=WebhookGetOut,
- status_code=200,
)
-async def get_webhook(webhook: WebhookGetIn = Body(...)) -> WebhookGetOut:
- try:
- index, data = await Config.get_webhook(None, None, webhook.webhookId)
- index = [WebhookIndexItem(**_) for _ in index]
- data = {uid: Webhook(**cfg) for uid, cfg in data.items()}
- except Exception as e:
- return WebhookGetOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- index=[],
- data={},
- )
- return WebhookGetOut(index=index, data=data)
+async def list_webhooks() -> WebhookGetOut:
+ index, data = await Config.get_webhook(None, None, None)
+ return WebhookGetOut(
+ index=project_model_list(WebhookIndexItem, index),
+ data=project_model_map(WebhookRead, data),
+ )
@router.post(
- "/webhook/add",
+ "/webhooks",
tags=["Add"],
- summary="添加webhook项",
+ summary="创建全局 Webhook 配置",
response_model=WebhookCreateOut,
- status_code=200,
)
-async def add_webhook() -> WebhookCreateOut:
- try:
- uid, config = await Config.add_webhook(None, None)
- data = Webhook(**(await config.toDict()))
- except Exception as e:
- return WebhookCreateOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- webhookId="",
- data=Webhook(**{}),
- )
- return WebhookCreateOut(webhookId=str(uid), data=data)
+async def create_webhook() -> WebhookCreateOut:
+ uid, config = await Config.add_webhook(None, None)
+ return WebhookCreateOut(
+ id=str(uid),
+ data=project_model(WebhookRead, await config.toDict()),
+ )
-@router.post(
- "/webhook/update",
+@router.patch(
+ "/webhooks/order",
tags=["Update"],
- summary="更新webhook项",
+ summary="重新排序全局 Webhook",
response_model=OutBase,
- status_code=200,
)
-async def update_webhook(webhook: WebhookUpdateIn = Body(...)) -> OutBase:
- try:
- await Config.update_webhook(
- None, None, webhook.webhookId, webhook.data.model_dump(exclude_unset=True)
- )
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def reorder_webhooks(body: IndexOrderPatch = Body(...)) -> OutBase:
+ await Config.reorder_webhook(None, None, body.index_list)
return OutBase()
@router.post(
- "/webhook/delete",
- tags=["Delete"],
- summary="删除webhook项",
+ "/webhooks/test",
+ tags=["Action"],
+ summary="测试指定 Webhook 配置",
response_model=OutBase,
- status_code=200,
)
-async def delete_webhook(webhook: WebhookDeleteIn = Body(...)) -> OutBase:
- try:
- await Config.del_webhook(None, None, webhook.webhookId)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def test_webhook(data: WebhookRead = Body(...)) -> OutBase:
+ webhook_config = WebhookConfig()
+ await webhook_config.load(dump_writable_data(data))
+ await Notify.WebhookPush(
+ "AUTO-MAS Webhook测试",
+ "这是一条测试消息,如果您收到此消息,说明Webhook配置正确!",
+ webhook_config,
+ )
return OutBase()
-@router.post(
- "/webhook/order",
+@router.get(
+ "/webhooks/{webhook_id}",
+ tags=["Get"],
+ summary="查询单个全局 Webhook 配置",
+ response_model=WebhookDetailOut,
+)
+async def get_webhook(webhook_id: WebhookIdPath) -> WebhookDetailOut:
+ _, data = await Config.get_webhook(None, None, webhook_id)
+ projected = project_model_map(WebhookRead, data)
+ return WebhookDetailOut(data=projected[webhook_id])
+
+
+@router.patch(
+ "/webhooks/{webhook_id}",
tags=["Update"],
- summary="重新排序webhook项",
+ summary="更新全局 Webhook 配置",
response_model=OutBase,
- status_code=200,
)
-async def reorder_webhook(webhook: WebhookReorderIn = Body(...)) -> OutBase:
- try:
- await Config.reorder_webhook(None, None, webhook.indexList)
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def update_webhook(
+ webhook_id: WebhookIdPath, data: WebhookRead = Body(...)
+) -> OutBase:
+ await Config.update_webhook(None, None, webhook_id, dump_writable_data(data))
return OutBase()
-@router.post(
- "/webhook/test",
- tags=["Action"],
- summary="测试Webhook配置",
+@router.delete(
+ "/webhooks/{webhook_id}",
+ tags=["Delete"],
+ summary="删除全局 Webhook 配置",
response_model=OutBase,
- status_code=200,
)
-async def test_webhook(webhook: WebhookTestIn = Body(...)) -> OutBase:
- """测试自定义Webhook"""
-
- try:
- webhook_config = WebhookConfig()
- await webhook_config.load(webhook.data.model_dump())
- await Notify.WebhookPush(
- "AUTO-MAS Webhook测试",
- "这是一条测试消息,如果您收到此消息,说明Webhook配置正确!",
- webhook_config,
- )
- except Exception as e:
- return OutBase(code=500, status="error", message=f"Webhook测试失败: {str(e)}")
+async def delete_webhook(webhook_id: WebhookIdPath) -> OutBase:
+ await Config.del_webhook(None, None, webhook_id)
return OutBase()
diff --git a/app/api/tools.py b/app/api/tools.py
index 6cc1f178..40c59af4 100644
--- a/app/api/tools.py
+++ b/app/api/tools.py
@@ -22,50 +22,31 @@
from fastapi import APIRouter, Body
+
+
from app.core import Config
-from app.models.schema import ToolsGetOut, ToolsConfig, OutBase, ToolsUpdateIn
+from app.contracts.common_contract import OutBase, dump_writable_data, project_model
+from app.contracts.tools_contract import ToolsConfigRead, ToolsGetOut
router = APIRouter(prefix="/api/tools", tags=["工具设置"])
-@router.post(
- "/get",
+@router.get(
+ "",
tags=["Get"],
summary="查询工具配置",
response_model=ToolsGetOut,
- status_code=200,
)
async def get_tools() -> ToolsGetOut:
- """查询工具配置"""
-
- try:
- data = await Config.get_tools()
- except Exception as e:
- return ToolsGetOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- data=ToolsConfig(**{}),
- )
- return ToolsGetOut(data=ToolsConfig(**data))
+ return ToolsGetOut(data=project_model(ToolsConfigRead, await Config.get_tools()))
-@router.post(
- "/update",
+@router.patch(
+ "",
tags=["Update"],
summary="更新工具配置",
response_model=OutBase,
- status_code=200,
)
-async def update_tools(script: ToolsUpdateIn = Body(...)) -> OutBase:
- """更新工具配置"""
-
- try:
- data = script.data.model_dump(exclude_unset=True)
- await Config.update_tools(data)
-
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+async def update_tools(data: ToolsConfigRead = Body(...)) -> OutBase:
+ await Config.update_tools(dump_writable_data(data))
return OutBase()
diff --git a/app/api/update.py b/app/api/update.py
index 9d007705..157ad6c0 100644
--- a/app/api/update.py
+++ b/app/api/update.py
@@ -22,40 +22,62 @@
import asyncio
-from fastapi import APIRouter, Body
+from contextlib import suppress
+from typing import Annotated, Any
+
+from fastapi import APIRouter, Body, Depends
from app.core import Config
from app.services import Updater
-from app.models.schema import *
+from app.contracts.common_contract import OutBase
+from app.contracts.update_contract import UpdateCheckIn, UpdateCheckOut
+
router = APIRouter(prefix="/api/update", tags=["软件更新"])
+QueryUpdateCheckIn = Annotated[UpdateCheckIn, Depends()]
+
+
+def _track_temp_task(task: asyncio.Task[Any]) -> None:
+ """统一跟踪临时后台任务,避免重复的列表维护代码。"""
+
+ Config.temp_task.append(task)
+
+ def _cleanup(done_task: asyncio.Task[Any]) -> None:
+ with suppress(ValueError):
+ Config.temp_task.remove(done_task)
+
+ task.add_done_callback(_cleanup)
+
+
+async def _build_update_check_out(version: UpdateCheckIn) -> UpdateCheckOut:
+ if_need, latest_version, update_info = await Updater.check_update(
+ current_version=version.current_version, if_force=version.if_force
+ )
+ return UpdateCheckOut(
+ if_need_update=if_need, latest_version=latest_version, update_info=update_info
+ )
+
+
@router.post(
"/check",
tags=["Get"],
summary="检查更新",
response_model=UpdateCheckOut,
- status_code=200,
)
async def check_update(version: UpdateCheckIn = Body(...)) -> UpdateCheckOut:
+ return await _build_update_check_out(version)
- try:
- if_need, latest_version, update_info = await Updater.check_update(
- current_version=version.current_version, if_force=version.if_force
- )
- except Exception as e:
- return UpdateCheckOut(
- code=500,
- status="error",
- message=f"{type(e).__name__}: {str(e)}",
- if_need_update=False,
- latest_version="",
- update_info={},
- )
- return UpdateCheckOut(
- if_need_update=if_need, latest_version=latest_version, update_info=update_info
- )
+
+@router.get(
+ "/check",
+ tags=["Get"],
+ summary="按 REST 风格检查更新",
+ response_model=UpdateCheckOut,
+)
+async def check_update_rest(version: QueryUpdateCheckIn) -> UpdateCheckOut:
+ return await _build_update_check_out(version)
@router.post(
@@ -63,18 +85,10 @@ async def check_update(version: UpdateCheckIn = Body(...)) -> UpdateCheckOut:
tags=["Action"],
summary="下载更新",
response_model=OutBase,
- status_code=200,
)
async def download_update() -> OutBase:
-
- try:
- task = asyncio.create_task(Updater.download_update())
- Config.temp_task.append(task)
- task.add_done_callback(lambda t: Config.temp_task.remove(t))
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+ task = asyncio.create_task(Updater.download_update())
+ _track_temp_task(task)
return OutBase()
@@ -83,16 +97,8 @@ async def download_update() -> OutBase:
tags=["Action"],
summary="安装更新",
response_model=OutBase,
- status_code=200,
)
async def install_update() -> OutBase:
-
- try:
- task = asyncio.create_task(Updater.install_update())
- Config.temp_task.append(task)
- task.add_done_callback(lambda t: Config.temp_task.remove(t))
- except Exception as e:
- return OutBase(
- code=500, status="error", message=f"{type(e).__name__}: {str(e)}"
- )
+ task = asyncio.create_task(Updater.install_update())
+ _track_temp_task(task)
return OutBase()
diff --git a/app/api/ws_command.py b/app/api/ws_command.py
index b93828bd..abb74178 100644
--- a/app/api/ws_command.py
+++ b/app/api/ws_command.py
@@ -28,20 +28,40 @@
"""
import inspect
-from typing import Callable, Dict, Any, Optional
from functools import wraps
+from typing import Any, Callable, ParamSpec, TypeAlias, TypeVar, cast
from pydantic import BaseModel
from app.utils.logger import get_logger
logger = get_logger("WS命令")
+P = ParamSpec("P")
+R = TypeVar("R")
+RegisteredWsCommand: TypeAlias = Callable[..., Any]
+
# 全局命令注册表
-_ws_command_registry: Dict[str, Callable] = {}
+_ws_command_registry: dict[str, RegisteredWsCommand] = {}
+
+
+def _failed_result(message: str, code: int) -> dict[str, Any]:
+ return {"success": False, "message": message, "code": code}
+
+def _pack_result(data: dict[str, Any]) -> dict[str, Any]:
+ code = cast(int, data.get("code", 200))
+ return {
+ "success": code == 200,
+ "data": data,
+ "code": code,
+ "message": data.get("message"),
+ }
-def ws_command(endpoint: str):
+
+def ws_command(
+ endpoint: str,
+) -> Callable[[Callable[P, Any]], Callable[P, Any]]:
"""
WebSocket 命令装饰器
@@ -68,24 +88,24 @@ async def clone_task(params: TaskCloneIn):
endpoint: 命令的唯一标识符,如 "ws.clone", "core.shutdown"
"""
- def decorator(func: Callable):
+ def decorator(func: Callable[P, Any]) -> Callable[P, Any]:
# 注册到全局命令表
_ws_command_registry[endpoint] = func
logger.debug(f"已注册 WebSocket 命令: {endpoint}")
@wraps(func)
- async def wrapper(*args, **kwargs):
+ async def wrapper(*args: P.args, **kwargs: P.kwargs):
# 保持原函数功能不变
return await func(*args, **kwargs)
- return wrapper
+ return cast(Callable[P, Any], wrapper)
return decorator
async def execute_ws_command(
- endpoint: str, params: Optional[Dict[str, Any]] = None
-) -> Dict[str, Any]:
+ endpoint: str, params: dict[str, Any] | None = None
+) -> dict[str, Any]:
"""
执行 WebSocket 命令
@@ -105,7 +125,7 @@ async def execute_ws_command(
# 检查命令是否存在
if endpoint not in _ws_command_registry:
logger.warning(f"未找到命令: {endpoint}")
- return {"success": False, "message": f"未找到命令: {endpoint}", "code": 404}
+ return _failed_result(f"未找到命令: {endpoint}", 404)
func = _ws_command_registry[endpoint]
@@ -120,7 +140,7 @@ async def execute_ws_command(
result = await func()
else:
# 检查第一个参数是否是 Pydantic Model
- first_param = list(parameters.values())[0]
+ first_param = next(iter(parameters.values()))
param_type = first_param.annotation
if (
@@ -134,11 +154,7 @@ async def execute_ws_command(
result = await func(param_instance)
except Exception as e:
logger.error(f"构建参数模型失败: {type(e).__name__}: {e}")
- return {
- "success": False,
- "message": f"参数错误: {str(e)}",
- "code": 400,
- }
+ return _failed_result(f"参数错误: {str(e)}", 400)
elif params:
# 普通参数,直接传递
result = await func(**params)
@@ -149,19 +165,10 @@ async def execute_ws_command(
# 处理返回结果
if isinstance(result, BaseModel):
result_dict = result.model_dump()
- return {
- "success": result_dict.get("code", 200) == 200,
- "data": result_dict,
- "code": result_dict.get("code", 200),
- "message": result_dict.get("message"),
- }
+ return _pack_result(result_dict)
elif isinstance(result, dict):
- return {
- "success": result.get("code", 200) == 200,
- "data": result,
- "code": result.get("code", 200),
- "message": result.get("message"),
- }
+ result_dict = cast(dict[str, Any], result)
+ return _pack_result(result_dict)
else:
return {"success": True, "data": result, "code": 200}
@@ -169,14 +176,10 @@ async def execute_ws_command(
logger.error(
f"执行命令 {endpoint} 失败: {type(e).__name__}: {str(e)}", exc_info=True
)
- return {
- "success": False,
- "message": f"执行失败: {type(e).__name__}: {str(e)}",
- "code": 500,
- }
+ return _failed_result(f"执行失败: {type(e).__name__}: {str(e)}", 500)
-def get_ws_command_registry() -> Dict[str, Callable]:
+def get_ws_command_registry() -> dict[str, RegisteredWsCommand]:
"""获取所有已注册的 WebSocket 命令"""
return _ws_command_registry.copy()
diff --git a/app/api/websocket.py b/app/api/ws_debug.py
similarity index 66%
rename from app/api/websocket.py
rename to app/api/ws_debug.py
index 13a238f9..801cc7f7 100644
--- a/app/api/websocket.py
+++ b/app/api/ws_debug.py
@@ -28,14 +28,13 @@
支持:创建客户端、连接、断开、发送消息、鉴权等
"""
-import json
-from typing import Optional, Dict, Any, Callable
-from fastapi import APIRouter, WebSocket, WebSocketDisconnect
+from typing import NoReturn, Optional
+from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
from app.utils.websocket import ws_client_manager
from app.api.ws_command import list_ws_commands
from app.utils.logger import get_logger
-from app.models.schema import (
+from app.contracts.ws_contract import (
WSClientCreateIn,
WSClientCreateOut,
WSClientConnectIn,
@@ -52,125 +51,15 @@
WSCommandsOut,
)
-logger = get_logger("WS端点")
+logger = get_logger("WS调试")
-router = APIRouter(prefix="/api/ws", tags=["Websocket端点"])
-WSDEV_CHANNEL_NAME = "wsdev"
+router = APIRouter(prefix="/api/ws_debug", tags=["WebSocket调试"])
-async def _send_wsdev_snapshot(websocket: WebSocket):
- """
- 发送 wsdev 初始化快照。
-
- Args:
- websocket: 当前调试前端的 WebSocket 连接。
-
- Raises:
- Exception: 发送初始化数据失败时抛出底层异常。
- """
- # 发送当前所有客户端状态
- clients = ws_client_manager.list_clients()
- await websocket.send_json({"type": "init", "clients": list(clients.values())})
-
- # 发送历史消息
- history = ws_client_manager.get_message_history()
- for client_name, messages in history.items():
- for msg in messages:
- await websocket.send_json({"type": "message", "client": client_name, **msg})
-
-
-async def _invoke_declared_callback(callback: Optional[Callable], *args):
- """
- 调用声明式回调并自动兼容同步/异步函数。
-
- Args:
- callback: 声明式回调函数。
- *args: 传递给回调的参数。
-
- Raises:
- Exception: 回调执行过程中抛出的原始异常会向上抛出。
- """
- if callback is None:
- return
-
- result = callback(*args)
- if hasattr(result, "__await__"):
- await result
-
-
-def _build_channel_handlers(
- channel_name: str,
- websocket: WebSocket,
- channel_config: Dict[str, Any],
-):
- """
- 构建声明式通道的消息与生命周期回调。
-
- Args:
- channel_name: 通道名称。
- websocket: 当前 WebSocket 连接。
- channel_config: 通道声明配置。
-
- Returns:
- tuple: `(on_message, on_connect, on_disconnect)` 三个回调。
- """
- declared_on_message = channel_config.get("on_message")
- declared_on_connect = channel_config.get("on_connect")
- declared_on_disconnect = channel_config.get("on_disconnect")
+def _raise_ws_http_error(prefix: str, exc: Exception) -> NoReturn:
+ logger.error(f"{prefix}: {type(exc).__name__}: {exc}")
+ raise HTTPException(status_code=500, detail=f"{prefix}: {exc}") from exc
- if channel_name == WSDEV_CHANNEL_NAME:
-
- async def on_message(data: Dict[str, Any]):
- action = data.get("action") if isinstance(data, dict) else None
- if action == "ping":
- await websocket.send_text("pong")
- elif action == "request_snapshot":
- await _send_wsdev_snapshot(websocket)
- await _invoke_declared_callback(declared_on_message, data)
-
- async def on_connect():
- ws_client_manager.add_debug_connection(websocket)
- logger.info(f"调试前端已连接 (/api/ws/{channel_name}): {websocket.client}")
- await _send_wsdev_snapshot(websocket)
- await _invoke_declared_callback(declared_on_connect)
-
- async def on_disconnect():
- ws_client_manager.remove_debug_connection(websocket)
- logger.info(f"调试前端已断开 (/api/ws/{channel_name}): {websocket.client}")
- await _invoke_declared_callback(declared_on_disconnect)
-
- return on_message, on_connect, on_disconnect
-
- async def on_message(data: Dict[str, Any]):
- await _invoke_declared_callback(declared_on_message, data)
-
- async def on_connect():
- logger.info(f"声明通道已连接 (/api/ws/{channel_name}): {websocket.client}")
- await _invoke_declared_callback(declared_on_connect)
-
- async def on_disconnect():
- logger.info(f"声明通道已断开 (/api/ws/{channel_name}): {websocket.client}")
- await _invoke_declared_callback(declared_on_disconnect)
-
- return on_message, on_connect, on_disconnect
-
-
-def _register_builtin_reverse_channels():
- """
- 注册内置声明式反向通道。
-
- Raises:
- ValueError: 当内置通道名称非法或与保留名称冲突时抛出。
- """
- ws_client_manager.register_reverse_channel(
- name=WSDEV_CHANNEL_NAME,
- ping_interval=15.0,
- ping_timeout=30.0,
- overwrite=True,
- )
-
-
-_register_builtin_reverse_channels()
# ============== API 路由 ==============
@@ -191,7 +80,8 @@ async def create_client(request: WSClientCreateIn) -> WSClientCreateOut:
- **reconnect_interval**: 重连间隔
- **max_reconnect_attempts**: 最大重连次数
"""
- try:
+
+ async def _success() -> WSClientCreateOut:
client = await ws_client_manager.create_client(
name=request.name,
url=request.url,
@@ -211,11 +101,11 @@ async def create_client(request: WSClientCreateIn) -> WSClientCreateOut:
"is_connected": client.is_connected,
},
)
+
+ try:
+ return await _success()
except Exception as e:
- logger.error(f"创建客户端失败: {type(e).__name__}: {e}")
- return WSClientCreateOut(
- code=500, status="error", message=f"创建客户端失败: {str(e)}"
- )
+ _raise_ws_http_error("创建客户端失败", e)
@router.post(
@@ -232,7 +122,7 @@ async def connect_client(request: WSClientConnectIn) -> WSClientStatusOut:
code=404, status="error", message=f"客户端 [{request.name}] 不存在"
)
- try:
+ async def _success() -> WSClientStatusOut:
success = await ws_client_manager.connect_client(request.name)
client = ws_client_manager.get_client(request.name)
@@ -257,11 +147,11 @@ async def connect_client(request: WSClientConnectIn) -> WSClientStatusOut:
"is_connected": client.is_connected if client else False,
},
)
+
+ try:
+ return await _success()
except Exception as e:
- logger.error(f"连接客户端失败: {type(e).__name__}: {e}")
- return WSClientStatusOut(
- code=500, status="error", message=f"连接失败: {str(e)}"
- )
+ _raise_ws_http_error("连接客户端失败", e)
@router.post(
@@ -278,7 +168,7 @@ async def disconnect_client(request: WSClientDisconnectIn) -> WSClientStatusOut:
code=404, status="error", message=f"客户端 [{request.name}] 不存在"
)
- try:
+ async def _success() -> WSClientStatusOut:
await ws_client_manager.disconnect_client(request.name)
return WSClientStatusOut(
code=200,
@@ -286,11 +176,11 @@ async def disconnect_client(request: WSClientDisconnectIn) -> WSClientStatusOut:
message=f"客户端 [{request.name}] 已断开",
data={"name": request.name, "is_connected": False},
)
+
+ try:
+ return await _success()
except Exception as e:
- logger.error(f"断开客户端失败: {type(e).__name__}: {e}")
- return WSClientStatusOut(
- code=500, status="error", message=f"断开失败: {str(e)}"
- )
+ _raise_ws_http_error("断开客户端失败", e)
@router.post(
@@ -317,16 +207,16 @@ async def remove_client(request: WSClientRemoveIn) -> WSClientStatusOut:
message=f"客户端 [{request.name}] 是系统客户端,不可删除",
)
- try:
+ async def _success() -> WSClientStatusOut:
await ws_client_manager.remove_client(request.name)
return WSClientStatusOut(
code=200, status="success", message=f"客户端 [{request.name}] 已删除"
)
+
+ try:
+ return await _success()
except Exception as e:
- logger.error(f"删除客户端失败: {type(e).__name__}: {e}")
- return WSClientStatusOut(
- code=500, status="error", message=f"删除失败: {str(e)}"
- )
+ _raise_ws_http_error("删除客户端失败", e)
@router.post(
@@ -398,7 +288,7 @@ async def send_message(request: WSClientSendIn) -> WSClientStatusOut:
code=400, status="error", message=f"客户端 [{request.name}] 未连接"
)
- try:
+ async def _success() -> WSClientStatusOut:
success = await ws_client_manager.send_message(request.name, request.message)
if success:
return WSClientStatusOut(
@@ -409,11 +299,11 @@ async def send_message(request: WSClientSendIn) -> WSClientStatusOut:
)
else:
return WSClientStatusOut(code=500, status="error", message="消息发送失败")
+
+ try:
+ return await _success()
except Exception as e:
- logger.error(f"发送消息失败: {type(e).__name__}: {e}")
- return WSClientStatusOut(
- code=500, status="error", message=f"发送失败: {str(e)}"
- )
+ _raise_ws_http_error("发送消息失败", e)
@router.post(
@@ -438,7 +328,7 @@ async def send_json_message(request: WSClientSendJsonIn) -> WSClientStatusOut:
message = {"id": request.msg_id, "type": request.msg_type, "data": request.data}
- try:
+ async def _success() -> WSClientStatusOut:
success = await ws_client_manager.send_message(request.name, message)
if success:
return WSClientStatusOut(
@@ -449,11 +339,11 @@ async def send_json_message(request: WSClientSendJsonIn) -> WSClientStatusOut:
)
else:
return WSClientStatusOut(code=500, status="error", message="消息发送失败")
+
+ try:
+ return await _success()
except Exception as e:
- logger.error(f"发送消息失败: {type(e).__name__}: {e}")
- return WSClientStatusOut(
- code=500, status="error", message=f"发送失败: {str(e)}"
- )
+ _raise_ws_http_error("发送消息失败", e)
@router.post(
@@ -481,7 +371,7 @@ async def send_auth(request: WSClientAuthIn) -> WSClientStatusOut:
code=400, status="error", message=f"客户端 [{request.name}] 未连接"
)
- try:
+ async def _success() -> WSClientStatusOut:
success = await ws_client_manager.send_auth(
name=request.name,
token=request.token,
@@ -497,11 +387,11 @@ async def send_auth(request: WSClientAuthIn) -> WSClientStatusOut:
return WSClientStatusOut(
code=500, status="error", message="认证消息发送失败"
)
+
+ try:
+ return await _success()
except Exception as e:
- logger.error(f"发送认证消息失败: {type(e).__name__}: {e}")
- return WSClientStatusOut(
- code=500, status="error", message=f"发送失败: {str(e)}"
- )
+ _raise_ws_http_error("发送认证消息失败", e)
@router.get(
@@ -570,39 +460,44 @@ async def get_commands() -> WSCommandsOut:
)
-@router.websocket("/{channel_name}")
-async def websocket_dynamic_channel(websocket: WebSocket, channel_name: str):
+@router.websocket("/live")
+async def websocket_live(websocket: WebSocket):
"""
- 声明式动态 WebSocket 端点。
-
- 路由会根据 `channel_name` 查找管理器中的声明配置,
- 并统一通过 `openwsr` 接管当前连接。
+ 实时消息推送 WebSocket 端点
- Raises:
- Exception: 会话创建或运行失败时抛出底层异常。
+ 前端连接此端点后,可实时接收所有客户端的消息和事件
"""
- channel_config = ws_client_manager.get_reverse_channel_config(channel_name)
- if channel_config is None:
- logger.warning(f"未声明的反向通道连接被拒绝: /api/ws/{channel_name}")
- await websocket.close(code=1008, reason=f"未声明通道: {channel_name}")
- return
-
await websocket.accept()
+ ws_client_manager.add_debug_connection(websocket)
- on_message, on_connect, on_disconnect = _build_channel_handlers(
- channel_name=channel_name,
- websocket=websocket,
- channel_config=channel_config,
- )
+ logger.info(f"调试前端已连接: {websocket.client}")
- session = await ws_client_manager.openwsr(
- name=channel_name,
- websocket=websocket,
- ping_interval=float(channel_config.get("ping_interval", 15.0)),
- ping_timeout=float(channel_config.get("ping_timeout", 30.0)),
- auth_token=channel_config.get("auth_token"),
- on_message=on_message,
- on_connect=on_connect,
- on_disconnect=on_disconnect,
- )
- await session.wait_closed()
+ try:
+ # 发送当前所有客户端状态
+ clients = ws_client_manager.list_clients()
+ await websocket.send_json({"type": "init", "clients": list(clients.values())})
+
+ # 发送历史消息
+ history = ws_client_manager.get_message_history()
+ for client_name, messages in history.items():
+ for msg in messages:
+ await websocket.send_json(
+ {"type": "message", "client": client_name, **msg}
+ )
+
+ # 保持连接,接收心跳
+ while True:
+ try:
+ data = await websocket.receive_text()
+ # 处理心跳或其他命令
+ if data == "ping":
+ await websocket.send_text("pong")
+ except WebSocketDisconnect:
+ break
+ except Exception as e:
+ logger.error(f"WebSocket 错误: {e}")
+ break
+
+ finally:
+ ws_client_manager.remove_debug_connection(websocket)
+ logger.info(f"调试前端已断开: {websocket.client}")
diff --git a/app/contracts/__init__.py b/app/contracts/__init__.py
new file mode 100644
index 00000000..58d03e69
--- /dev/null
+++ b/app/contracts/__init__.py
@@ -0,0 +1,65 @@
+# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
+# Copyright © 2024-2025 DLmaster361
+# Copyright © 2025 MoeSnowyFox
+# Copyright © 2025-2026 AUTO-MAS Team
+
+# This file is part of AUTO-MAS.
+
+# AUTO-MAS is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of
+# the License, or (at your option) any later version.
+
+# AUTO-MAS is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty
+# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
+# the GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with AUTO-MAS. If not, see .
+
+# Contact: DLmaster_361@163.com
+
+"""
+API Contract 层
+
+本模块包含所有 API 请求/响应模型,按领域拆分为独立文件。
+
+设计原则:
+1. Contract 只负责 API 边界定义,不包含业务逻辑
+2. 使用单一 Contract 模型 + readOnly/writeOnly 字段标记
+3. 所有 Contract 继承 ApiModel,获得统一配置
+4. 字段命名使用 snake_case,通过 alias 兼容前端
+"""
+
+from .common_contract import (
+ ApiModel,
+ ComboBoxItem,
+ ComboBoxOut,
+ IndexOrderPatch,
+ InfoOut,
+ OutBase,
+ ResourceCollectionOut,
+ ResourceCreateOut,
+ ResourceItemOut,
+ dump_writable_data,
+ project_model,
+ project_model_list,
+ project_model_map,
+)
+
+__all__ = [
+ "ApiModel",
+ "OutBase",
+ "InfoOut",
+ "ComboBoxItem",
+ "ComboBoxOut",
+ "ResourceCollectionOut",
+ "ResourceItemOut",
+ "ResourceCreateOut",
+ "IndexOrderPatch",
+ "dump_writable_data",
+ "project_model",
+ "project_model_list",
+ "project_model_map",
+]
diff --git a/app/contracts/common_contract.py b/app/contracts/common_contract.py
new file mode 100644
index 00000000..183a5a01
--- /dev/null
+++ b/app/contracts/common_contract.py
@@ -0,0 +1,570 @@
+from __future__ import annotations
+
+from collections.abc import Iterable, Mapping
+from functools import lru_cache
+import re
+from types import UnionType
+from typing import (
+ Annotated,
+ Any,
+ Generic,
+ TypeAlias,
+ TypeVar,
+ Union,
+ cast,
+ get_args,
+ get_origin,
+)
+
+from pydantic import (
+ AliasChoices,
+ BaseModel,
+ ConfigDict,
+ Field,
+ create_model,
+ model_validator,
+)
+
+from app.core.config.fields import VirtualField
+from app.core.config.pydantic import PydanticConfigBase
+
+
+ModelT = TypeVar("ModelT", bound=BaseModel)
+MapKeyT = TypeVar("MapKeyT")
+IndexT = TypeVar("IndexT")
+DataT = TypeVar("DataT")
+RawModelSource: TypeAlias = Mapping[str, Any] | BaseModel
+
+
+class ApiModel(BaseModel):
+ """API Contract 的统一基线。"""
+
+ model_config = ConfigDict(
+ extra="forbid",
+ validate_assignment=True,
+ populate_by_name=True,
+ validate_by_alias=True,
+ )
+
+ @staticmethod
+ def _to_snake(name: str) -> str:
+ normalized = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
+ normalized = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", normalized)
+ return normalized.replace("-", "_").lower()
+
+ @model_validator(mode="before")
+ @classmethod
+ def _normalize_input_keys(cls, data: Any) -> Any:
+ if not isinstance(data, Mapping):
+ return data
+
+ normalized: dict[str, Any] = {}
+ field_names = cls.model_fields.keys()
+ raw_mapping = cast(Mapping[object, Any], data)
+ for raw_key, value in raw_mapping.items():
+ key = str(raw_key)
+ if key in field_names:
+ normalized[key] = value
+ continue
+
+ snake_key = cls._to_snake(key)
+ if snake_key in field_names and snake_key not in normalized:
+ normalized[snake_key] = value
+ continue
+
+ normalized[key] = value
+
+ return normalized
+
+
+class OutBase(ApiModel):
+ code: int = Field(default=200, description="状态码")
+ status: str = Field(default="success", description="操作状态")
+ message: str = Field(default="操作成功", description="操作消息")
+
+
+class InfoOut(OutBase):
+ data: dict[str, Any] = Field(..., description="收到的服务器数据")
+
+
+class ComboBoxItem(ApiModel):
+ label: str = Field(..., description="展示值")
+ value: str | None = Field(..., description="实际值")
+
+
+class ComboBoxOut(OutBase):
+ data: list[ComboBoxItem] = Field(..., description="下拉框选项")
+
+
+class ResourceCollectionOut(OutBase, Generic[IndexT, DataT]):
+ index: list[IndexT] = Field(..., description="资源索引列表")
+ data: dict[str, DataT] = Field(..., description="资源数据字典")
+
+
+class ResourceItemOut(OutBase, Generic[DataT]):
+ data: DataT = Field(..., description="资源数据")
+
+
+class ResourceCreateOut(ResourceItemOut[DataT], Generic[DataT]):
+ id: str = Field(..., description="新创建资源的唯一 ID")
+
+
+class IndexOrderPatch(ApiModel):
+ index_list: list[str] = Field(
+ ...,
+ validation_alias=AliasChoices("index_list", "indexList"),
+ serialization_alias="indexList",
+ description="按新顺序排列的资源 ID 列表",
+ )
+
+
+def _annotate(annotation: Any, metadata: tuple[object, ...]) -> Any:
+ if not metadata:
+ return annotation
+ return cast(Any, Annotated)[(annotation, *metadata)]
+
+
+def _make_optional(annotation: Any) -> Any:
+ origin = get_origin(annotation)
+ if origin in (Union, UnionType) and type(None) in get_args(annotation):
+ return annotation
+ return annotation | None
+
+
+def _clone_field_annotation(
+ field_info: Any,
+ annotation: Any,
+ *,
+ keep_virtual: bool,
+ read_only: bool = False,
+ write_only: bool = False,
+) -> Any:
+ metadata = tuple(
+ item
+ for item in field_info.metadata
+ if keep_virtual or not isinstance(item, VirtualField)
+ )
+ field_kwargs: dict[str, Any] = {}
+ if field_info.description is not None:
+ field_kwargs["description"] = field_info.description
+ if field_info.validation_alias is not None:
+ field_kwargs["validation_alias"] = field_info.validation_alias
+ if field_info.serialization_alias is not None:
+ field_kwargs["serialization_alias"] = field_info.serialization_alias
+ if field_info.alias is not None:
+ field_kwargs["alias"] = field_info.alias
+ json_schema_extra: dict[str, Any] = {}
+ raw_schema_extra: Any = field_info.json_schema_extra
+ if isinstance(raw_schema_extra, Mapping):
+ schema_extra_mapping = cast(
+ Mapping[str, Any],
+ raw_schema_extra,
+ )
+ json_schema_extra.update(dict(schema_extra_mapping))
+ if read_only:
+ json_schema_extra["readOnly"] = True
+ if write_only:
+ json_schema_extra["writeOnly"] = True
+ if json_schema_extra:
+ field_kwargs["json_schema_extra"] = json_schema_extra
+ if field_info.discriminator is not None:
+ field_kwargs["discriminator"] = field_info.discriminator
+ if field_kwargs:
+ metadata = (*metadata, Field(**field_kwargs))
+ return _annotate(annotation, metadata)
+
+
+def _model_field_definitions(
+ model_cls: type[BaseModel],
+ *,
+ optional_fields: bool,
+ keep_virtual: bool,
+ mark_virtual_as_read_only: bool = False,
+) -> dict[str, tuple[Any, Any]]:
+ field_definitions: dict[str, tuple[Any, Any]] = {}
+
+ for field_name, field_info in model_cls.model_fields.items():
+ if not keep_virtual and any(
+ isinstance(item, VirtualField) for item in field_info.metadata
+ ):
+ continue
+
+ annotation = field_info.annotation
+ if optional_fields:
+ annotation = _make_optional(annotation)
+
+ is_virtual = any(isinstance(item, VirtualField) for item in field_info.metadata)
+
+ annotated = _clone_field_annotation(
+ field_info,
+ annotation,
+ keep_virtual=keep_virtual,
+ read_only=mark_virtual_as_read_only and is_virtual,
+ )
+
+ if optional_fields:
+ default = Field(default=None, description=field_info.description)
+ elif field_info.default_factory is not None:
+ default = Field(
+ default_factory=field_info.default_factory,
+ description=field_info.description,
+ )
+ elif field_info.is_required():
+ default = ...
+ else:
+ default = Field(
+ default=field_info.default, description=field_info.description
+ )
+
+ field_definitions[field_name] = (annotated, default)
+
+ return field_definitions
+
+
+@lru_cache(maxsize=None)
+def derive_group_contract_model(
+ group_cls: type[BaseModel],
+ *,
+ model_name: str,
+) -> type[ApiModel]:
+ return create_model(
+ model_name,
+ __base__=ApiModel,
+ **cast(
+ dict[str, Any],
+ _model_field_definitions(
+ group_cls,
+ optional_fields=True,
+ keep_virtual=True,
+ mark_virtual_as_read_only=True,
+ ),
+ ),
+ )
+
+
+@lru_cache(maxsize=None)
+def derive_group_read_model(
+ group_cls: type[BaseModel],
+ *,
+ model_name: str,
+) -> type[ApiModel]:
+ return derive_group_contract_model(group_cls, model_name=model_name)
+
+
+@lru_cache(maxsize=None)
+def derive_group_patch_model(
+ group_cls: type[BaseModel],
+ *,
+ model_name: str,
+) -> type[ApiModel]:
+ return derive_group_contract_model(group_cls, model_name=model_name)
+
+
+@lru_cache(maxsize=None)
+def derive_config_contract_model(
+ config_cls: type[PydanticConfigBase],
+ *,
+ model_name: str,
+ include_groups: tuple[str, ...] | None = None,
+) -> type[ApiModel]:
+ field_definitions: dict[str, tuple[Any, Any]] = {}
+
+ allowed_groups = set(include_groups) if include_groups is not None else None
+ for group_name, field_info in config_cls.model_fields.items():
+ if allowed_groups is not None and group_name not in allowed_groups:
+ continue
+ group_cls = field_info.annotation
+ if not isinstance(group_cls, type) or not issubclass(group_cls, BaseModel):
+ continue
+ group_model = derive_group_contract_model(
+ group_cls,
+ model_name=f"{model_name}{group_name}",
+ )
+ field_definitions[group_name] = (
+ group_model | None,
+ Field(default=None, description=field_info.description),
+ )
+
+ return create_model(
+ model_name,
+ __base__=ApiModel,
+ **cast(dict[str, Any], field_definitions),
+ )
+
+
+@lru_cache(maxsize=None)
+def derive_config_read_model(
+ config_cls: type[PydanticConfigBase],
+ *,
+ model_name: str,
+ include_groups: tuple[str, ...] | None = None,
+) -> type[ApiModel]:
+ return derive_config_contract_model(
+ config_cls,
+ model_name=model_name,
+ include_groups=include_groups,
+ )
+
+
+@lru_cache(maxsize=None)
+def derive_config_patch_model(
+ config_cls: type[PydanticConfigBase],
+ *,
+ model_name: str,
+ include_groups: tuple[str, ...] | None = None,
+) -> type[ApiModel]:
+ return derive_config_contract_model(
+ config_cls,
+ model_name=model_name,
+ include_groups=include_groups,
+ )
+
+
+def derive_config_contracts(
+ config_cls: type[PydanticConfigBase],
+ *,
+ read_name: str,
+ patch_name: str,
+ include_groups: tuple[str, ...] | None = None,
+) -> tuple[type[ApiModel], type[ApiModel]]:
+ model = derive_config_contract_model(
+ config_cls,
+ model_name=read_name,
+ include_groups=include_groups,
+ )
+ return (model, model)
+
+
+def _extract_model_type(annotation: Any) -> type[BaseModel] | None:
+ origin = get_origin(annotation)
+ if origin is None:
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel):
+ return annotation
+ return None
+
+ if origin in (Union, UnionType):
+ for arg in get_args(annotation):
+ if arg is type(None):
+ continue
+ model_type = _extract_model_type(arg)
+ if model_type is not None:
+ return model_type
+ return None
+
+
+def _prune_read_only_data(
+ model_cls: type[BaseModel], payload: Mapping[str, Any]
+) -> dict[str, Any]:
+ result: dict[str, Any] = {}
+ for field_name, raw_value in payload.items():
+ field_info = model_cls.model_fields.get(field_name)
+ if field_info is None:
+ continue
+
+ extras = field_info.json_schema_extra
+ if (
+ isinstance(extras, Mapping)
+ and cast(Mapping[str, Any], extras).get("readOnly") is True
+ ):
+ continue
+
+ value = raw_value
+ nested_model = _extract_model_type(field_info.annotation)
+ if nested_model is not None and isinstance(value, Mapping):
+ value = _prune_read_only_data(nested_model, cast(Mapping[str, Any], value))
+
+ result[field_name] = value
+ return result
+
+
+def dump_writable_data(data: BaseModel) -> dict[str, Any]:
+ """导出可写字段,自动剔除 readOnly 字段。"""
+
+ payload = data.model_dump(exclude_unset=True, exclude_none=True)
+ return _prune_read_only_data(type(data), payload)
+
+
+def _normalize_source(raw: RawModelSource | None) -> dict[str, Any]:
+ if raw is None:
+ return {}
+ if isinstance(raw, BaseModel):
+ return raw.model_dump()
+ return dict(raw)
+
+
+def _project_value(annotation: Any, value: Any) -> Any:
+ if value is None:
+ return None
+
+ origin = get_origin(annotation)
+
+ if origin is None:
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel):
+ return _project_model_data(annotation, value)
+ return value
+
+ if origin in (Union, UnionType):
+ for candidate in (arg for arg in get_args(annotation) if arg is not type(None)):
+ try:
+ projected = _project_value(candidate, value)
+ if isinstance(candidate, type) and issubclass(candidate, BaseModel):
+ candidate.model_validate(projected)
+ return projected
+ except Exception:
+ continue
+ return value
+
+ if origin in (list, set, frozenset):
+ item_annotation = get_args(annotation)[0] if get_args(annotation) else Any
+ if not isinstance(value, (list, tuple, set, frozenset)):
+ return value
+
+ iterable_value = cast(
+ list[Any] | tuple[Any, ...] | set[Any] | frozenset[Any], value
+ )
+ items = [_project_value(item_annotation, item) for item in iterable_value]
+ if origin is set:
+ return set(items)
+ if origin is frozenset:
+ return frozenset(items)
+ return items
+
+ if origin is tuple:
+ if not isinstance(value, (list, tuple)):
+ return value
+
+ tuple_value = cast(list[Any] | tuple[Any, ...], value)
+ item_annotations = get_args(annotation)
+ if len(item_annotations) == 2 and item_annotations[1] is Ellipsis:
+ return tuple(
+ _project_value(item_annotations[0], item) for item in tuple_value
+ )
+
+ return tuple(
+ _project_value(item_annotation, item)
+ for item_annotation, item in zip(item_annotations, tuple_value)
+ )
+
+ if origin in (dict, Mapping):
+ key_annotation, value_annotation = (
+ get_args(annotation) if get_args(annotation) else (Any, Any)
+ )
+ if not isinstance(value, Mapping):
+ return value
+
+ mapping_value = cast(Mapping[Any, Any], value)
+ return {
+ _project_value(key_annotation, key): _project_value(value_annotation, item)
+ for key, item in mapping_value.items()
+ }
+
+ return value
+
+
+def _project_model_data(
+ model_cls: type[BaseModel],
+ raw: RawModelSource | None,
+ include_keys: Iterable[str] | None = None,
+) -> dict[str, Any]:
+ source = _normalize_source(raw)
+ field_names = include_keys or model_cls.model_fields.keys()
+
+ projected: dict[str, Any] = {}
+ for name in field_names:
+ field = model_cls.model_fields.get(name)
+ if field is None:
+ continue
+
+ source_value: Any | None = None
+ has_value = False
+ if name in source:
+ source_value = source[name]
+ has_value = True
+ else:
+ if field.alias is not None and field.alias in source:
+ source_value = source[field.alias]
+ has_value = True
+ elif field.validation_alias is not None:
+ alias = field.validation_alias
+ if isinstance(alias, str) and alias in source:
+ source_value = source[alias]
+ has_value = True
+ elif isinstance(alias, AliasChoices):
+ for candidate in alias.choices:
+ if isinstance(candidate, str) and candidate in source:
+ source_value = source[candidate]
+ has_value = True
+ break
+
+ if not has_value:
+ continue
+
+ projected[name] = _project_value(field.annotation, source_value)
+
+ return projected
+
+
+def project_model(
+ model_cls: type[ModelT],
+ raw: RawModelSource | None,
+ include_keys: Iterable[str] | None = None,
+) -> ModelT:
+ """把运行期字典投影为声明过字段的 Contract 模型。"""
+
+ return model_cls.model_validate(_project_model_data(model_cls, raw, include_keys))
+
+
+def project_model_list(
+ model_cls: type[ModelT],
+ raw_list: Iterable[RawModelSource] | None,
+ include_keys: Iterable[str] | None = None,
+) -> list[ModelT]:
+ if raw_list is None:
+ return []
+
+ projected_items: list[ModelT] = []
+ for raw_item in raw_list:
+ projected_items.append(
+ project_model(model_cls, raw_item, include_keys=include_keys)
+ )
+ return projected_items
+
+
+def project_model_map(
+ model_cls: type[ModelT],
+ raw_map: Mapping[MapKeyT, RawModelSource] | None,
+ include_keys: Iterable[str] | None = None,
+) -> dict[MapKeyT, ModelT]:
+ if raw_map is None:
+ return {}
+
+ projected_map: dict[MapKeyT, ModelT] = {}
+ for key, raw_item in raw_map.items():
+ projected_map[key] = project_model(
+ model_cls, raw_item, include_keys=include_keys
+ )
+ return projected_map
+
+
+__all__ = [
+ "ApiModel",
+ "OutBase",
+ "InfoOut",
+ "ComboBoxItem",
+ "ComboBoxOut",
+ "ResourceCollectionOut",
+ "ResourceItemOut",
+ "ResourceCreateOut",
+ "IndexOrderPatch",
+ "derive_group_read_model",
+ "derive_group_patch_model",
+ "derive_group_contract_model",
+ "derive_config_read_model",
+ "derive_config_patch_model",
+ "derive_config_contract_model",
+ "derive_config_contracts",
+ "dump_writable_data",
+ "project_model",
+ "project_model_list",
+ "project_model_map",
+]
diff --git a/app/contracts/dispatch_contract.py b/app/contracts/dispatch_contract.py
new file mode 100644
index 00000000..d7daf40c
--- /dev/null
+++ b/app/contracts/dispatch_contract.py
@@ -0,0 +1,51 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import Field
+
+from .common_contract import ApiModel, OutBase
+
+
+class DispatchIn(ApiModel):
+ taskId: str = Field(
+ ...,
+ description="目标任务ID, 设置类任务可选对应脚本ID或用户ID, 代理类任务可选对应队列ID或脚本ID",
+ )
+
+
+class TaskCreateIn(DispatchIn):
+ mode: Literal["AutoProxy", "ManualReview", "ScriptConfig"] = Field(
+ ..., description="任务模式"
+ )
+
+
+class TaskCreateOut(OutBase):
+ taskId: str = Field(..., description="新创建的任务ID")
+
+
+class PowerIn(ApiModel):
+ signal: Literal[
+ "NoAction",
+ "Shutdown",
+ "ShutdownForce",
+ "Reboot",
+ "Hibernate",
+ "Sleep",
+ "KillSelf",
+ ] = Field(..., description="电源操作信号")
+
+
+class PowerOut(OutBase):
+ signal: Literal[
+ "NoAction",
+ "Shutdown",
+ "ShutdownForce",
+ "Reboot",
+ "Hibernate",
+ "Sleep",
+ "KillSelf",
+ ] = Field(..., description="电源操作信号")
+
+
+__all__ = ["DispatchIn", "TaskCreateIn", "TaskCreateOut", "PowerIn", "PowerOut"]
diff --git a/app/contracts/emulator_contract.py b/app/contracts/emulator_contract.py
new file mode 100644
index 00000000..295e7010
--- /dev/null
+++ b/app/contracts/emulator_contract.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import Field
+
+from app.models.common import EmulatorConfig
+from .common_contract import (
+ ApiModel,
+ ResourceCollectionOut,
+ ResourceCreateOut,
+ ResourceItemOut,
+ derive_config_contract_model,
+)
+from app.models.shared import DeviceInfo
+
+
+_EmulatorBase = derive_config_contract_model(
+ EmulatorConfig,
+ model_name="EmulatorRead",
+ include_groups=("Info",),
+)
+
+
+class EmulatorRead(_EmulatorBase):
+ """模拟器配置读取/写入模型。"""
+
+
+class EmulatorConfigIndexItem(ApiModel):
+ uid: str = Field(..., description="唯一标识符")
+ type: Literal["EmulatorConfig"] = Field(..., description="配置类型")
+
+
+class EmulatorGetOut(ResourceCollectionOut[EmulatorConfigIndexItem, EmulatorRead]):
+ """模拟器列表响应模型"""
+
+
+class EmulatorDetailOut(ResourceItemOut[EmulatorRead]):
+ """模拟器详情响应模型"""
+
+
+class EmulatorCreateOut(ResourceCreateOut[EmulatorRead]):
+ """模拟器创建响应模型"""
+
+
+class EmulatorActionBody(ApiModel):
+ index: str = Field(..., description="模拟器索引")
+
+
+class EmulatorStatusOut(ResourceItemOut[dict[str, dict[str, DeviceInfo]]]):
+ """模拟器状态响应模型"""
+
+
+class EmulatorDeviceStatusOut(ResourceItemOut[dict[str, DeviceInfo]]):
+ """模拟器设备状态响应模型"""
+
+
+class EmulatorSearchResult(ApiModel):
+ type: str = Field(..., description="模拟器类型")
+ path: str = Field(..., description="模拟器路径")
+ name: str = Field(..., description="模拟器名称")
+
+
+class EmulatorSearchOut(ResourceItemOut[list[EmulatorSearchResult]]):
+ data: list[EmulatorSearchResult] = Field(..., description="搜索到的模拟器列表")
+
+
+__all__ = [
+ "EmulatorRead",
+ "EmulatorConfigIndexItem",
+ "EmulatorGetOut",
+ "EmulatorDetailOut",
+ "EmulatorCreateOut",
+ "EmulatorActionBody",
+ "EmulatorStatusOut",
+ "EmulatorDeviceStatusOut",
+ "EmulatorSearchResult",
+ "EmulatorSearchOut",
+]
diff --git a/app/contracts/general_contract.py b/app/contracts/general_contract.py
new file mode 100644
index 00000000..6206c7b6
--- /dev/null
+++ b/app/contracts/general_contract.py
@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import Field
+
+from .common_contract import derive_config_contract_model
+from app.models.general import GeneralConfig as RuntimeGeneralConfig
+from app.models.general import GeneralUserConfig as RuntimeGeneralUserConfig
+
+
+_GeneralConfigBase = derive_config_contract_model(
+ RuntimeGeneralConfig,
+ model_name="GeneralConfig",
+)
+_GeneralUserConfigBase = derive_config_contract_model(
+ RuntimeGeneralUserConfig,
+ model_name="GeneralUserConfig",
+)
+
+
+class GeneralConfig(_GeneralConfigBase):
+ type: Literal["GeneralConfig"] = Field(
+ default="GeneralConfig", description="配置类型"
+ )
+
+
+class GeneralUserConfig(_GeneralUserConfigBase):
+ type: Literal["GeneralUserConfig"] = Field(
+ default="GeneralUserConfig", description="配置类型"
+ )
+
+
+__all__ = [
+ "GeneralConfig",
+ "GeneralUserConfig",
+]
diff --git a/app/contracts/history_contract.py b/app/contracts/history_contract.py
new file mode 100644
index 00000000..fd3339f6
--- /dev/null
+++ b/app/contracts/history_contract.py
@@ -0,0 +1,67 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import Field
+
+from .common_contract import ApiModel, OutBase
+
+
+class HistoryIndexItem(ApiModel):
+ date: str = Field(..., description="日期")
+ status: Literal["DONE", "ERROR"] = Field(..., description="状态")
+ jsonFile: str = Field(..., description="对应JSON文件")
+
+
+class HistoryData(ApiModel):
+ index: list[HistoryIndexItem] | None = Field(
+ default=None, description="历史记录索引列表"
+ )
+ recruit_statistics: dict[str, int] | None = Field(
+ default=None, description="公招统计数据, key为星级, value为对应的公招数量"
+ )
+ drop_statistics: dict[str, dict[str, int]] | None = Field(
+ default=None,
+ description="掉落统计数据, 格式为 { '关卡号': { '掉落物': 数量 } }",
+ )
+ error_info: dict[str, str] | None = Field(
+ default=None, description="报错信息, key为时间戳, value为错误描述"
+ )
+ sanity: int | None = Field(default=None, description="当前理智值")
+ sanity_full_at: str | None = Field(
+ default=None, description="理智回满时间, 格式通常为 YYYY-MM-DD HH:MM:SS"
+ )
+ log_content: str | None = Field(
+ default=None, description="日志内容, 仅在提取单条历史记录数据时返回"
+ )
+
+
+class HistorySearchIn(ApiModel):
+ mode: Literal["DAILY", "WEEKLY", "MONTHLY"] = Field(..., description="合并模式")
+ start_date: str = Field(..., description="开始日期, 格式YYYY-MM-DD")
+ end_date: str = Field(..., description="结束日期, 格式YYYY-MM-DD")
+
+
+class HistorySearchOut(OutBase):
+ data: dict[str, dict[str, HistoryData]] = Field(
+ ...,
+ description="历史记录索引数据字典, 格式为 { '日期': { '用户名': [历史记录信息] } }",
+ )
+
+
+class HistoryDataGetIn(ApiModel):
+ jsonPath: str = Field(..., description="需要提取数据的历史记录JSON文件")
+
+
+class HistoryDataGetOut(OutBase):
+ data: HistoryData = Field(..., description="历史记录数据")
+
+
+__all__ = [
+ "HistoryIndexItem",
+ "HistoryData",
+ "HistorySearchIn",
+ "HistorySearchOut",
+ "HistoryDataGetIn",
+ "HistoryDataGetOut",
+]
diff --git a/app/contracts/info_contract.py b/app/contracts/info_contract.py
new file mode 100644
index 00000000..6db1330b
--- /dev/null
+++ b/app/contracts/info_contract.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import Field
+
+from .common_contract import ApiModel, OutBase
+
+
+class VersionOut(OutBase):
+ if_need_update: bool = Field(..., description="后端代码是否需要更新")
+ current_time: str = Field(..., description="后端代码当前时间戳")
+ current_hash: str = Field(..., description="后端代码当前哈希值")
+
+
+class NoticeOut(OutBase):
+ if_need_show: bool = Field(..., description="是否需要显示公告")
+ data: dict[str, str] = Field(
+ ..., description="公告信息, key为公告标题, value为公告内容"
+ )
+
+
+class GetStageIn(ApiModel):
+ type: Literal[
+ "User",
+ "Today",
+ "ALL",
+ "Monday",
+ "Tuesday",
+ "Wednesday",
+ "Thursday",
+ "Friday",
+ "Saturday",
+ "Sunday",
+ ] = Field(
+ ...,
+ description="选择的日期类型, Today为当天, ALL为包含当天未开放关卡在内的所有项",
+ )
+
+
+__all__ = ["VersionOut", "NoticeOut", "GetStageIn"]
diff --git a/app/contracts/maa_contract.py b/app/contracts/maa_contract.py
new file mode 100644
index 00000000..59ab36bb
--- /dev/null
+++ b/app/contracts/maa_contract.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import Field
+
+from .common_contract import derive_config_contract_model
+from app.models.maa import MaaConfig as RuntimeMaaConfig
+from app.models.maa import MaaPlanConfig as RuntimeMaaPlanConfig
+from app.models.maa import MaaUserConfig as RuntimeMaaUserConfig
+
+
+_MaaConfigBase = derive_config_contract_model(
+ RuntimeMaaConfig,
+ model_name="MaaConfig",
+)
+_MaaUserConfigBase = derive_config_contract_model(
+ RuntimeMaaUserConfig,
+ model_name="MaaUserConfig",
+)
+_MaaPlanConfigBase = derive_config_contract_model(
+ RuntimeMaaPlanConfig,
+ model_name="MaaPlanConfig",
+)
+
+
+class MaaConfig(_MaaConfigBase):
+ type: Literal["MaaConfig"] = Field(default="MaaConfig", description="配置类型")
+
+
+class MaaUserConfig(_MaaUserConfigBase):
+ type: Literal["MaaUserConfig"] = Field(
+ default="MaaUserConfig", description="配置类型"
+ )
+
+
+class MaaPlanConfig(_MaaPlanConfigBase):
+ type: Literal["MaaPlanConfig"] = Field(
+ default="MaaPlanConfig", description="配置类型"
+ )
+
+
+__all__ = [
+ "MaaConfig",
+ "MaaUserConfig",
+ "MaaPlanConfig",
+]
diff --git a/app/contracts/maaend_contract.py b/app/contracts/maaend_contract.py
new file mode 100644
index 00000000..2030f282
--- /dev/null
+++ b/app/contracts/maaend_contract.py
@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import Field
+
+from .common_contract import derive_config_contract_model
+from app.models.maaend import MaaEndConfig as RuntimeMaaEndConfig
+from app.models.maaend import MaaEndUserConfig as RuntimeMaaEndUserConfig
+
+
+_MaaEndConfigBase = derive_config_contract_model(
+ RuntimeMaaEndConfig,
+ model_name="MaaEndConfig",
+)
+_MaaEndUserConfigBase = derive_config_contract_model(
+ RuntimeMaaEndUserConfig,
+ model_name="MaaEndUserConfig",
+)
+
+
+class MaaEndConfig(_MaaEndConfigBase):
+ type: Literal["MaaEndConfig"] = Field(
+ default="MaaEndConfig", description="配置类型"
+ )
+
+
+class MaaEndUserConfig(_MaaEndUserConfigBase):
+ type: Literal["MaaEndUserConfig"] = Field(
+ default="MaaEndUserConfig", description="配置类型"
+ )
+
+
+__all__ = [
+ "MaaEndConfig",
+ "MaaEndUserConfig",
+]
diff --git a/app/contracts/plan_contract.py b/app/contracts/plan_contract.py
new file mode 100644
index 00000000..0d8c8c33
--- /dev/null
+++ b/app/contracts/plan_contract.py
@@ -0,0 +1,278 @@
+from __future__ import annotations
+
+from collections.abc import Iterator
+from typing import Literal
+
+from pydantic import AliasChoices, Field
+
+from .common_contract import (
+ ApiModel,
+ ResourceCollectionOut,
+ ResourceCreateOut,
+ ResourceItemOut,
+)
+
+
+WEEKDAY_META: tuple[tuple[str, str, str], ...] = (
+ ("monday", "Monday", "周一"),
+ ("tuesday", "Tuesday", "周二"),
+ ("wednesday", "Wednesday", "周三"),
+ ("thursday", "Thursday", "周四"),
+ ("friday", "Friday", "周五"),
+ ("saturday", "Saturday", "周六"),
+ ("sunday", "Sunday", "周日"),
+)
+
+
+def _iter_weekday_fields(
+ day_model: type[ApiModel], *, optional: bool
+) -> Iterator[tuple[str, object, object]]:
+ """按 `WEEKDAY_META` 生成周字段定义。
+
+ Args:
+ day_model: 星期字段对应的数据模型类型(如 `MaaPlanDayRead` / `MaaPlanDayPatch`)。
+ optional: 是否生成可选字段。
+ - `False`:字段类型为 `day_model`,使用 `default_factory=day_model`。
+ - `True`:字段类型为 `day_model | None`,使用 `default=None`。
+
+ Yields:
+ 三元组 `(field_name, field_type, field_info)`,用于在类体内批量写入
+ `__annotations__` 和字段默认值。
+ """
+ for day_key, day_alias, day_desc in WEEKDAY_META:
+ if optional:
+ yield (
+ day_key,
+ day_model | None,
+ Field(
+ default=None,
+ validation_alias=AliasChoices(day_key, day_alias),
+ serialization_alias=day_alias,
+ description=day_desc,
+ ),
+ )
+ continue
+
+ yield (
+ day_key,
+ day_model,
+ Field(
+ default_factory=day_model,
+ validation_alias=AliasChoices(day_key, day_alias),
+ serialization_alias=day_alias,
+ description=day_desc,
+ ),
+ )
+
+
+class PlanIndexItem(ApiModel):
+ uid: str = Field(..., description="唯一标识符")
+ type: Literal["MaaPlanConfig"] = Field(..., description="配置类型")
+
+
+class MaaPlanInfoRead(ApiModel):
+ name: str = Field(
+ default="新 MAA 计划表",
+ validation_alias=AliasChoices("name", "Name"),
+ serialization_alias="Name",
+ description="计划表名称",
+ )
+ mode: Literal["ALL", "Weekly"] = Field(
+ default="ALL",
+ validation_alias=AliasChoices("mode", "Mode"),
+ serialization_alias="Mode",
+ description="计划表模式",
+ )
+
+
+class MaaPlanInfoPatch(ApiModel):
+ name: str | None = Field(
+ default=None,
+ validation_alias=AliasChoices("name", "Name"),
+ serialization_alias="Name",
+ description="计划表名称",
+ )
+ mode: Literal["ALL", "Weekly"] | None = Field(
+ default=None,
+ validation_alias=AliasChoices("mode", "Mode"),
+ serialization_alias="Mode",
+ description="计划表模式",
+ )
+
+
+class MaaPlanDayRead(ApiModel):
+ medicine_numb: int = Field(
+ default=0,
+ validation_alias=AliasChoices("medicine_numb", "MedicineNumb"),
+ serialization_alias="MedicineNumb",
+ description="吃理智药",
+ )
+ series_numb: Literal["0", "6", "5", "4", "3", "2", "1", "-1"] = Field(
+ default="0",
+ validation_alias=AliasChoices("series_numb", "SeriesNumb"),
+ serialization_alias="SeriesNumb",
+ description="连战次数",
+ )
+ stage: str = Field(
+ default="-",
+ validation_alias=AliasChoices("stage", "Stage"),
+ serialization_alias="Stage",
+ description="关卡选择",
+ )
+ stage_1: str = Field(
+ default="-",
+ validation_alias=AliasChoices("stage_1", "Stage_1"),
+ serialization_alias="Stage_1",
+ description="备选关卡 - 1",
+ )
+ stage_2: str = Field(
+ default="-",
+ validation_alias=AliasChoices("stage_2", "Stage_2"),
+ serialization_alias="Stage_2",
+ description="备选关卡 - 2",
+ )
+ stage_3: str = Field(
+ default="-",
+ validation_alias=AliasChoices("stage_3", "Stage_3"),
+ serialization_alias="Stage_3",
+ description="备选关卡 - 3",
+ )
+ stage_remain: str = Field(
+ default="-",
+ validation_alias=AliasChoices("stage_remain", "Stage_Remain"),
+ serialization_alias="Stage_Remain",
+ description="剩余理智关卡",
+ )
+
+
+class MaaPlanDayPatch(ApiModel):
+ medicine_numb: int | None = Field(
+ default=None,
+ validation_alias=AliasChoices("medicine_numb", "MedicineNumb"),
+ serialization_alias="MedicineNumb",
+ description="吃理智药",
+ )
+ series_numb: Literal["0", "6", "5", "4", "3", "2", "1", "-1"] | None = Field(
+ default=None,
+ validation_alias=AliasChoices("series_numb", "SeriesNumb"),
+ serialization_alias="SeriesNumb",
+ description="连战次数",
+ )
+ stage: str | None = Field(
+ default=None,
+ validation_alias=AliasChoices("stage", "Stage"),
+ serialization_alias="Stage",
+ description="关卡选择",
+ )
+ stage_1: str | None = Field(
+ default=None,
+ validation_alias=AliasChoices("stage_1", "Stage_1"),
+ serialization_alias="Stage_1",
+ description="备选关卡 - 1",
+ )
+ stage_2: str | None = Field(
+ default=None,
+ validation_alias=AliasChoices("stage_2", "Stage_2"),
+ serialization_alias="Stage_2",
+ description="备选关卡 - 2",
+ )
+ stage_3: str | None = Field(
+ default=None,
+ validation_alias=AliasChoices("stage_3", "Stage_3"),
+ serialization_alias="Stage_3",
+ description="备选关卡 - 3",
+ )
+ stage_remain: str | None = Field(
+ default=None,
+ validation_alias=AliasChoices("stage_remain", "Stage_Remain"),
+ serialization_alias="Stage_Remain",
+ description="剩余理智关卡",
+ )
+
+
+class MaaPlanRead(ApiModel):
+ info: MaaPlanInfoRead = Field(
+ default_factory=MaaPlanInfoRead,
+ validation_alias=AliasChoices("info", "Info"),
+ serialization_alias="Info",
+ description="基础信息",
+ )
+ all_days: MaaPlanDayRead = Field(
+ default_factory=MaaPlanDayRead,
+ validation_alias=AliasChoices("all_days", "ALL"),
+ serialization_alias="ALL",
+ description="全局",
+ )
+
+ # 自动展开的星期项
+ wk_key = ""
+ wk_type = object
+ wk_field = None
+ for wk_key, wk_type, wk_field in _iter_weekday_fields(
+ MaaPlanDayRead, optional=False
+ ):
+ __annotations__[wk_key] = wk_type
+ locals()[wk_key] = wk_field
+ del wk_key, wk_type, wk_field
+
+
+class MaaPlanPatch(ApiModel):
+ info: MaaPlanInfoPatch | None = Field(
+ default=None,
+ validation_alias=AliasChoices("info", "Info"),
+ serialization_alias="Info",
+ description="基础信息",
+ )
+ all_days: MaaPlanDayPatch | None = Field(
+ default=None,
+ validation_alias=AliasChoices("all_days", "ALL"),
+ serialization_alias="ALL",
+ description="全局",
+ )
+
+ # 自动展开的星期项
+ wk_key = ""
+ wk_type = object
+ wk_field = None
+ for wk_key, wk_type, wk_field in _iter_weekday_fields(
+ MaaPlanDayPatch, optional=True
+ ):
+ __annotations__[wk_key] = wk_type
+ locals()[wk_key] = wk_field
+ del wk_key, wk_type, wk_field
+
+
+class PlanCreateIn(ApiModel):
+ type: Literal["MaaPlan"] = Field(..., description="计划类型")
+
+
+class PlanUpdateBody(ApiModel):
+ data: MaaPlanPatch = Field(..., description="计划更新数据")
+
+
+class PlanCreateOut(ResourceCreateOut[MaaPlanRead]):
+ """计划创建响应模型"""
+
+
+class PlanDetailOut(ResourceItemOut[MaaPlanRead]):
+ """计划详情响应模型"""
+
+
+class PlanGetOut(ResourceCollectionOut[PlanIndexItem, MaaPlanRead]):
+ """计划列表响应模型"""
+
+
+__all__ = [
+ "PlanIndexItem",
+ "MaaPlanInfoRead",
+ "MaaPlanInfoPatch",
+ "MaaPlanDayRead",
+ "MaaPlanDayPatch",
+ "MaaPlanRead",
+ "MaaPlanPatch",
+ "PlanCreateIn",
+ "PlanCreateOut",
+ "PlanDetailOut",
+ "PlanGetOut",
+ "PlanUpdateBody",
+]
diff --git a/app/contracts/queue_contract.py b/app/contracts/queue_contract.py
new file mode 100644
index 00000000..089bf000
--- /dev/null
+++ b/app/contracts/queue_contract.py
@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import Field
+
+from app.models.common import QueueConfig, QueueItem, TimeSet
+from .common_contract import (
+ ApiModel,
+ ResourceCollectionOut,
+ ResourceCreateOut,
+ ResourceItemOut,
+ derive_config_contract_model,
+)
+
+
+_QueueBase = derive_config_contract_model(
+ QueueConfig,
+ model_name="QueueRead",
+ include_groups=("Info",),
+)
+_TimeSetBase = derive_config_contract_model(
+ TimeSet,
+ model_name="TimeSetRead",
+ include_groups=("Info",),
+)
+_QueueItemBase = derive_config_contract_model(
+ QueueItem,
+ model_name="QueueItemRead",
+ include_groups=("Info",),
+)
+
+
+class QueueRead(_QueueBase):
+ """队列配置读取/写入模型。"""
+
+
+class TimeSetRead(_TimeSetBase):
+ """时间集合读取/写入模型。"""
+
+
+class QueueItemRead(_QueueItemBase):
+ """任务项读取/写入模型。"""
+
+
+class QueueIndexItem(ApiModel):
+ uid: str = Field(..., description="唯一标识符")
+ type: Literal["QueueConfig"] = Field(..., description="配置类型")
+
+
+class QueueItemIndexItem(ApiModel):
+ uid: str = Field(..., description="唯一标识符")
+ type: Literal["QueueItem"] = Field(..., description="配置类型")
+
+
+class TimeSetIndexItem(ApiModel):
+ uid: str = Field(..., description="唯一标识符")
+ type: Literal["TimeSet"] = Field(..., description="配置类型")
+
+
+class QueueCreateOut(ResourceCreateOut[QueueRead]):
+ """队列创建响应模型"""
+
+
+class QueueDetailOut(ResourceItemOut[QueueRead]):
+ """队列详情响应模型"""
+
+
+class QueueGetOut(ResourceCollectionOut[QueueIndexItem, QueueRead]):
+ """队列列表响应模型"""
+
+class TimeSetCreateOut(ResourceCreateOut[TimeSetRead]):
+ """时间集创建响应模型"""
+
+
+class TimeSetDetailOut(ResourceItemOut[TimeSetRead]):
+ """时间集详情响应模型"""
+
+
+class TimeSetGetOut(ResourceCollectionOut[TimeSetIndexItem, TimeSetRead]):
+ """时间集列表响应模型"""
+
+class QueueItemCreateOut(ResourceCreateOut[QueueItemRead]):
+ """队列项创建响应模型"""
+
+
+class QueueItemDetailOut(ResourceItemOut[QueueItemRead]):
+ """队列项详情响应模型"""
+
+
+class QueueItemGetOut(ResourceCollectionOut[QueueItemIndexItem, QueueItemRead]):
+ """队列项列表响应模型"""
+
+
+__all__ = [
+ "QueueRead",
+ "TimeSetRead",
+ "QueueItemRead",
+ "QueueIndexItem",
+ "QueueItemIndexItem",
+ "TimeSetIndexItem",
+ "QueueCreateOut",
+ "QueueDetailOut",
+ "QueueGetOut",
+ "TimeSetCreateOut",
+ "TimeSetDetailOut",
+ "TimeSetGetOut",
+ "QueueItemCreateOut",
+ "QueueItemDetailOut",
+ "QueueItemGetOut",
+]
diff --git a/app/contracts/scripts_contract.py b/app/contracts/scripts_contract.py
new file mode 100644
index 00000000..3b18da41
--- /dev/null
+++ b/app/contracts/scripts_contract.py
@@ -0,0 +1,287 @@
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Annotated, Any, Literal
+
+from pydantic import Field
+
+from .common_contract import (
+ ApiModel,
+ ResourceCollectionOut,
+ ResourceCreateOut,
+ ResourceItemOut,
+ dump_writable_data,
+ project_model,
+)
+from .general_contract import (
+ GeneralConfig,
+ GeneralUserConfig,
+)
+from .maa_contract import MaaConfig, MaaUserConfig
+from .maaend_contract import (
+ MaaEndConfig,
+ MaaEndUserConfig,
+)
+from .src_contract import SrcConfig, SrcUserConfig
+
+
+ScriptConfigType = Literal["MaaConfig", "GeneralConfig", "SrcConfig", "MaaEndConfig"]
+UserConfigType = Literal[
+ "MaaUserConfig", "GeneralUserConfig", "SrcUserConfig", "MaaEndUserConfig"
+]
+ScriptCreateType = Literal["MAA", "SRC", "General", "MaaEnd"]
+
+ScriptModel = MaaConfig | SrcConfig | GeneralConfig | MaaEndConfig
+UserModel = MaaUserConfig | SrcUserConfig | GeneralUserConfig | MaaEndUserConfig
+ScriptModelClass = (
+ type[MaaConfig] | type[SrcConfig] | type[GeneralConfig] | type[MaaEndConfig]
+)
+UserModelClass = (
+ type[MaaUserConfig]
+ | type[SrcUserConfig]
+ | type[GeneralUserConfig]
+ | type[MaaEndUserConfig]
+)
+ScriptPatchClass = ScriptModelClass
+UserPatchClass = UserModelClass
+
+ScriptReadData = Annotated[
+ MaaConfig | SrcConfig | GeneralConfig | MaaEndConfig,
+ Field(discriminator="type"),
+]
+UserReadData = Annotated[
+ MaaUserConfig | SrcUserConfig | GeneralUserConfig | MaaEndUserConfig,
+ Field(discriminator="type"),
+]
+
+SCRIPT_CONTRACT_BY_TYPE: dict[ScriptConfigType, ScriptModelClass] = {
+ "MaaConfig": MaaConfig,
+ "GeneralConfig": GeneralConfig,
+ "SrcConfig": SrcConfig,
+ "MaaEndConfig": MaaEndConfig,
+}
+SCRIPT_PATCH_BY_TYPE: dict[ScriptConfigType, ScriptPatchClass] = {
+ "MaaConfig": MaaConfig,
+ "GeneralConfig": GeneralConfig,
+ "SrcConfig": SrcConfig,
+ "MaaEndConfig": MaaEndConfig,
+}
+USER_CONTRACT_BY_TYPE: dict[UserConfigType, UserModelClass] = {
+ "MaaUserConfig": MaaUserConfig,
+ "GeneralUserConfig": GeneralUserConfig,
+ "SrcUserConfig": SrcUserConfig,
+ "MaaEndUserConfig": MaaEndUserConfig,
+}
+USER_PATCH_BY_TYPE: dict[UserConfigType, UserPatchClass] = {
+ "MaaUserConfig": MaaUserConfig,
+ "GeneralUserConfig": GeneralUserConfig,
+ "SrcUserConfig": SrcUserConfig,
+ "MaaEndUserConfig": MaaEndUserConfig,
+}
+SCRIPT_CREATE_TO_CONFIG_TYPE: dict[ScriptCreateType, ScriptConfigType] = {
+ "MAA": "MaaConfig",
+ "SRC": "SrcConfig",
+ "General": "GeneralConfig",
+ "MaaEnd": "MaaEndConfig",
+}
+SCRIPT_CONFIG_TO_USER_TYPE: dict[ScriptConfigType, UserConfigType] = {
+ "MaaConfig": "MaaUserConfig",
+ "GeneralConfig": "GeneralUserConfig",
+ "SrcConfig": "SrcUserConfig",
+ "MaaEndConfig": "MaaEndUserConfig",
+}
+
+
+class ScriptIndexItem(ApiModel):
+ uid: str = Field(..., description="唯一标识符")
+ type: ScriptConfigType = Field(..., description="配置类型")
+
+
+class UserIndexItem(ApiModel):
+ uid: str = Field(..., description="唯一标识符")
+ type: UserConfigType = Field(..., description="配置类型")
+
+
+class ScriptCreateIn(ApiModel):
+ type: ScriptCreateType = Field(
+ ..., description="脚本类型: MAA脚本, 通用脚本, SRC脚本, MaaEnd脚本"
+ )
+ copyFromId: str | None = Field(
+ default=None, description="直接从该脚本 ID 复制创建, 仅复制创建时使用"
+ )
+
+
+class ScriptCreateOut(ResourceCreateOut[ScriptReadData]):
+ """脚本创建响应模型"""
+
+
+class ScriptDetailOut(ResourceItemOut[ScriptReadData]):
+ """脚本详情响应模型"""
+
+
+class ScriptGetOut(ResourceCollectionOut[ScriptIndexItem, ScriptReadData]):
+ """脚本列表响应模型"""
+
+
+class ScriptFileBody(ApiModel):
+ path: str = Field(..., description="文件路径")
+
+
+class ScriptUrlBody(ApiModel):
+ url: str = Field(..., description="配置文件 URL")
+
+
+class ScriptUploadBody(ApiModel):
+ config_name: str = Field(..., description="配置名称")
+ author: str = Field(..., description="作者")
+ description: str = Field(..., description="描述")
+
+
+class UserGetOut(ResourceCollectionOut[UserIndexItem, UserReadData]):
+ """用户列表响应模型"""
+
+
+class UserDetailOut(ResourceItemOut[UserReadData]):
+ """用户详情响应模型"""
+
+
+class UserCreateOut(ResourceCreateOut[UserReadData]):
+ """用户创建响应模型"""
+
+ScriptPatchData = Annotated[
+ MaaConfig | SrcConfig | GeneralConfig | MaaEndConfig,
+ Field(discriminator="type"),
+]
+UserPatchData = Annotated[
+ MaaUserConfig | SrcUserConfig | GeneralUserConfig | MaaEndUserConfig,
+ Field(discriminator="type"),
+]
+
+
+class ScriptPatchBody(ApiModel):
+ data: ScriptPatchData = Field(..., description="脚本 Patch 数据")
+
+
+class UserPatchBody(ApiModel):
+ data: UserPatchData = Field(..., description="用户 Patch 数据")
+
+
+class InfrastructureImportBody(ApiModel):
+ path: str = Field(..., description="JSON 文件路径, 用于导入自定义基建文件")
+
+
+def script_contract_type_from_create(create_type: ScriptCreateType) -> ScriptConfigType:
+ return SCRIPT_CREATE_TO_CONFIG_TYPE[create_type]
+
+
+def script_contract_type_from_runtime(type_name: str) -> ScriptConfigType:
+ if type_name not in SCRIPT_CONTRACT_BY_TYPE:
+ raise KeyError(f"未知脚本 Contract 类型: {type_name}")
+ return type_name
+
+
+def user_contract_type_from_script(script_type: ScriptConfigType) -> UserConfigType:
+ return SCRIPT_CONFIG_TO_USER_TYPE[script_type]
+
+
+def project_script_model(
+ script_type: ScriptConfigType,
+ raw: Mapping[str, Any] | ApiModel | None,
+) -> ScriptModel:
+ return project_model(SCRIPT_CONTRACT_BY_TYPE[script_type], raw)
+
+
+def project_user_model(
+ user_type: UserConfigType,
+ raw: Mapping[str, Any] | ApiModel | None,
+) -> UserModel:
+ return project_model(USER_CONTRACT_BY_TYPE[user_type], raw)
+
+
+def project_script_model_map(
+ index_list: list[ScriptIndexItem],
+ raw_map: Mapping[str, Mapping[str, Any] | ApiModel],
+) -> dict[str, ScriptModel]:
+ index_map: dict[str, ScriptConfigType] = {
+ item.uid: item.type for item in index_list
+ }
+ return {
+ uid: project_script_model(index_map[uid], raw)
+ for uid, raw in raw_map.items()
+ if uid in index_map
+ }
+
+
+def project_user_model_map(
+ index_list: list[UserIndexItem],
+ raw_map: Mapping[str, Mapping[str, Any] | ApiModel],
+) -> dict[str, UserModel]:
+ index_map: dict[str, UserConfigType] = {item.uid: item.type for item in index_list}
+ return {
+ uid: project_user_model(index_map[uid], raw)
+ for uid, raw in raw_map.items()
+ if uid in index_map
+ }
+
+
+def dump_script_patch_data(
+ script_type: ScriptConfigType, data: ScriptPatchData
+) -> dict[str, Any]:
+ if data.type != script_type:
+ raise ValueError(f"Patch 类型不匹配: 期望 {script_type}, 实际 {data.type}")
+ writable = dump_writable_data(data)
+ writable.pop("type", None)
+ return writable
+
+
+def dump_user_patch_data(
+ user_type: UserConfigType, data: UserPatchData
+) -> dict[str, Any]:
+ if data.type != user_type:
+ raise ValueError(f"Patch 类型不匹配: 期望 {user_type}, 实际 {data.type}")
+ writable = dump_writable_data(data)
+ writable.pop("type", None)
+ return writable
+
+
+__all__ = [
+ "ScriptConfigType",
+ "UserConfigType",
+ "ScriptCreateType",
+ "ScriptModel",
+ "UserModel",
+ "ScriptReadData",
+ "UserReadData",
+ "ScriptPatchData",
+ "UserPatchData",
+ "SCRIPT_CONTRACT_BY_TYPE",
+ "SCRIPT_PATCH_BY_TYPE",
+ "USER_CONTRACT_BY_TYPE",
+ "USER_PATCH_BY_TYPE",
+ "SCRIPT_CREATE_TO_CONFIG_TYPE",
+ "SCRIPT_CONFIG_TO_USER_TYPE",
+ "ScriptIndexItem",
+ "UserIndexItem",
+ "ScriptCreateIn",
+ "ScriptCreateOut",
+ "ScriptDetailOut",
+ "ScriptGetOut",
+ "ScriptFileBody",
+ "ScriptUrlBody",
+ "ScriptUploadBody",
+ "ScriptPatchBody",
+ "UserGetOut",
+ "UserDetailOut",
+ "UserCreateOut",
+ "UserPatchBody",
+ "InfrastructureImportBody",
+ "script_contract_type_from_create",
+ "script_contract_type_from_runtime",
+ "user_contract_type_from_script",
+ "project_script_model",
+ "project_user_model",
+ "project_script_model_map",
+ "project_user_model_map",
+ "dump_script_patch_data",
+ "dump_user_patch_data",
+]
diff --git a/app/contracts/setting_contract.py b/app/contracts/setting_contract.py
new file mode 100644
index 00000000..302e58fe
--- /dev/null
+++ b/app/contracts/setting_contract.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import Field
+
+from app.models.common import Webhook
+from app.models.global_config import GlobalConfig
+from .common_contract import (
+ ApiModel,
+ ResourceCollectionOut,
+ ResourceCreateOut,
+ ResourceItemOut,
+ derive_config_contract_model,
+)
+
+
+_WebhookBase = derive_config_contract_model(
+ Webhook,
+ model_name="WebhookRead",
+)
+_GlobalConfigBase = derive_config_contract_model(
+ GlobalConfig,
+ model_name="GlobalConfigRead",
+ include_groups=("Function", "Voice", "Start", "UI", "Notify", "Update"),
+)
+
+
+class WebhookRead(_WebhookBase):
+ """Webhook 配置读取/写入模型。"""
+
+
+class GlobalConfigRead(_GlobalConfigBase):
+ """全局配置读取/写入模型。"""
+
+
+class WebhookIndexItem(ApiModel):
+ uid: str = Field(..., description="唯一标识符")
+ type: Literal["Webhook"] = Field(..., description="配置类型")
+
+
+class WebhookGetOut(ResourceCollectionOut[WebhookIndexItem, WebhookRead]):
+ """Webhook 列表响应模型"""
+
+
+class WebhookDetailOut(ResourceItemOut[WebhookRead]):
+ """Webhook 详情响应模型"""
+
+
+class WebhookCreateOut(ResourceCreateOut[WebhookRead]):
+ """Webhook 创建响应模型"""
+
+
+class SettingGetOut(ResourceItemOut[GlobalConfigRead]):
+ """全局设置响应模型"""
+
+
+__all__ = [
+ "WebhookRead",
+ "GlobalConfigRead",
+ "WebhookIndexItem",
+ "WebhookGetOut",
+ "WebhookDetailOut",
+ "WebhookCreateOut",
+ "SettingGetOut",
+]
diff --git a/app/contracts/src_contract.py b/app/contracts/src_contract.py
new file mode 100644
index 00000000..26769fbb
--- /dev/null
+++ b/app/contracts/src_contract.py
@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import Field
+
+from .common_contract import derive_config_contract_model
+from app.models.src import SrcConfig as RuntimeSrcConfig
+from app.models.src import SrcUserConfig as RuntimeSrcUserConfig
+
+
+_SrcConfigBase = derive_config_contract_model(
+ RuntimeSrcConfig,
+ model_name="SrcConfig",
+)
+_SrcUserConfigBase = derive_config_contract_model(
+ RuntimeSrcUserConfig,
+ model_name="SrcUserConfig",
+)
+
+
+class SrcConfig(_SrcConfigBase):
+ type: Literal["SrcConfig"] = Field(default="SrcConfig", description="配置类型")
+
+
+class SrcUserConfig(_SrcUserConfigBase):
+ type: Literal["SrcUserConfig"] = Field(
+ default="SrcUserConfig", description="配置类型"
+ )
+
+
+__all__ = [
+ "SrcConfig",
+ "SrcUserConfig",
+]
diff --git a/app/contracts/tools_contract.py b/app/contracts/tools_contract.py
new file mode 100644
index 00000000..9e883562
--- /dev/null
+++ b/app/contracts/tools_contract.py
@@ -0,0 +1,24 @@
+from __future__ import annotations
+
+from .common_contract import ResourceItemOut, derive_config_contract_model
+from app.models.global_config import ToolsConfig
+
+
+_ToolsConfigBase = derive_config_contract_model(
+ ToolsConfig,
+ model_name="ToolsConfigRead",
+)
+
+
+class ToolsConfigRead(_ToolsConfigBase):
+ """工具配置读取/写入模型。"""
+
+
+class ToolsGetOut(ResourceItemOut[ToolsConfigRead]):
+ """工具配置响应模型"""
+
+
+__all__ = [
+ "ToolsConfigRead",
+ "ToolsGetOut",
+]
diff --git a/app/contracts/update_contract.py b/app/contracts/update_contract.py
new file mode 100644
index 00000000..0fe4974e
--- /dev/null
+++ b/app/contracts/update_contract.py
@@ -0,0 +1,19 @@
+from __future__ import annotations
+
+from pydantic import Field
+
+from .common_contract import ApiModel, OutBase
+
+
+class UpdateCheckIn(ApiModel):
+ current_version: str = Field(..., description="当前前端版本号")
+ if_force: bool = Field(default=False, description="是否强制拉取更新信息")
+
+
+class UpdateCheckOut(OutBase):
+ if_need_update: bool = Field(..., description="是否需要更新前端")
+ latest_version: str = Field(..., description="最新前端版本号")
+ update_info: dict[str, list[str]] = Field(..., description="版本更新信息字典")
+
+
+__all__ = ["UpdateCheckIn", "UpdateCheckOut"]
diff --git a/app/contracts/ws_contract.py b/app/contracts/ws_contract.py
new file mode 100644
index 00000000..893670eb
--- /dev/null
+++ b/app/contracts/ws_contract.py
@@ -0,0 +1,127 @@
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import Field
+
+from .common_contract import ApiModel, OutBase
+
+
+class WSClientCreateIn(ApiModel):
+ """创建 WebSocket 客户端请求"""
+
+ name: str = Field(..., description="客户端名称,用于标识")
+ url: str = Field(
+ ..., description="WebSocket 服务器地址,如 ws://localhost:5140/path"
+ )
+ ping_interval: float = Field(default=15.0, description="心跳发送间隔(秒)")
+ ping_timeout: float = Field(default=30.0, description="心跳超时时间(秒)")
+ reconnect_interval: float = Field(default=5.0, description="重连间隔(秒)")
+ max_reconnect_attempts: int = Field(
+ default=-1, description="最大重连次数,-1为无限"
+ )
+
+
+class WSClientCreateOut(OutBase):
+ """创建客户端响应"""
+
+ data: dict[str, Any] | None = Field(default=None, description="返回数据")
+
+
+class WSClientConnectIn(ApiModel):
+ """连接请求"""
+
+ name: str = Field(..., description="客户端名称")
+
+
+class WSClientDisconnectIn(ApiModel):
+ """断开连接请求"""
+
+ name: str = Field(..., description="客户端名称")
+
+
+class WSClientRemoveIn(ApiModel):
+ """删除客户端请求"""
+
+ name: str = Field(..., description="客户端名称")
+
+
+class WSClientSendIn(ApiModel):
+ """发送消息请求"""
+
+ name: str = Field(..., description="客户端名称")
+ message: dict[str, Any] = Field(..., description="要发送的 JSON 消息")
+
+
+class WSClientSendJsonIn(ApiModel):
+ """发送自定义 JSON 消息请求"""
+
+ name: str = Field(..., description="客户端名称")
+ msg_id: str = Field(default="Client", description="消息 ID")
+ msg_type: str = Field(..., description="消息类型")
+ data: dict[str, Any] = Field(default_factory=dict, description="消息数据")
+
+
+class WSClientAuthIn(ApiModel):
+ """发送认证请求"""
+
+ name: str = Field(..., description="客户端名称")
+ token: str = Field(..., description="认证 Token")
+ auth_type: str = Field(default="auth", description="认证消息类型")
+ extra_data: dict[str, Any] | None = Field(
+ default=None, description="额外认证数据"
+ )
+
+
+class WSClientStatusIn(ApiModel):
+ """获取客户端状态请求"""
+
+ name: str = Field(..., description="客户端名称")
+
+
+class WSClientStatusOut(OutBase):
+ """客户端状态响应"""
+
+ data: dict[str, Any] | None = Field(default=None, description="状态数据")
+
+
+class WSClientListOut(OutBase):
+ """客户端列表响应"""
+
+ data: dict[str, Any] | None = Field(default=None, description="客户端列表")
+
+
+class WSMessageHistoryOut(OutBase):
+ """消息历史响应"""
+
+ data: dict[str, Any] | None = Field(default=None, description="消息历史")
+
+
+class WSClearHistoryIn(ApiModel):
+ """清空消息历史请求"""
+
+ name: str | None = Field(default=None, description="客户端名称,为空则清空所有")
+
+
+class WSCommandsOut(OutBase):
+ """可用命令列表响应"""
+
+ data: dict[str, Any] | None = Field(default=None, description="命令列表")
+
+
+__all__ = [
+ "WSClientCreateIn",
+ "WSClientCreateOut",
+ "WSClientConnectIn",
+ "WSClientDisconnectIn",
+ "WSClientRemoveIn",
+ "WSClientSendIn",
+ "WSClientSendJsonIn",
+ "WSClientAuthIn",
+ "WSClientStatusIn",
+ "WSClientStatusOut",
+ "WSClientListOut",
+ "WSMessageHistoryOut",
+ "WSClearHistoryIn",
+ "WSCommandsOut",
+]
diff --git a/app/core/__init__.py b/app/core/__init__.py
index 7d1c7384..87c6330e 100644
--- a/app/core/__init__.py
+++ b/app/core/__init__.py
@@ -22,12 +22,11 @@
from .broadcast import Broadcast
-from .config import Config
+from .config.manager import Config
from .emulator_manager import EmulatorManager
-from .task_manager import TaskManager
from .maa_manager import MaaFWManager
-from .plugins import PluginManager
-
+from .plugins.manager import PluginManager
+from .task_manager import TaskManager
from .timer import MainTimer
__all__ = [
diff --git a/app/core/__init__.pyi b/app/core/__init__.pyi
new file mode 100644
index 00000000..b1a481a8
--- /dev/null
+++ b/app/core/__init__.pyi
@@ -0,0 +1,9 @@
+from .broadcast import Broadcast as Broadcast
+from .config.manager import Config as Config
+from .emulator_manager import EmulatorManager as EmulatorManager
+from .maa_manager import MaaFWManager as MaaFWManager
+from .plugins.manager import PluginManager as PluginManager
+from .task_manager import TaskManager as TaskManager
+from .timer import MainTimer as MainTimer
+
+__all__: list[str]
diff --git a/app/core/broadcast.py b/app/core/broadcast.py
index 62e53f8b..24b960b0 100644
--- a/app/core/broadcast.py
+++ b/app/core/broadcast.py
@@ -22,7 +22,7 @@
import asyncio
from copy import deepcopy
-from typing import Set
+from typing import Any, Set, cast
from app.utils import get_logger
@@ -31,23 +31,24 @@
class _Broadcast:
-
def __init__(self):
- self.__subscribers: Set[asyncio.Queue] = set()
+ self.__subscribers: Set[asyncio.Queue[Any]] = set()
- async def subscribe(self, queue: asyncio.Queue):
+ async def subscribe(self, queue: asyncio.Queue[Any]) -> None:
"""订阅者注册"""
self.__subscribers.add(queue)
- async def unsubscribe(self, queue: asyncio.Queue):
+ async def unsubscribe(self, queue: asyncio.Queue[Any]) -> None:
"""取消订阅"""
self.__subscribers.remove(queue)
- async def put(self, item):
+ async def put(self, item: Any) -> None:
"""向所有订阅者广播消息"""
logger.debug(f"向所有订阅者广播消息: {item}")
+ should_copy = isinstance(item, (dict, list, set, tuple))
for subscriber in self.__subscribers:
- await subscriber.put(deepcopy(item))
+ payload = deepcopy(cast(Any, item)) if should_copy else item
+ await subscriber.put(payload)
Broadcast = _Broadcast()
diff --git a/app/core/config.py b/app/core/config.py
deleted file mode 100644
index ae536bd1..00000000
--- a/app/core/config.py
+++ /dev/null
@@ -1,2195 +0,0 @@
-# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
-# Copyright © 2024-2025 DLmaster361
-# Copyright © 2025 MoeSnowyFox
-# Copyright © 2025-2026 AUTO-MAS Team
-
-# This file is part of AUTO-MAS.
-
-# AUTO-MAS is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as
-# published by the Free Software Foundation, either version 3 of
-# the License, or (at your option) any later version.
-
-# AUTO-MAS is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty
-# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
-# the GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with AUTO-MAS. If not, see .
-
-# Contact: DLmaster_361@163.com
-
-import os
-import re
-import sys
-import httpx
-import shutil
-import asyncio
-import uvicorn
-import sqlite3
-import truststore
-from pathlib import Path
-from fastapi import WebSocket
-from collections import defaultdict
-from jinja2 import Environment, FileSystemLoader
-from datetime import datetime, timedelta, date
-from typing import Literal, Optional, Union, Dict, Any, List
-import uuid
-import json
-
-from app.models.config import (
- GeneralConfig,
- MaaConfig,
- SrcConfig,
- MaaEndConfig,
- MaaPlanConfig,
- QueueConfig,
- QueueItem,
- MaaUserConfig,
- SrcUserConfig,
- MaaEndUserConfig,
- GeneralUserConfig,
- GlobalConfig,
- CLASS_BOOK,
- Webhook,
- TimeSet,
- EmulatorConfig,
-)
-from app.models.schema import WebSocketMessage
-from app.utils.constants import (
- UTC4,
- UTC8,
- RESOURCE_STAGE_INFO,
- RESOURCE_STAGE_DROP_INFO,
- TYPE_BOOK,
- RESOURCE_STAGE_DATE_TEXT,
-)
-from app.utils import get_logger
-
-logger = get_logger("配置管理")
-
-if (Path.cwd() / "environment/git/bin/git.exe").exists():
- os.environ["GIT_PYTHON_GIT_EXECUTABLE"] = str(
- Path.cwd() / "environment/git/bin/git.exe"
- )
-
-try:
- from git import Repo
-except ImportError:
- Repo = None
-
-
-class AppConfig(GlobalConfig):
- VERSION = "v5.2.0-beta.2"
-
- def __init__(self) -> None:
- super().__init__()
-
- logger.info("")
- logger.info("===================================")
- logger.info("AUTO-MAS 后端应用程序")
- logger.info(f"版本号: {self.VERSION}")
- logger.info(f"工作目录: {Path.cwd()}")
- logger.info("===================================")
-
- self.log_path = Path.cwd() / "debug/app.log"
- self.database_path = Path.cwd() / "data/data.db"
- self.config_path = Path.cwd() / "config"
- self.history_path = Path.cwd() / "history"
- # 检查目录
- self.log_path.parent.mkdir(parents=True, exist_ok=True)
- self.database_path.parent.mkdir(parents=True, exist_ok=True)
- self.config_path.mkdir(parents=True, exist_ok=True)
- self.history_path.mkdir(parents=True, exist_ok=True)
-
- # 初始化Git仓库(如果可用)
- try:
- if Repo is not None:
- self.repo = Repo(Path.cwd())
- else:
- self.repo = None
- except Exception as e:
- logger.warning(f"Git仓库初始化失败: {e}")
- self.repo = None
-
- self.notify_env = Environment(
- loader=FileSystemLoader(str(Path.cwd() / "res/html"))
- )
-
- self.server: Optional[uvicorn.Server] = None
- self.websocket: Optional[WebSocket] = None
- self.power_sign: Literal[
- "NoAction",
- "Shutdown",
- "ShutdownForce",
- "Reboot",
- "Hibernate",
- "Sleep",
- "KillSelf",
- ] = "NoAction"
- self.temp_task: List[asyncio.Task] = []
-
- truststore.inject_into_ssl()
-
- async def init_config(self) -> None:
- """初始化配置管理"""
-
- await self.check_data()
-
- await self.connect(self.config_path / "Config.json")
- await self.EmulatorConfig.connect(self.config_path / "EmulatorConfig.json")
- await self.PlanConfig.connect(self.config_path / "PlanConfig.json")
- await self.ScriptConfig.connect(self.config_path / "ScriptConfig.json")
- await self.QueueConfig.connect(self.config_path / "QueueConfig.json")
- await self.ToolsConfig.connect(self.config_path / "ToolsConfig.json")
- await self.PluginConfig.connect(self.config_path / "PluginConfig.json")
-
- from app.services import System
-
- self.bind("Start", "IfSelfStart", System.set_SelfStart)
- self.bind("Function", "IfAllowSleep", System.set_Sleep)
- await System.set_SelfStart(self.get("Start", "IfSelfStart"))
- await System.set_Sleep(self.get("Function", "IfAllowSleep"))
-
- self.loop = asyncio.get_running_loop()
-
- logger.info("程序初始化完成")
-
- async def check_data(self) -> None:
- """检查用户数据文件并处理数据文件版本更新"""
-
- # 生成主数据库
- if not self.database_path.exists():
- db = sqlite3.connect(self.database_path)
- cur = db.cursor()
- cur.execute("CREATE TABLE version(v text)")
- cur.execute("INSERT INTO version VALUES(?)", ("v1.11",))
- db.commit()
- cur.close()
- db.close()
-
- # 数据文件版本更新
- db = sqlite3.connect(self.database_path)
- cur = db.cursor()
- cur.execute("SELECT * FROM version WHERE True")
- version = cur.fetchall()
-
- if version[0][0] != "v1.11":
- logger.info(
- "数据文件版本更新开始",
- )
- if_streaming = False
- # v1.7-->v1.8
- if version[0][0] == "v1.7" or if_streaming:
- logger.info(
- "数据文件版本更新: v1.7-->v1.8",
- )
- if_streaming = True
-
- if (Path.cwd() / "config/QueueConfig").exists():
- for QueueConfig in (Path.cwd() / "config/QueueConfig").glob(
- "*.json"
- ):
- with QueueConfig.open(encoding="utf-8") as f:
- queue_config = json.load(f)
-
- queue_config["QueueSet"]["TimeEnabled"] = queue_config[
- "QueueSet"
- ]["Enabled"]
-
- for i in range(10):
- queue_config["Queue"][f"Script_{i}"] = queue_config[
- "Queue"
- ][f"Member_{i + 1}"]
- queue_config["Time"][f"Enabled_{i}"] = queue_config["Time"][
- f"TimeEnabled_{i}"
- ]
- queue_config["Time"][f"Set_{i}"] = queue_config["Time"][
- f"TimeSet_{i}"
- ]
-
- with QueueConfig.open("w", encoding="utf-8") as f:
- json.dump(queue_config, f, ensure_ascii=False, indent=4)
-
- cur.execute("DELETE FROM version WHERE v = ?", ("v1.7",))
- cur.execute("INSERT INTO version VALUES(?)", ("v1.8",))
- db.commit()
- # v1.8-->v1.9
- if version[0][0] == "v1.8" or if_streaming:
- logger.info(
- "数据文件版本更新: v1.8-->v1.9",
- )
- if_streaming = True
-
- await self.ScriptConfig.connect(self.config_path / "ScriptConfig.json")
- await self.PlanConfig.connect(self.config_path / "PlanConfig.json")
- await self.QueueConfig.connect(self.config_path / "QueueConfig.json")
-
- if (Path.cwd() / "config/config.json").exists():
- (Path.cwd() / "config/config.json").rename(
- Path.cwd() / "config/Config.json"
- )
- await self.connect(self.config_path / "Config.json")
-
- plan_dict = {"固定": "Fixed"}
-
- if (Path.cwd() / "config/MaaPlanConfig").exists():
- for MaaPlanConfig in (
- Path.cwd() / "config/MaaPlanConfig"
- ).iterdir():
- if (
- MaaPlanConfig.is_dir()
- and (MaaPlanConfig / "config.json").exists()
- ):
- maa_plan_config = json.loads(
- (MaaPlanConfig / "config.json").read_text(
- encoding="utf-8"
- )
- )
- uid, pc = await self.add_plan("MaaPlan")
- plan_dict[MaaPlanConfig.name] = str(uid)
-
- await pc.load(maa_plan_config)
-
- script_dict: Dict[str, Optional[str]] = {"禁用": None}
-
- if (Path.cwd() / "config/MaaConfig").exists():
- for MaaConfig in (Path.cwd() / "config/MaaConfig").iterdir():
- if MaaConfig.is_dir():
- maa_config = json.loads(
- (MaaConfig / "config.json").read_text(encoding="utf-8")
- )
- maa_config["Info"] = maa_config["MaaSet"]
- maa_config["Run"] = maa_config["RunSet"]
-
- uid, sc = await self.add_script("MAA")
- script_dict[MaaConfig.name] = str(uid)
- await sc.load(maa_config)
-
- if (MaaConfig / "Default/gui.json").exists():
- (Path.cwd() / f"data/{uid}/Default/ConfigFile").mkdir(
- parents=True, exist_ok=True
- )
- shutil.copy(
- MaaConfig / "Default/gui.json",
- Path.cwd()
- / f"data/{uid}/Default/ConfigFile/gui.json",
- )
-
- for user in (MaaConfig / "UserData").iterdir():
- if user.is_dir() and (user / "config.json").exists():
- user_config = json.loads(
- (user / "config.json").read_text(
- encoding="utf-8"
- )
- )
-
- user_config["Info"]["StageMode"] = plan_dict.get(
- user_config["Info"]["StageMode"], "Fixed"
- )
- user_config["Info"]["Password"] = ""
-
- user_uid, uc = await self.add_user(str(uid))
- await uc.load(user_config)
-
- if (user / "Routine/gui.json").exists():
- (
- Path.cwd()
- / f"data/{uid}/{user_uid}/ConfigFile"
- ).mkdir(parents=True, exist_ok=True)
- shutil.copy(
- user / "Routine/gui.json",
- Path.cwd()
- / f"data/{uid}/{user_uid}/ConfigFile/gui.json",
- )
- if (
- user / "Infrastructure/infrastructure.json"
- ).exists():
- (
- Path.cwd()
- / f"data/{uid}/{user_uid}/Infrastructure"
- ).mkdir(parents=True, exist_ok=True)
- shutil.copy(
- user / "Infrastructure/infrastructure.json",
- Path.cwd()
- / f"data/{uid}/{user_uid}/Infrastructure/infrastructure.json",
- )
-
- if (Path.cwd() / "config/GeneralConfig").exists():
- for GeneralConfig in (
- Path.cwd() / "config/GeneralConfig"
- ).iterdir():
- if GeneralConfig.is_dir():
- general_config = json.loads(
- (GeneralConfig / "config.json").read_text(
- encoding="utf-8"
- )
- )
- general_config["Info"] = {
- "Name": general_config["Script"]["Name"],
- "RootPath": general_config["Script"]["RootPath"],
- }
-
- general_config["Script"]["ConfigPathMode"] = (
- "File"
- if "所有文件"
- in general_config["Script"]["ConfigPathMode"]
- else "Folder"
- )
-
- uid, sc = await self.add_script("General")
- script_dict[GeneralConfig.name] = str(uid)
- await sc.load(general_config)
-
- for user in (GeneralConfig / "SubData").iterdir():
- if user.is_dir() and (user / "config.json").exists():
- user_config = json.loads(
- (user / "config.json").read_text(
- encoding="utf-8"
- )
- )
-
- user_uid, uc = await self.add_user(str(uid))
- await uc.load(user_config)
-
- if (user / "ConfigFiles").exists():
- (Path.cwd() / f"data/{uid}/{user_uid}").mkdir(
- parents=True, exist_ok=True
- )
- shutil.move(
- user / "ConfigFiles",
- Path.cwd()
- / f"data/{uid}/{user_uid}/ConfigFile",
- )
-
- if (Path.cwd() / "config/QueueConfig").exists():
- for QueueConfig in (Path.cwd() / "config/QueueConfig").glob(
- "*.json"
- ):
- queue_config = json.loads(
- QueueConfig.read_text(encoding="utf-8")
- )
-
- uid, qc = await self.add_queue()
-
- queue_config["Info"] = queue_config["QueueSet"]
- await qc.load(queue_config)
-
- for i in range(10):
- item_uid, item = await self.add_queue_item(str(uid))
- time_uid, time = await self.add_time_set(str(uid))
-
- await time.load(
- {
- "Info": {
- "Enabled": queue_config["Time"][f"Enabled_{i}"],
- "Time": queue_config["Time"][f"Set_{i}"],
- }
- }
- )
- await item.load(
- {
- "Info": {
- "ScriptId": script_dict.get(
- queue_config["Queue"][f"Script_{i}"], "-"
- )
- }
- }
- )
-
- if (Path.cwd() / "config/QueueConfig").exists():
- shutil.rmtree(Path.cwd() / "config/QueueConfig")
- if (Path.cwd() / "config/MaaPlanConfig").exists():
- shutil.rmtree(Path.cwd() / "config/MaaPlanConfig")
- if (Path.cwd() / "config/MaaConfig").exists():
- shutil.rmtree(Path.cwd() / "config/MaaConfig")
- if (Path.cwd() / "config/GeneralConfig").exists():
- shutil.rmtree(Path.cwd() / "config/GeneralConfig")
- if (Path.cwd() / "data/gameid.txt").exists():
- (Path.cwd() / "data/gameid.txt").unlink()
- if (Path.cwd() / "data/key").exists():
- shutil.rmtree(Path.cwd() / "data/key")
-
- cur.execute("DELETE FROM version WHERE v = ?", ("v1.8",))
- cur.execute("INSERT INTO version VALUES(?)", ("v1.9",))
- db.commit()
- # v1.9-->v1.10
- if version[0][0] == "v1.9" or if_streaming:
- logger.info(
- "数据文件版本更新: v1.9-->v1.10",
- )
- if_streaming = True
-
- if (Path.cwd() / "config/Config.json").exists():
- data = json.loads(
- (Path.cwd() / "config/Config.json").read_text(encoding="utf-8")
- )
- data["Data"]["LastStageUpdated"] = ""
- data["Data"]["Stage"] = "{ }"
- data["Function"]["IfBlockAd"] = data["Function"].get(
- "IfSkipMumuSplashAds", False
- )
- (Path.cwd() / "config/Config.json").write_text(
- json.dumps(data, ensure_ascii=False, indent=4), encoding="utf-8"
- )
-
- cur.execute("DELETE FROM version WHERE v = ?", ("v1.9",))
- cur.execute("INSERT INTO version VALUES(?)", ("v1.10",))
- db.commit()
- # v1.10-->v1.11
- if version[0][0] == "v1.10" or if_streaming:
- logger.info(
- "数据文件版本更新: v1.10-->v1.11",
- )
- if_streaming = True
-
- if (Path.cwd() / "config/ScriptConfig.json").exists():
- data = (Path.cwd() / "config/ScriptConfig.json").read_text(
- encoding="utf-8"
- )
- data.replace("IfWakeUp", "IfStartUp")
- data.replace("IfAutoRoguelike", "IfRoguelike")
- data.replace("IfBase", "IfInfrast")
- data.replace("IfCombat", "IfFight")
- data.replace("IfMission", "IfAward")
- data.replace("IfRecruiting", "IfRecruit")
- (Path.cwd() / "config/ScriptConfig.json").write_text(
- data, encoding="utf-8"
- )
-
- cur.execute("DELETE FROM version WHERE v = ?", ("v1.10",))
- cur.execute("INSERT INTO version VALUES(?)", ("v1.11",))
- db.commit()
-
- cur.close()
- db.close()
- logger.success("数据文件版本更新完成")
-
- async def send_json(self, data: dict) -> None:
- """通过WebSocket发送JSON数据"""
- if Config.websocket is None:
- logger.warning("WebSocket 未连接")
- else:
- await Config.websocket.send_json(data)
-
- async def send_websocket_message(
- self,
- id: str,
- type: Literal["Update", "Message", "Info", "Signal"],
- data: Dict[str, Any],
- ) -> None:
- """通过WebSocket发送消息"""
- if Config.websocket is None:
- logger.warning("WebSocket 未连接")
- else:
- await Config.websocket.send_json(
- WebSocketMessage(id=id, type=type, data=data).model_dump()
- )
-
- async def get_git_version(self) -> tuple[bool, str, str]:
- """获取Git版本信息,如果Git不可用则返回默认值"""
-
- def _get_git_info():
-
- if self.repo is None:
- logger.warning("Git仓库不可用,返回默认版本信息")
- return False, "unknown", "unknown"
-
- # 获取当前 commit
- current_commit = self.repo.head.commit
- # 获取 commit 哈希
- commit_hash = current_commit.hexsha
- # 获取 commit 时间
- commit_time = datetime.fromtimestamp(current_commit.committed_date)
-
- # 检查是否为最新 commit
- try:
- # 获取远程分支的最新 commit
- origin = self.repo.remotes.origin
- origin.fetch() # 拉取最新信息
- remote_commit = self.repo.commit(
- f"origin/{self.repo.active_branch.name}"
- )
- is_latest = bool(current_commit.hexsha == remote_commit.hexsha)
- except Exception as e:
- logger.warning(f"无法获取远程分支信息: {e}")
- is_latest = False
-
- return is_latest, commit_hash, commit_time.strftime("%Y-%m-%d %H:%M:%S")
-
- # 在线程池中执行 Git 操作
- is_latest, commit_hash, commit_time = await self.loop.run_in_executor(
- None, _get_git_info
- )
- return is_latest, commit_hash, commit_time
-
- async def add_script(
- self,
- script: Literal["MAA", "SRC", "General", "MaaEnd"],
- script_id: str | None = None,
- ) -> tuple[uuid.UUID, MaaConfig | SrcConfig | GeneralConfig | MaaEndConfig]:
- """添加脚本配置"""
-
- logger.info(f"添加脚本配置: {script}, 从 {script_id} 复制")
-
- if script_id is None:
- return await self.ScriptConfig.add(CLASS_BOOK[script])
- else:
- script_uid = uuid.UUID(script_id)
-
- if not isinstance(self.ScriptConfig[script_uid], CLASS_BOOK[script]):
- raise TypeError(f"脚本配置类型不匹配: {script_id} {script}")
-
- new_uid, new_config = await self.ScriptConfig.add(CLASS_BOOK[script])
-
- await new_config.load(
- await self.ScriptConfig[script_uid].toDict(regenerate_uuids=True)
- )
-
- # 复制用户数据
- if (Path.cwd() / f"data/{script_id}").exists():
- shutil.copytree(
- Path.cwd() / f"data/{script_id}",
- Path.cwd() / f"data/{new_uid}",
- dirs_exist_ok=True,
- )
- for old_user, new_user in zip(
- self.ScriptConfig[script_uid].UserData.keys(),
- new_config.UserData.keys(),
- ):
- if (Path.cwd() / f"data/{new_uid}/{old_user}").exists():
- (Path.cwd() / f"data/{new_uid}/{old_user}").rename(
- Path.cwd() / f"data/{new_uid}/{new_user}"
- )
-
- return new_uid, new_config
-
- async def get_script(self, script_id: str | None) -> tuple[list, dict]:
- """获取脚本配置"""
-
- logger.info(f"获取脚本配置: {script_id}")
-
- if script_id is None:
- # 获取所有脚本配置
- data = await self.ScriptConfig.toDict()
- else:
- # 获取指定脚本配置
- data = await self.ScriptConfig.get(uuid.UUID(script_id))
-
- index = data.pop("instances", [])
- return list(index), data
-
- async def update_script(
- self, script_id: str, data: Dict[str, Dict[str, Any]]
- ) -> None:
- """更新脚本配置"""
-
- logger.info(f"更新脚本配置: {script_id}")
-
- uid = uuid.UUID(script_id)
-
- if self.ScriptConfig[uid].is_locked:
- raise RuntimeError(f"脚本 {script_id} 正在运行, 无法更新配置项")
-
- for group, items in data.items():
- for name, value in items.items():
- await self.ScriptConfig[uid].set(group, name, value)
-
- async def del_script(self, script_id: str) -> None:
- """删除脚本配置"""
-
- logger.info(f"删除脚本配置: {script_id}")
-
- uid = uuid.UUID(script_id)
-
- if self.ScriptConfig[uid].is_locked:
- raise RuntimeError(f"脚本 {script_id} 正在运行, 无法删除")
-
- # 删除脚本相关的队列项
- for queue in self.QueueConfig.values():
- for key, value in queue.QueueItem.items():
- if value.get("Info", "ScriptId") == str(uid):
- await queue.QueueItem.remove(key)
-
- await self.ScriptConfig.remove(uid)
- if (Path.cwd() / f"data/{uid}").exists():
- shutil.rmtree(Path.cwd() / f"data/{uid}")
-
- async def reorder_script(self, index_list: list[str]) -> None:
- """重新排序脚本"""
-
- logger.info(f"重新排序脚本: {index_list}")
-
- await self.ScriptConfig.setOrder([uuid.UUID(_) for _ in index_list])
-
- async def import_script_from_file(self, script_id: str, jsonFile: str) -> None:
- """从文件加载脚本配置"""
-
- logger.info(f"从文件加载脚本配置: {script_id} - {jsonFile}")
- uid = uuid.UUID(script_id)
- file_path = Path(jsonFile)
-
- if uid not in self.ScriptConfig:
- logger.error(f"{script_id} 不存在")
- raise KeyError(f"脚本 {script_id} 不存在")
- if not isinstance(self.ScriptConfig[uid], GeneralConfig):
- logger.error(f"{script_id} 不是通用脚本配置")
- raise TypeError(f"脚本 {script_id} 不是通用脚本配置")
- if not Path(file_path).exists():
- logger.error(f"文件不存在: {file_path}")
- raise FileNotFoundError(f"文件不存在: {file_path}")
-
- data = json.loads(file_path.read_text(encoding="utf-8"))
- await self.ScriptConfig[uid].load(data)
-
- logger.success(f"{script_id} 配置加载成功")
-
- async def export_script_to_file(self, script_id: str, jsonFile: str):
- """导出脚本配置到文件"""
-
- logger.info(f"导出配置到文件: {script_id} - {jsonFile}")
-
- uid = uuid.UUID(script_id)
- file_path = Path(jsonFile)
-
- if uid not in self.ScriptConfig:
- logger.error(f"{script_id} 不存在")
- raise KeyError(f"脚本 {script_id} 不存在")
- if not isinstance(self.ScriptConfig[uid], GeneralConfig):
- logger.error(f"{script_id} 不是通用脚本配置")
- raise TypeError(f"脚本 {script_id} 不是通用脚本配置")
-
- temp = await self.ScriptConfig[uid].toDict(if_decrypt=False)
- temp.pop("SubConfigsInfo", None)
- temp = await self.remove_privacy_info(temp, Path(file_path).stem)
-
- file_path.write_text(
- json.dumps(temp, ensure_ascii=False, indent=4), encoding="utf-8"
- )
-
- logger.success(f"{script_id} 配置导出成功")
-
- async def import_script_from_web(self, script_id: str, url: str):
- """从「AUTO-MAS 配置分享中心」导入配置"""
-
- logger.info(f"从网络加载脚本配置: {script_id} - {url}")
- uid = uuid.UUID(script_id)
-
- if uid not in self.ScriptConfig:
- logger.error(f"{script_id} 不存在")
- raise KeyError(f"脚本 {script_id} 不存在")
- if not isinstance(self.ScriptConfig[uid], GeneralConfig):
- logger.error(f"{script_id} 不是通用脚本配置")
- raise TypeError(f"脚本 {script_id} 不是通用脚本配置")
-
- # 使用 httpx 异步请求
- async with httpx.AsyncClient(
- proxy=Config.proxy, follow_redirects=True
- ) as client:
- try:
- response = await client.get(url)
- if response.status_code == 200:
- data = response.json()
- else:
- logger.warning(
- f"无法从 AUTO-MAS 服务器获取配置内容: {response.text}"
- )
- raise ConnectionError(
- f"无法从 AUTO-MAS 服务器获取配置内容: {response.status_code}"
- )
- except httpx.RequestError as e:
- logger.warning(f"无法从 AUTO-MAS 服务器获取配置内容: {e}")
- raise ConnectionError(f"无法从 AUTO-MAS 服务器获取配置内容: {e}")
-
- if data.get("code", 200) == 500:
- logger.error(f"从 AUTO-MAS 服务器获取配置内容失败: {data.get('message')}")
- raise ConnectionError(
- f"从 AUTO-MAS 服务器获取配置内容失败: {data.get('message')}"
- )
-
- await self.ScriptConfig[uid].load(data)
-
- logger.success(f"{script_id} 配置加载成功")
-
- async def upload_script_to_web(
- self, script_id: str, config_name: str, author: str, description: str
- ):
- """上传配置到「AUTO-MAS 配置分享中心」"""
-
- logger.info(f"上传配置到网络: {script_id} - {config_name} - {author}")
-
- uid = uuid.UUID(script_id)
-
- if uid not in self.ScriptConfig:
- logger.error(f"{script_id} 不存在")
- raise KeyError(f"脚本 {script_id} 不存在")
- if not isinstance(self.ScriptConfig[uid], GeneralConfig):
- logger.error(f"{script_id} 不是通用脚本配置")
- raise TypeError(f"脚本 {script_id} 不是通用脚本配置")
-
- temp = await self.ScriptConfig[uid].toDict(if_decrypt=False)
- temp.pop("SubConfigsInfo", None)
- temp = await self.remove_privacy_info(temp, config_name)
-
- files = {
- "file": (
- f"{config_name}&&{author}&&{description}&&{int(datetime.now(tz=UTC8).timestamp() * 1000)}.json",
- json.dumps(temp, ensure_ascii=False),
- "application/json",
- )
- }
- data = {"username": author, "description": description}
-
- async with httpx.AsyncClient(
- proxy=Config.proxy, follow_redirects=True
- ) as client:
- try:
- response = await client.post(
- "https://share.auto-mas.top/api/upload/share",
- files=files,
- data=data,
- )
-
- if response.status_code == 200:
- logger.success("配置上传成功")
- else:
- logger.error(f"无法上传配置到 AUTO-MAS 服务器: {response.text}")
- raise ConnectionError(
- f"无法上传配置到 AUTO-MAS 服务器: {response.status_code} - {response.text}"
- )
- except httpx.RequestError as e:
- logger.error(f"无法上传配置到 AUTO-MAS 服务器: {e}")
- raise ConnectionError(f"无法上传配置到 AUTO-MAS 服务器: {e}")
-
- async def remove_privacy_info(self, confg: dict, name: str) -> dict:
- """移除配置中可能存在的隐私信息"""
-
- confg["Info"]["Name"] = name
- for path in ["ScriptPath", "ConfigPath", "LogPath", "TrackProcessExe"]:
- if Path(confg["Script"][path]).is_relative_to(
- Path(confg["Info"]["RootPath"])
- ):
- confg["Script"][path] = str(
- Path(r"C:/脚本根目录")
- / Path(confg["Script"][path]).relative_to(
- Path(confg["Info"]["RootPath"])
- )
- )
- if sys.platform == "win32" and Path(confg["Script"][path]).is_relative_to(
- Path(os.environ["APPDATA"])
- ):
- confg["Script"][
- path
- ] = f"%APPDATA%/{Path(confg["Script"][path]).relative_to(Path(os.environ["APPDATA"]))}"
- confg["Info"]["RootPath"] = str(Path(r"C:/脚本根目录"))
-
- return confg
-
- async def get_user(
- self, script_id: str, user_id: Optional[str]
- ) -> tuple[list, dict]:
- """获取用户配置"""
-
- logger.info(f"获取用户配置: {script_id} - {user_id}")
-
- uid = uuid.UUID(script_id)
-
- if user_id is None:
- # 获取全部用户配置
- data = await self.ScriptConfig[uid].UserData.toDict()
- else:
- # 获取指定用户配置
- data = await self.ScriptConfig[uid].UserData.get(uuid.UUID(user_id))
-
- index = data.pop("instances", [])
- return list(index), data
-
- async def add_user(
- self, script_id: str
- ) -> tuple[
- uuid.UUID, MaaUserConfig | SrcUserConfig | GeneralUserConfig | MaaEndUserConfig
- ]:
- """添加用户配置"""
-
- logger.info(f"{script_id} 添加用户配置")
-
- script_config = self.ScriptConfig[uuid.UUID(script_id)]
-
- # 根据脚本类型选择添加对应用户配置
- if isinstance(script_config, MaaConfig):
- uid, config = await script_config.UserData.add(MaaUserConfig)
- elif isinstance(script_config, SrcConfig):
- uid, config = await script_config.UserData.add(SrcUserConfig)
- elif isinstance(script_config, GeneralConfig):
- uid, config = await script_config.UserData.add(GeneralUserConfig)
- elif isinstance(script_config, MaaEndConfig):
- uid, config = await script_config.UserData.add(MaaEndUserConfig)
- else:
- raise TypeError(f"不支持的脚本配置类型: {type(script_config)}")
-
- return uid, config
-
- async def update_user(
- self, script_id: str, user_id: str, data: Dict[str, Dict[str, Any]]
- ) -> None:
- """更新用户配置"""
-
- logger.info(f"{script_id} 更新用户配置: {user_id}")
-
- script_uid = uuid.UUID(script_id)
- user_uid = uuid.UUID(user_id)
-
- for group, items in data.items():
- for name, value in items.items():
- await (
- self.ScriptConfig[script_uid]
- .UserData[user_uid]
- .set(group, name, value)
- )
-
- async def del_user(self, script_id: str, user_id: str) -> None:
- """删除用户配置"""
-
- logger.info(f"{script_id} 删除用户配置: {user_id}")
-
- script_uid = uuid.UUID(script_id)
- user_uid = uuid.UUID(user_id)
-
- await self.ScriptConfig[script_uid].UserData.remove(user_uid)
- if (Path.cwd() / f"data/{script_id}/{user_id}").exists():
- shutil.rmtree(Path.cwd() / f"data/{script_id}/{user_id}")
-
- async def reorder_user(self, script_id: str, index_list: list[str]) -> None:
- """重新排序用户"""
-
- logger.info(f"{script_id} 重新排序用户: {index_list}")
-
- script_uid = uuid.UUID(script_id)
-
- await self.ScriptConfig[script_uid].UserData.setOrder(
- list(map(uuid.UUID, index_list))
- )
-
- async def set_infrastructure(
- self, script_id: str, user_id: str, jsonFile: str
- ) -> None:
- logger.info(f"{script_id} - {user_id} 设置基建配置: {jsonFile}")
-
- script_uid = uuid.UUID(script_id)
- user_uid = uuid.UUID(user_id)
- json_path = Path(jsonFile)
-
- if not json_path.exists():
- raise FileNotFoundError(f"文件未找到: {json_path}")
-
- if not isinstance(self.ScriptConfig[script_uid], MaaConfig):
- raise TypeError(f"脚本 {script_id} 不是 MAA 脚本, 无法设置基建配置")
-
- infrast_data = json.loads(json_path.read_text(encoding="utf-8"))
-
- if len(infrast_data.get("plans", [])) == 0:
- raise ValueError("未找到有效的基建排班信息")
-
- # 如果标题为默认标题, 则使用文件名作为标题
- if infrast_data.get("title", "文件标题") == "文件标题":
- infrast_data["title"] = json_path.stem
-
- await self.ScriptConfig[script_uid].UserData[user_uid].set(
- "Data", "CustomInfrast", json.dumps(infrast_data, ensure_ascii=False)
- )
-
- async def get_user_combox_infrastructure(
- self, script_id: str, user_id: str
- ) -> list[dict]:
- logger.info(f"获取用户自定义基建排班下拉框信息: {script_id} - {user_id}")
-
- script_uid = uuid.UUID(script_id)
- user_uid = uuid.UUID(user_id)
-
- script_config = self.ScriptConfig[script_uid]
-
- # 根据脚本类型选择添加对应用户配置
- if not isinstance(script_config, MaaConfig):
- raise TypeError(f"不支持的脚本配置类型: {type(script_config)}")
-
- logger.info("开始获取用户自定义基建排班下拉框信息")
-
- data = []
- for i, plan in enumerate(
- json.loads(
- script_config.UserData[user_uid].get("Data", "CustomInfrast")
- ).get("plans", [])
- ):
- data.append({"label": plan.get("name", f"排班 {i+1}"), "value": str(i)})
-
- logger.success("用户自定义基建排班下拉框信息获取成功")
-
- return data
-
- async def add_plan(
- self, script: Literal["MaaPlan"]
- ) -> tuple[uuid.UUID, MaaPlanConfig]:
- """添加计划表"""
-
- logger.info(f"添加计划表: {script}")
-
- return await self.PlanConfig.add(CLASS_BOOK[script])
-
- async def get_plan(self, plan_id: Optional[str]) -> tuple[list, dict]:
- """获取计划表配置"""
-
- logger.info(f"获取计划表配置: {plan_id}")
-
- if plan_id is None:
- data = await self.PlanConfig.toDict()
- else:
- data = await self.PlanConfig.get(uuid.UUID(plan_id))
-
- index = data.pop("instances", [])
- return list(index), data
-
- async def update_plan(self, plan_id: str, data: Dict[str, Dict[str, Any]]) -> None:
- """更新计划表配置"""
-
- logger.info(f"更新计划表配置: {plan_id}")
-
- plan_uid = uuid.UUID(plan_id)
-
- for group, items in data.items():
- for name, value in items.items():
- await self.PlanConfig[plan_uid].set(group, name, value)
-
- async def del_plan(self, plan_id: str) -> None:
- """删除计划表配置"""
-
- logger.info(f"删除计划表配置: {plan_id}")
-
- plan_uid = uuid.UUID(plan_id)
-
- user_list = []
-
- for script in self.ScriptConfig.values():
- if isinstance(script, MaaConfig):
- for user in script.UserData.values():
- if user.get("Info", "StageMode") == str(plan_uid):
- if user.is_locked:
- raise RuntimeError(
- f"用户 {user.get('Info','Name')} 正在使用此计划表且被锁定, 无法完成删除"
- )
- user_list.append(user)
-
- for user in user_list:
- await user.set("Info", "StageMode", "Fixed")
-
- await self.PlanConfig.remove(plan_uid)
-
- async def reorder_plan(self, index_list: list[str]) -> None:
- """重新排序计划表"""
-
- logger.info(f"重新排序计划表: {index_list}")
-
- await self.PlanConfig.setOrder(list(map(uuid.UUID, index_list)))
-
- async def get_emulator(self, emulator_id: Optional[str]) -> tuple[list, dict]:
- """获取模拟器配置"""
- logger.info(f"获取全局模拟器设置: {emulator_id}")
-
- if emulator_id is None:
- data = await self.EmulatorConfig.toDict()
- else:
- data = await self.EmulatorConfig.get(uuid.UUID(emulator_id))
-
- index = data.pop("instances", [])
- return list(index), data
-
- async def add_emulator(self) -> tuple[uuid.UUID, EmulatorConfig]:
- """添加模拟器配置"""
- logger.info("添加全局模拟器配置")
-
- uid, config = await self.EmulatorConfig.add(EmulatorConfig)
- return uid, config
-
- async def update_emulator(
- self, emulator_id: str, data: Dict[str, Dict[str, Any]]
- ) -> None:
- """更新模拟器配置"""
-
- emulator_uid = uuid.UUID(emulator_id)
-
- logger.info(f"更新模拟器配置: {emulator_id}")
-
- for group, items in data.items():
- for name, value in items.items():
- await self.EmulatorConfig[emulator_uid].set(group, name, value)
-
- async def del_emulator(self, emulator_id: str) -> None:
- """删除模拟器配置"""
-
- emulator_uid = uuid.UUID(emulator_id)
-
- logger.info(f"删除全局模拟器配置: {emulator_id}")
-
- script_list = []
-
- for script in self.ScriptConfig.values():
- if isinstance(script, MaaConfig):
- if script.get("Emulator", "Id") == str(emulator_id):
- if script.is_locked:
- raise RuntimeError(
- f"脚本 {script.get('Info','Name')} 正在使用此模拟器且被锁定, 无法完成删除"
- )
- script_list.append(script)
- elif isinstance(script, GeneralConfig):
- if script.get("Game", "Type") == "Emulator" and script.get(
- "Game", "EmulatorId"
- ) == str(emulator_id):
- if script.is_locked:
- raise RuntimeError(
- f"脚本 {script.get('Info','Name')} 正在使用此模拟器且被锁定, 无法完成删除"
- )
- script_list.append(script)
-
- for script in script_list:
- if isinstance(script, MaaConfig):
- await script.set("Emulator", "Id", "-")
- elif isinstance(script, GeneralConfig):
- await script.set("Game", "EmulatorId", "-")
-
- await self.EmulatorConfig.remove(emulator_uid)
-
- async def reorder_emulator(self, index_list: list[str]) -> None:
- """重新排序模拟器"""
-
- logger.info(f"重新排序模拟器: {index_list}")
-
- await self.EmulatorConfig.setOrder(list(map(uuid.UUID, index_list)))
-
- async def add_queue(self) -> tuple[uuid.UUID, QueueConfig]:
- """添加调度队列"""
-
- logger.info("添加调度队列")
-
- return await self.QueueConfig.add(QueueConfig)
-
- async def get_queue(self, queue_id: Optional[str]) -> tuple[list, dict]:
- """获取调度队列配置"""
-
- logger.info(f"获取调度队列配置: {queue_id}")
-
- if queue_id is None:
- data = await self.QueueConfig.toDict()
- else:
- data = await self.QueueConfig.get(uuid.UUID(queue_id))
-
- index = data.pop("instances", [])
- return list(index), data
-
- async def update_queue(
- self, queue_id: str, data: Dict[str, Dict[str, Any]]
- ) -> None:
- """更新调度队列配置"""
-
- logger.info(f"更新调度队列配置: {queue_id}")
-
- queue_uid = uuid.UUID(queue_id)
-
- for group, items in data.items():
- for name, value in items.items():
- await self.QueueConfig[queue_uid].set(group, name, value)
-
- async def del_queue(self, queue_id: str) -> None:
- """删除调度队列配置"""
-
- logger.info(f"删除调度队列配置: {queue_id}")
-
- await self.QueueConfig.remove(uuid.UUID(queue_id))
-
- async def reorder_queue(self, index_list: list[str]) -> None:
- """重新排序调度队列"""
-
- logger.info(f"重新排序调度队列: {index_list}")
-
- await self.QueueConfig.setOrder(list(map(uuid.UUID, index_list)))
-
- async def get_time_set(
- self, queue_id: str, time_set_id: Optional[str]
- ) -> tuple[list, dict]:
- """获取时间设置配置"""
-
- logger.info(f"获取队列的时间配置: {queue_id} - {time_set_id}")
-
- queue_uid = uuid.UUID(queue_id)
-
- if time_set_id is None:
- data = await self.QueueConfig[queue_uid].TimeSet.toDict()
- else:
- data = await self.QueueConfig[queue_uid].TimeSet.get(uuid.UUID(time_set_id))
-
- index = data.pop("instances", [])
- return list(index), data
-
- async def add_time_set(self, queue_id: str) -> tuple[uuid.UUID, TimeSet]:
- """添加时间设置配置"""
-
- logger.info(f"{queue_id} 添加时间设置配置")
-
- queue_uid = uuid.UUID(queue_id)
- uid, config = await self.QueueConfig[queue_uid].TimeSet.add(TimeSet)
-
- return uid, config
-
- async def update_time_set(
- self, queue_id: str, time_set_id: str, data: Dict[str, Dict[str, Any]]
- ) -> None:
- """更新时间设置配置"""
-
- logger.info(f"{queue_id} 更新时间设置配置: {time_set_id}")
-
- queue_uid = uuid.UUID(queue_id)
- time_set_uid = uuid.UUID(time_set_id)
-
- for group, items in data.items():
- for name, value in items.items():
- await (
- self.QueueConfig[queue_uid]
- .TimeSet[time_set_uid]
- .set(group, name, value)
- )
-
- async def del_time_set(self, queue_id: str, time_set_id: str) -> None:
- """删除时间设置配置"""
-
- logger.info(f"{queue_id} 删除时间设置配置: {time_set_id}")
-
- queue_uid = uuid.UUID(queue_id)
- time_set_uid = uuid.UUID(time_set_id)
-
- await self.QueueConfig[queue_uid].TimeSet.remove(time_set_uid)
-
- async def reorder_time_set(self, queue_id: str, index_list: list[str]) -> None:
- """重新排序时间设置"""
-
- logger.info(f"{queue_id} 重新排序时间设置: {index_list}")
-
- queue_uid = uuid.UUID(queue_id)
-
- await self.QueueConfig[queue_uid].TimeSet.setOrder(
- list(map(uuid.UUID, index_list))
- )
-
- async def get_queue_item(
- self, queue_id: str, queue_item_id: Optional[str]
- ) -> tuple[list, dict]:
- """获取队列项配置"""
-
- logger.info(f"获取队列的队列项配置: {queue_id} - {queue_item_id}")
-
- queue_uid = uuid.UUID(queue_id)
-
- if queue_item_id is None:
- data = await self.QueueConfig[queue_uid].QueueItem.toDict()
- else:
- data = await self.QueueConfig[queue_uid].QueueItem.get(
- uuid.UUID(queue_item_id)
- )
-
- index = data.pop("instances", [])
- return list(index), data
-
- async def add_queue_item(self, queue_id: str) -> tuple[uuid.UUID, QueueItem]:
- """添加队列项配置"""
-
- logger.info(f"{queue_id} 添加队列项配置")
-
- queue_uid = uuid.UUID(queue_id)
-
- uid, config = await self.QueueConfig[queue_uid].QueueItem.add(QueueItem)
-
- return uid, config
-
- async def update_queue_item(
- self, queue_id: str, queue_item_id: str, data: Dict[str, Dict[str, Any]]
- ) -> None:
- """更新队列项配置"""
-
- logger.info(f"{queue_id} 更新队列项配置: {queue_item_id}")
-
- queue_uid = uuid.UUID(queue_id)
- queue_item_uid = uuid.UUID(queue_item_id)
-
- for group, items in data.items():
- for name, value in items.items():
- await (
- self.QueueConfig[queue_uid]
- .QueueItem[queue_item_uid]
- .set(group, name, value)
- )
-
- async def del_queue_item(self, queue_id: str, queue_item_id: str) -> None:
- """删除队列项配置"""
-
- logger.info(f"{queue_id} 删除队列项配置: {queue_item_id}")
-
- queue_uid = uuid.UUID(queue_id)
- queue_item_uid = uuid.UUID(queue_item_id)
-
- await self.QueueConfig[queue_uid].QueueItem.remove(queue_item_uid)
-
- async def reorder_queue_item(self, queue_id: str, index_list: list[str]) -> None:
- """重新排序队列项"""
-
- logger.info(f"{queue_id} 重新排序队列项: {index_list}")
-
- queue_uid = uuid.UUID(queue_id)
-
- await self.QueueConfig[queue_uid].QueueItem.setOrder(
- list(map(uuid.UUID, index_list))
- )
-
- async def get_tools(self) -> Dict[str, Any]:
- """获取工具设置"""
-
- logger.debug("获取工具设置")
-
- return await self.ToolsConfig.toDict()
-
- async def update_tools(self, data: Dict[str, Dict[str, Any]]) -> None:
- """更新工具设置"""
-
- logger.info("更新工具设置")
-
- for group, items in data.items():
- for name, value in items.items():
- await self.ToolsConfig.set(group, name, value)
-
- logger.success("工具设置更新成功")
-
- async def get_setting(self) -> Dict[str, Any]:
- """获取全局设置"""
-
- logger.info("获取全局设置")
-
- return await self.toDict()
-
- async def update_setting(self, data: Dict[str, Dict[str, Any]]) -> None:
- """更新全局设置"""
-
- logger.info("更新全局设置")
-
- for group, items in data.items():
- for name, value in items.items():
- await self.set(group, name, value)
-
- logger.success("全局设置更新成功")
-
- async def get_webhook(
- self,
- script_id: Optional[str],
- user_id: Optional[str],
- webhook_id: Optional[str],
- ) -> tuple[list, dict]:
- """获取webhook配置"""
-
- if script_id is None and user_id is None:
- logger.info(f"获取全局webhook设置: {webhook_id}")
-
- if webhook_id is None:
- data = await self.Notify_CustomWebhooks.toDict()
- else:
- data = await self.Notify_CustomWebhooks.get(uuid.UUID(webhook_id))
-
- else:
- logger.info(f"获取webhook设置: {script_id} - {user_id} - {webhook_id}")
-
- script_uid = uuid.UUID(script_id)
- user_uid = uuid.UUID(user_id)
-
- if webhook_id is None:
- data = (
- await self.ScriptConfig[script_uid]
- .UserData[user_uid]
- .Notify_CustomWebhooks.toDict()
- )
- else:
- data = (
- await self.ScriptConfig[script_uid]
- .UserData[user_uid]
- .Notify_CustomWebhooks.get(uuid.UUID(webhook_id))
- )
-
- index = data.pop("instances", [])
- return list(index), data
-
- async def add_webhook(
- self, script_id: Optional[str], user_id: Optional[str]
- ) -> tuple[uuid.UUID, Webhook]:
- """添加webhook配置"""
-
- if script_id is None and user_id is None:
- logger.info("添加全局webhook配置")
-
- uid, config = await self.Notify_CustomWebhooks.add(Webhook)
- return uid, config
-
- else:
- logger.info(f"添加webhook配置: {script_id} - {user_id}")
-
- script_uid = uuid.UUID(script_id)
- user_uid = uuid.UUID(user_id)
-
- uid, config = (
- await self.ScriptConfig[script_uid]
- .UserData[user_uid]
- .Notify_CustomWebhooks.add(Webhook)
- )
- return uid, config
-
- async def update_webhook(
- self,
- script_id: Optional[str],
- user_id: Optional[str],
- webhook_id: str,
- data: Dict[str, Dict[str, Any]],
- ) -> None:
- """更新 webhook 配置"""
-
- webhook_uid = uuid.UUID(webhook_id)
-
- if script_id is None and user_id is None:
- logger.info(f"更新 webhook 全局配置: {webhook_id}")
-
- for group, items in data.items():
- for name, value in items.items():
- await self.Notify_CustomWebhooks[webhook_uid].set(
- group, name, value
- )
-
- else:
- logger.info(f"更新 webhook 配置: {script_id} - {user_id} - {webhook_id}")
-
- script_uid = uuid.UUID(script_id)
- user_uid = uuid.UUID(user_id)
-
- for group, items in data.items():
- for name, value in items.items():
- await (
- self.ScriptConfig[script_uid]
- .UserData[user_uid]
- .Notify_CustomWebhooks[webhook_uid]
- .set(group, name, value)
- )
-
- async def del_webhook(
- self, script_id: Optional[str], user_id: Optional[str], webhook_id: str
- ) -> None:
- """删除 webhook 配置"""
-
- webhook_uid = uuid.UUID(webhook_id)
-
- if script_id is None and user_id is None:
- logger.info(f"删除全局 webhook 配置: {webhook_id}")
-
- await self.Notify_CustomWebhooks.remove(webhook_uid)
-
- else:
- logger.info(f"删除 webhook 配置: {script_id} - {user_id} - {webhook_id}")
-
- script_uid = uuid.UUID(script_id)
- user_uid = uuid.UUID(user_id)
-
- await (
- self.ScriptConfig[script_uid]
- .UserData[user_uid]
- .Notify_CustomWebhooks.remove(webhook_uid)
- )
-
- async def reorder_webhook(
- self, script_id: Optional[str], user_id: Optional[str], index_list: list[str]
- ) -> None:
- """重新排序 webhook"""
-
- if script_id is None and user_id is None:
- logger.info(f"重新排序全局 webhook: {index_list}")
-
- await self.Notify_CustomWebhooks.setOrder(list(map(uuid.UUID, index_list)))
-
- else:
- logger.info(f"重新排序 webhook: {script_id} - {user_id} - {index_list}")
-
- script_uid = uuid.UUID(script_id)
- user_uid = uuid.UUID(user_id)
-
- await (
- self.ScriptConfig[script_uid]
- .UserData[user_uid]
- .Notify_CustomWebhooks.setOrder(list(map(uuid.UUID, index_list)))
- )
-
- @property
- def proxy(self) -> Optional[httpx.Proxy]:
- """获取代理设置,返回适用于 httpx 的代理对象"""
- proxy_addr = self.get("Update", "ProxyAddress")
- if not proxy_addr:
- return None
-
- # 如果地址不包含协议,默认为 http
- if not proxy_addr.startswith(("http://", "https://", "socks5://", "socks4://")):
- proxy_addr = f"http://{proxy_addr}"
-
- try:
- logger.info(f"使用代理: {proxy_addr}")
- return httpx.Proxy(proxy_addr)
- except Exception as e:
- logger.warning(f"代理配置无效: {proxy_addr}, 错误: {e}")
- return None
-
- async def get_stage_info(
- self,
- type: Literal[
- "User",
- "Today",
- "ALL",
- "Monday",
- "Tuesday",
- "Wednesday",
- "Thursday",
- "Friday",
- "Saturday",
- "Sunday",
- "Info",
- ],
- ):
- """获取关卡信息"""
-
- if json.loads(self.get("Data", "Stage")) != {}:
- task = asyncio.create_task(self.get_stage())
- self.temp_task.append(task)
- task.add_done_callback(lambda t: self.temp_task.remove(t))
- else:
- await self.get_stage()
-
- if type == "Info":
- today = datetime.now(tz=UTC4).isoweekday()
- res_stage_info = []
- for stage in RESOURCE_STAGE_INFO:
- if (
- today in stage["days"]
- and stage["value"] in RESOURCE_STAGE_DROP_INFO
- ):
- res_stage_info.append(RESOURCE_STAGE_DROP_INFO[stage["value"]])
- return {
- "Activity": json.loads(self.get("Data", "Stage")).get("Info", []),
- "Resource": res_stage_info,
- }
- elif type == "User":
- data = json.loads(self.get("Data", "Stage")).get("ALL", [])
- for combox in data:
- combox["label"] = RESOURCE_STAGE_DATE_TEXT.get(
- combox["value"], combox["label"]
- )
- return data
- elif type == "Today":
- return json.loads(self.get("Data", "Stage")).get(
- datetime.now(tz=UTC4).strftime("%A"), []
- )
- else:
- return json.loads(self.get("Data", "Stage")).get(type, [])
-
- async def get_proxy_overview(self) -> Dict[str, Any]:
- """获取代理情况概览信息"""
-
- logger.info("获取代理情况概览信息")
-
- history_index = await self.search_history(
- "DAILY", datetime.now(tz=UTC4).date(), datetime.now(tz=UTC4).date()
- )
- if datetime.now(tz=UTC4).strftime("%Y-%m-%d") not in history_index:
- return {}
- history_data = {
- k: await self.merge_statistic_info(v)
- for k, v in history_index[
- datetime.now(tz=UTC4).strftime("%Y-%m-%d")
- ].items()
- }
- overview = {}
- for user, data in history_data.items():
- index_data = data.get("index", [])
- if index_data:
- last_proxy_date = max(
- datetime.strptime(_["date"], "%Y-%m-%d %H:%M:%S")
- for _ in index_data
- ).strftime("%Y-%m-%d %H:%M:%S")
- else:
- last_proxy_date = "暂无代理数据"
- proxy_times = len(data.get("index", []))
- error_info = data.get("error_info", {})
- error_times = len(error_info)
- overview[user] = {
- "LastProxyDate": last_proxy_date,
- "ProxyTimes": proxy_times,
- "ErrorTimes": error_times,
- "ErrorInfo": error_info,
- }
- return overview
-
- async def get_stage(self) -> Optional[Dict[str, List[Dict[str, str]]]]:
- """更新活动关卡信息"""
-
- if datetime.now() - timedelta(hours=1) < datetime.strptime(
- self.get("Data", "LastStageUpdated"), "%Y-%m-%d %H:%M:%S"
- ):
- logger.info("一小时内已进行过一次检查, 直接使用缓存的活动关卡信息")
- return json.loads(self.get("Data", "Stage"))
-
- logger.info("开始获取活动关卡信息")
- try:
- async with httpx.AsyncClient(
- proxy=self.proxy, follow_redirects=True
- ) as client:
- response = await client.get(
- "https://api.maa.plus/MaaAssistantArknights/api/gui/StageActivityV2.json",
- headers={"If-None-Match": self.get("Data", "StageETag")},
- )
-
- if response.status_code == 304:
- logger.info("关卡信息未更新,使用本地缓存的活动关卡信息")
- await self.set(
- "Data",
- "LastStageUpdated",
- datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
- )
- elif response.status_code == 200:
- logger.success("成功获取远端活动关卡信息")
- await self.set(
- "Data",
- "LastStageUpdated",
- datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
- )
- await self.set(
- "Data",
- "StageETag",
- response.headers.get("ETag")
- or response.headers.get("etag")
- or "",
- )
- await self.set(
- "Data",
- "StageData",
- json.dumps(
- response.json()
- .get("Official", {})
- .get("sideStoryStage", {}),
- ensure_ascii=False,
- ),
- )
- else:
- logger.warning(f"无法从MAA服务器获取活动关卡信息:{response.text}")
- except Exception as e:
- logger.warning(f"无法从MAA服务器获取活动关卡信息: {e}")
-
- return json.loads(self.get("Data", "Stage"))
-
- async def get_script_combox(self):
- """获取脚本下拉框信息"""
-
- logger.info("开始获取脚本下拉框信息")
- data = [{"label": "未选择", "value": "-"}]
- for uid, script in self.ScriptConfig.items():
- data.append(
- {
- "label": f"{TYPE_BOOK[type(script).__name__]} - {script.get('Info', 'Name')}",
- "value": str(uid),
- }
- )
- logger.success("脚本下拉框信息获取成功")
-
- return data
-
- async def get_task_combox(self):
- """获取任务下拉框信息"""
-
- logger.info("开始获取任务下拉框信息")
- data = [{"label": "未选择", "value": None}]
- for uid, queue in self.QueueConfig.items():
- data.append(
- {
- "label": f"队列 - {queue.get('Info', 'Name')}",
- "value": str(uid),
- }
- )
- for uid, script in self.ScriptConfig.items():
- if not script.is_locked:
- data.append(
- {
- "label": f"脚本 - {TYPE_BOOK[type(script).__name__]} - {script.get('Info', 'Name')}",
- "value": str(uid),
- }
- )
- logger.success("任务下拉框信息获取成功")
-
- return data
-
- async def get_plan_combox(self):
- """获取计划下拉框信息"""
-
- logger.info("开始获取计划下拉框信息")
- data = [{"label": "固定", "value": "Fixed"}]
- for uid, plan in self.PlanConfig.items():
- data.append({"label": plan.get("Info", "Name"), "value": str(uid)})
- logger.success("计划下拉框信息获取成功")
-
- return data
-
- async def get_emulator_combox(self):
- """获取模拟器下拉框信息"""
-
- logger.info("开始获取模拟器下拉框信息")
- data = [{"label": "未选择", "value": "-"}]
- for uid, emulator in self.EmulatorConfig.items():
- data.append({"label": emulator.get("Info", "Name"), "value": str(uid)})
- logger.success("模拟器下拉框信息获取成功")
- return data
-
- async def get_emulator_devices_combox(self, emulator_id: str):
- """获取模拟器多开实例下拉框信息"""
-
- logger.info("开始获取模拟器下拉框信息")
-
- if self.EmulatorConfig[uuid.UUID(emulator_id)].get("Info", "Type") == "general":
- logger.info("通用模拟器不支持扫描多开实例, 返回空列表")
- return []
-
- data = [{"label": "未选择", "value": "-"}]
-
- from .emulator_manager import EmulatorManager
-
- for index, device in (
- await (await EmulatorManager.get_emulator_instance(emulator_id)).getInfo(
- None
- )
- ).items():
- data.append({"label": device.title, "value": index})
-
- logger.success("模拟器下拉框信息获取成功")
-
- return data
-
- async def get_notice(self) -> tuple[bool, Dict[str, str]]:
- """获取公告信息"""
-
- if datetime.now() - timedelta(hours=1) < datetime.strptime(
- self.get("Data", "LastNoticeUpdated"), "%Y-%m-%d %H:%M:%S"
- ):
- logger.info("一小时内已进行过一次检查, 直接使用缓存的公告信息")
- return False, json.loads(self.get("Data", "Notice")).get("notice_dict", {})
-
- logger.info("开始从 AUTO-MAS 服务器获取公告信息")
- try:
- async with httpx.AsyncClient(
- proxy=self.proxy, follow_redirects=True
- ) as client:
- response = await client.get(
- "https://api.auto-mas.top/file/Server/notice.json",
- headers={"If-None-Match": self.get("Data", "NoticeETag")},
- )
- if response.status_code == 304:
- logger.info("公告未更新,使用本地缓存的公告信息")
- await self.set(
- "Data",
- "LastNoticeUpdated",
- datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
- )
- elif response.status_code == 200:
- logger.info("公告已更新,要求展示公告信息")
- await self.set(
- "Data",
- "LastNoticeUpdated",
- datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
- )
- await self.set(
- "Data",
- "NoticeETag",
- response.headers.get("ETag")
- or response.headers.get("etag")
- or "",
- )
- await self.set("Data", "IfShowNotice", True)
- await self.set(
- "Data",
- "Notice",
- json.dumps(response.json(), ensure_ascii=False),
- )
- else:
- logger.warning(
- f"无法从 AUTO-MAS 服务器获取公告信息:{response.text}"
- )
- except Exception as e:
- logger.warning(f"无法从 AUTO-MAS 服务器获取公告信息: {e}")
-
- return self.get("Data", "IfShowNotice"), json.loads(
- self.get("Data", "Notice")
- ).get("notice_dict", {})
-
- async def get_web_config(self):
- """获取「AUTO-MAS 配置分享中心」配置"""
-
- local_web_config = json.loads(self.get("Data", "WebConfig"))
- if datetime.now() - timedelta(hours=1) < datetime.strptime(
- self.get("Data", "LastWebConfigUpdated"), "%Y-%m-%d %H:%M:%S"
- ):
- logger.info("一小时内已进行过一次检查, 直接使用缓存的配置分享中心信息")
- return local_web_config
-
- logger.info("开始从 AUTO-MAS 服务器获取配置分享中心信息")
-
- try:
- async with httpx.AsyncClient(
- proxy=self.proxy, follow_redirects=True
- ) as client:
- response = await client.get(
- "https://share.auto-mas.top/api/list/config/general"
- )
- if response.status_code == 200:
- remote_web_config = response.json()
- else:
- logger.warning(
- f"无法从 AUTO-MAS 服务器获取配置分享中心信息:{response.text}"
- )
- remote_web_config = None
- except Exception as e:
- logger.warning(f"无法从 AUTO-MAS 服务器获取配置分享中心信息: {e}")
- remote_web_config = None
-
- if remote_web_config is None:
- logger.warning("使用本地配置分享中心信息")
- return local_web_config
-
- await self.set(
- "Data", "LastWebConfigUpdated", datetime.now().strftime("%Y-%m-%d %H:%M:%S")
- )
- await self.set(
- "Data", "WebConfig", json.dumps(remote_web_config, ensure_ascii=False)
- )
-
- return remote_web_config
-
- async def save_maa_log(self, log_path: Path, logs: list, maa_result: str) -> bool:
- """
- 保存MAA日志并生成对应统计数据
-
- Args:
- log_path (Path): 日志文件保存路径
- logs (list): 日志列表
- maa_result (str): MAA任务结果
- Returns:
- bool: 是否存在高资
- """
-
- logger.info(f"开始处理 MAA 日志, 日志长度: {len(logs)}, 日志标记: {maa_result}")
-
- data = {
- "recruit_statistics": defaultdict(int),
- "drop_statistics": defaultdict(dict),
- "sanity": 0,
- "sanity_full_at": "",
- "maa_result": maa_result,
- }
-
- if_six_star = False
-
- # 提取理智相关信息
- for log_line in logs:
- # 提取当前理智值:理智: 5/180
- sanity_match = re.search(r"理智:\s*(\d+)/\d+", log_line)
- if sanity_match:
- data["sanity"] = int(sanity_match.group(1))
-
- # 提取理智回满时间:理智将在 2025-09-26 18:57 回满。(17h 29m 后)
- sanity_full_match = re.search(
- r"(理智将在\s*\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s*回满。\(\d+h\s+\d+m\s+后\))",
- log_line,
- )
- if sanity_full_match:
- data["sanity_full_at"] = sanity_full_match.group(1)
-
- # 公招统计(仅统计招募到的)
- confirmed_recruit = False
- current_star_level = None
- i = 0
- while i < len(logs):
- if "公招识别结果:" in logs[i]:
- current_star_level = None # 每次识别公招时清空之前的星级
- i += 1
- while i < len(logs) and "Tags" not in logs[i]: # 读取所有公招标签
- i += 1
-
- if i < len(logs) and "Tags" in logs[i]: # 识别星级
- star_match = re.search(r"(\d+)\s*★ Tags", logs[i])
- if star_match:
- current_star_level = f"{star_match.group(1)}★"
- if current_star_level == "6★":
- if_six_star = True
-
- if "已确认招募" in logs[i]: # 只有确认招募后才统计
- confirmed_recruit = True
-
- if confirmed_recruit and current_star_level:
- data["recruit_statistics"][current_star_level] += 1
- confirmed_recruit = False # 重置, 等待下一次公招
- current_star_level = None # 清空已处理的星级
-
- i += 1
-
- # 掉落统计
- # 存储所有关卡的掉落统计
- all_stage_drops = {}
-
- # 查找所有Fight任务的开始和结束位置
- fight_tasks = []
- for i, line in enumerate(logs):
- if "开始任务: Fight" in line or "开始任务: 理智作战" in line:
- # 查找对应的任务结束位置
- end_index = -1
- for j in range(i + 1, len(logs)):
- if "完成任务: Fight" in logs[j] or "完成任务: 理智作战" in logs[j]:
- end_index = j
- break
- # 如果遇到新的Fight任务开始, 则当前任务没有正常结束
- if j < len(logs) and (
- "开始任务: Fight" in logs[j] or "开始任务: 理智作战" in logs[j]
- ):
- break
-
- # 如果找到了结束位置, 记录这个任务的范围
- if end_index != -1:
- fight_tasks.append((i, end_index))
-
- # 处理每个Fight任务
- for start_idx, end_idx in fight_tasks:
- # 提取当前任务的日志
- task_logs = logs[start_idx : end_idx + 1]
-
- # 查找任务中的最后一次掉落统计
- last_drop_stats = {}
- current_stage = None
-
- for line in task_logs:
- # 匹配掉落统计行, 如"1-7 掉落统计:"
- drop_match = re.search(r"([\u4e00-\u9fffA-Za-z0-9\-]+) 掉落统计:", line)
- if drop_match:
- # 发现新的掉落统计, 重置当前关卡的掉落数据
- current_stage = drop_match.group(1)
- last_drop_stats = {}
- continue
-
- # 如果已经找到了关卡, 处理掉落物
- if current_stage:
- item_match: List[str] = re.findall(
- r"^(?!\[)(\S+?)\s*:\s*([\d,]+[kK]?)(?:\s*\(\+[\d,]+[kK]?\))?",
- line,
- re.M,
- )
- for item, total in item_match:
- total = total.replace(",", "")
- if total.lower().endswith("k"):
- total = int(total[:-1]) * 1000
- else:
- total = int(total)
-
- # 黑名单
- if item not in [
- "当前次数",
- "理智",
- "最快截图耗时",
- "专精等级",
- "剩余时间",
- ]:
- last_drop_stats[item] = total
-
- # 如果任务中有掉落统计, 更新总统计
- if current_stage and last_drop_stats:
- if current_stage not in all_stage_drops:
- all_stage_drops[current_stage] = {}
-
- # 累加掉落数据
- for item, count in last_drop_stats.items():
- all_stage_drops[current_stage].setdefault(item, 0)
- all_stage_drops[current_stage][item] += count
-
- # 将累加后的掉落数据保存到结果中
- data["drop_statistics"] = all_stage_drops
-
- # 保存日志
- log_path.parent.mkdir(parents=True, exist_ok=True)
- log_path.write_text("".join(logs), encoding="utf-8")
- # 保存统计数据
- log_path.with_suffix(".json").write_text(
- json.dumps(data, ensure_ascii=False, indent=4), encoding="utf-8"
- )
-
- logger.success(f"MAA 日志统计完成, 日志路径: {log_path}")
-
- return if_six_star
-
- async def save_maaend_log(
- self, log_path: Path, logs: list[str], maaend_result: str
- ) -> None:
- """
- Save MaaEnd logs and generate basic statistics data.
-
- Args:
- log_path (Path): Target log file path.
- logs (list[str]): Log lines.
- maaend_result (str): Result label for this run.
- """
-
- logger.info(
- f"开始处理MaaEnd日志, 日志长度: {len(logs)}, 日志标记: {maaend_result}"
- )
-
- data: Dict[str, str] = {"maaend_result": maaend_result}
-
- # 保存日志
- log_path.parent.mkdir(parents=True, exist_ok=True)
- log_path.with_suffix(".log").write_text("".join(logs), encoding="utf-8")
- log_path.with_suffix(".json").write_text(
- json.dumps(data, ensure_ascii=False, indent=4), encoding="utf-8"
- )
-
- logger.success(f"MaaEnd日志统计完成, 日志路径: {log_path.with_suffix('.log')}")
-
- async def save_src_log(self, log_path: Path, logs: list, src_result: str) -> None:
- """
- 保存SRC日志并生成对应统计数据
-
- Args:
- log_path (Path): 日志文件保存路径
- logs (list): 日志内容列表
- src_result (str): 待保存的日志结果信息
- """
-
- logger.info(f"开始处理SRC日志, 日志长度: {len(logs)}, 日志标记: {src_result}")
-
- data: Dict[str, str] = {"src_result": src_result}
-
- # 保存日志
- log_path.parent.mkdir(parents=True, exist_ok=True)
- log_path.with_suffix(".log").write_text("".join(logs), encoding="utf-8")
- log_path.with_suffix(".json").write_text(
- json.dumps(data, ensure_ascii=False, indent=4), encoding="utf-8"
- )
-
- logger.success(f"SRC日志统计完成, 日志路径: {log_path.with_suffix('.log')}")
-
- async def save_general_log(
- self, log_path: Path, logs: list, general_result: str
- ) -> None:
- """
- 保存通用日志并生成对应统计数据
-
- :param log_path: 日志文件保存路径
- :param logs: 日志内容列表
- :param general_result: 待保存的日志结果信息
- """
-
- logger.info(
- f"开始处理通用日志, 日志长度: {len(logs)}, 日志标记: {general_result}"
- )
-
- data: Dict[str, str] = {"general_result": general_result}
-
- # 保存日志
- log_path.parent.mkdir(parents=True, exist_ok=True)
- log_path.with_suffix(".log").write_text("".join(logs), encoding="utf-8")
- log_path.with_suffix(".json").write_text(
- json.dumps(data, ensure_ascii=False, indent=4), encoding="utf-8"
- )
-
- logger.success(f"通用日志统计完成, 日志路径: {log_path.with_suffix('.log')}")
-
- async def merge_statistic_info(self, statistic_path_list: List[Path]) -> dict:
- """
- 合并指定数据统计信息文件
-
- Args:
- statistic_path_list (List[Path]): 数据统计信息文件列表
-
- Returns:
- dict: 合并后的数据统计信息
- """
-
- data: Dict[str, Any] = {"index": {}}
-
- for json_file in statistic_path_list:
- try:
- single_data = json.loads(json_file.read_text(encoding="utf-8"))
- except Exception as e:
- logger.warning(
- f"无法解析文件 {json_file}, 错误信息: {type(e).__name__}: {str(e)}"
- )
- continue
-
- for key in single_data.keys():
- if key not in data:
- data[key] = {}
-
- # 合并公招统计
- if key == "recruit_statistics":
- for star_level, count in single_data[key].items():
- if star_level not in data[key]:
- data[key][star_level] = 0
- data[key][star_level] += count
-
- # 合并掉落统计
- elif key == "drop_statistics":
- for stage, drops in single_data[key].items():
- if stage not in data[key]:
- data[key][stage] = {} # 初始化关卡
-
- for item, count in drops.items():
- if item not in data[key][stage]:
- data[key][stage][item] = 0
- data[key][stage][item] += count
-
- # 处理理智相关字段 - 使用最后一个文件的值
- elif key in ["sanity", "sanity_full_at"]:
- data[key] = single_data[key]
-
- # 录入运行结果
- elif key in [
- "maa_result",
- "maaend_result",
- "src_result",
- "general_result",
- ]:
- actual_date = (
- datetime.strptime(
- f"{json_file.parent.parent.name} {json_file.stem}",
- "%Y-%m-%d %H-%M-%S",
- )
- .replace(tzinfo=UTC4)
- .astimezone()
- )
-
- if single_data[key] != "Success!":
- if "error_info" not in data:
- data["error_info"] = {}
- data["error_info"][
- actual_date.strftime("%Y-%m-%d %H:%M:%S")
- ] = single_data[key]
-
- data["index"][actual_date] = {
- "date": actual_date.strftime("%Y-%m-%d %H:%M:%S"),
- "status": (
- "DONE" if single_data[key] == "Success!" else "ERROR"
- ),
- "jsonFile": str(json_file),
- }
-
- data["index"] = [data["index"][_] for _ in sorted(data["index"])]
-
- # 确保返回的字典始终包含 index 字段,即使为空
- result = {k: v for k, v in data.items() if v}
- if "index" not in result:
- result["index"] = []
-
- return result
-
- async def search_history(
- self,
- mode: Literal["DAILY", "WEEKLY", "MONTHLY"],
- start_date: date,
- end_date: date,
- ) -> dict:
- """
- 搜索指定时间范围内的历史记录
-
- Args:
- mode (Literal["DAILY", "WEEKLY", "MONTHLY"]): 合并模式
- start_date (date): 开始日期
- end_date (date): 结束日期
- """
-
- logger.info(
- f"开始搜索历史记录, 合并模式: {mode}, 日期范围: {start_date} 至 {end_date}"
- )
-
- history_dict = {}
-
- for date_folder in self.history_path.iterdir():
- if not date_folder.is_dir():
- continue # 只处理日期文件夹
-
- try:
- date = datetime.strptime(date_folder.name, "%Y-%m-%d").date()
-
- if not (start_date <= date <= end_date):
- continue # 只统计在范围内的日期
-
- if mode == "DAILY":
- date_name = date.strftime("%Y-%m-%d")
- elif mode == "WEEKLY":
- date_name = date.strftime("%G-W%V")
- elif mode == "MONTHLY":
- date_name = date.strftime("%Y-%m")
- else:
- raise ValueError("无效的合并模式")
-
- if date_name not in history_dict:
- history_dict[date_name] = {}
-
- for user_folder in date_folder.iterdir():
- if not user_folder.is_dir():
- continue # 只处理用户文件夹
-
- if user_folder.stem not in history_dict[date_name]:
- history_dict[date_name][user_folder.stem] = list(
- user_folder.with_suffix("").glob("*.json")
- )
- else:
- history_dict[date_name][user_folder.stem] += list(
- user_folder.with_suffix("").glob("*.json")
- )
-
- except ValueError:
- logger.exception(f"非日期格式的目录: {date_folder}")
-
- logger.success(f"历史记录搜索完成, 共计 {len(history_dict)} 条记录")
-
- return {
- k: v
- for k, v in sorted(history_dict.items(), key=lambda x: x[0], reverse=True)
- }
-
- async def clean_old_history(self):
- """删除超过用户设定天数的历史记录文件(基于目录日期)"""
-
- if self.get("Function", "HistoryRetentionTime") == 0:
- logger.info("历史记录永久保留, 跳过历史记录清理")
- return
-
- logger.info("开始清理超过设定天数的历史记录")
-
- deleted_count = 0
-
- for date_folder in self.history_path.iterdir():
- if not date_folder.is_dir():
- continue # 只处理日期文件夹
-
- try:
- # 只检查 `YYYY-MM-DD` 格式的文件夹
- folder_date = datetime.strptime(date_folder.name, "%Y-%m-%d").date()
- if datetime.now(tz=UTC4).date() - folder_date > timedelta(
- days=self.get("Function", "HistoryRetentionTime")
- ):
- shutil.rmtree(date_folder, ignore_errors=True)
- deleted_count += 1
- logger.debug(f"已删除超期日志目录: {date_folder}")
- except ValueError:
- logger.warning(f"非日期格式的目录: {date_folder}")
-
- logger.success(f"清理完成: {deleted_count} 个日期目录")
-
-
-Config = AppConfig()
diff --git a/app/core/config/__init__.py b/app/core/config/__init__.py
new file mode 100644
index 00000000..8a50ac8f
--- /dev/null
+++ b/app/core/config/__init__.py
@@ -0,0 +1,69 @@
+from .base import (
+ MultipleConfig,
+ MultipleConfigAddEvent,
+ MultipleConfigDeleteEvent,
+ MultipleConfigReorderEvent,
+ dump_toml,
+)
+from .fields import RefField, VirtualField
+from .manager import AppConfig, Config
+from .pydantic import PydanticConfigBase, PluginConfigBase
+from .shortcuts import (
+ config,
+ encrypted,
+ ref,
+ relates_to,
+ singleton,
+ sub_configs,
+ virtual,
+)
+from .types import (
+ DayCount,
+ EncryptedString,
+ HHMMString,
+ JsonDictString,
+ JsonListString,
+ KeyboardKeyString,
+ NonNegativeInt,
+ PositiveInt,
+ UrlString,
+ YmdHmString,
+ YmdHmsString,
+ YmdString,
+ decrypt_encrypted_string,
+)
+
+
+__all__ = [
+ "MultipleConfig",
+ "MultipleConfigAddEvent",
+ "MultipleConfigDeleteEvent",
+ "MultipleConfigReorderEvent",
+ "dump_toml",
+ "RefField",
+ "VirtualField",
+ "PydanticConfigBase",
+ "PluginConfigBase",
+ "ref",
+ "virtual",
+ "encrypted",
+ "singleton",
+ "sub_configs",
+ "relates_to",
+ "config",
+ "JsonDictString",
+ "JsonListString",
+ "HHMMString",
+ "YmdHmString",
+ "YmdString",
+ "YmdHmsString",
+ "UrlString",
+ "KeyboardKeyString",
+ "EncryptedString",
+ "NonNegativeInt",
+ "PositiveInt",
+ "DayCount",
+ "decrypt_encrypted_string",
+ "AppConfig",
+ "Config",
+]
diff --git a/app/core/config/__init__.pyi b/app/core/config/__init__.pyi
new file mode 100644
index 00000000..6c174a6a
--- /dev/null
+++ b/app/core/config/__init__.pyi
@@ -0,0 +1,39 @@
+from .base import (
+ MultipleConfig as MultipleConfig,
+ MultipleConfigAddEvent as MultipleConfigAddEvent,
+ MultipleConfigDeleteEvent as MultipleConfigDeleteEvent,
+ MultipleConfigReorderEvent as MultipleConfigReorderEvent,
+ dump_toml as dump_toml,
+)
+from .fields import RefField as RefField, VirtualField as VirtualField
+from .manager import AppConfig as AppConfig, Config as Config
+from .pydantic import (
+ PluginConfigBase as PluginConfigBase,
+ PydanticConfigBase as PydanticConfigBase,
+)
+from .shortcuts import (
+ config as config,
+ encrypted as encrypted,
+ ref as ref,
+ relates_to as relates_to,
+ singleton as singleton,
+ sub_configs as sub_configs,
+ virtual as virtual,
+)
+from .types import (
+ DayCount as DayCount,
+ EncryptedString as EncryptedString,
+ HHMMString as HHMMString,
+ JsonDictString as JsonDictString,
+ JsonListString as JsonListString,
+ KeyboardKeyString as KeyboardKeyString,
+ NonNegativeInt as NonNegativeInt,
+ PositiveInt as PositiveInt,
+ UrlString as UrlString,
+ YmdHmString as YmdHmString,
+ YmdHmsString as YmdHmsString,
+ YmdString as YmdString,
+ decrypt_encrypted_string as decrypt_encrypted_string,
+)
+
+__all__: list[str]
diff --git a/app/core/config/base.py b/app/core/config/base.py
new file mode 100644
index 00000000..9cd5e09c
--- /dev/null
+++ b/app/core/config/base.py
@@ -0,0 +1,868 @@
+# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
+# Copyright © 2024-2025 DLmaster361
+# Copyright © 2025 MoeSnowyFox
+# Copyright © 2025-2026 AUTO-MAS Team
+
+# This file is part of AUTO-MAS.
+
+# AUTO-MAS is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of
+# the License, or (at your option) any later version.
+
+# AUTO-MAS is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty
+# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
+# the GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with AUTO-MAS. If not, see .
+
+# Contact: DLmaster_361@163.com
+
+
+from __future__ import annotations
+
+import asyncio
+import inspect
+import json
+import tomllib
+import uuid
+import weakref
+import os
+from contextlib import asynccontextmanager
+from dataclasses import dataclass
+from pathlib import Path
+from collections.abc import AsyncGenerator, Callable, Coroutine, Iterator
+from typing import Any, Generic, Protocol, TypeVar, cast, overload
+from importlib import import_module
+
+from filelock import FileLock, Timeout
+from app.utils import get_logger
+
+
+logger = get_logger("配置管理")
+
+
+tomli_w = import_module("tomli_w")
+
+
+PRIMARY_CONFIG_SUFFIX = ".toml"
+LEGACY_CONFIG_SUFFIX = ".json"
+
+
+def dump_toml(data: dict[str, Any]) -> str:
+ """
+ 使用 tomli-w 库序列化 TOML 数据。
+
+ Args:
+ data: 要序列化的字典数据
+
+ Returns:
+ TOML 格式的字符串
+
+ Raises:
+ TypeError: 如果数据包含不可序列化的类型
+ """
+ try:
+ return cast(Any, tomli_w).dumps(data)
+ except (TypeError, ValueError) as e:
+ logger.error(f"TOML 序列化失败: {e}, 数据类型: {type(data)}")
+ raise
+
+
+def atomic_write_text(path: Path, content: str, encoding: str = "utf-8") -> None:
+ """原子写入文本文件,使用跨进程文件锁 + 写后校验。"""
+
+ temp_file = path.with_suffix(f"{path.suffix}.tmp")
+ lock_file = path.with_suffix(f"{path.suffix}.lock")
+ file_lock = FileLock(str(lock_file), timeout=10)
+ try:
+ with file_lock:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temp_file.write_text(content, encoding=encoding)
+
+ verify_content = temp_file.read_text(encoding=encoding)
+ if verify_content != content:
+ raise OSError(f"配置写入校验失败: {path}")
+
+ if os.name == "nt" and path.exists():
+ backup_file = path.with_suffix(f"{path.suffix}.bak")
+ try:
+ path.replace(backup_file)
+ except OSError as backup_error:
+ logger.warning(
+ f"旧配置备份失败,尝试直接覆盖: {path}, 错误: {backup_error}"
+ )
+
+ temp_file.replace(path)
+ except Timeout as e:
+ logger.error(f"获取配置文件锁超时: {path}, 错误: {e}")
+ raise
+ finally:
+ if temp_file.exists():
+ try:
+ temp_file.unlink()
+ except OSError as cleanup_error:
+ logger.warning(
+ f"清理临时配置文件失败: {temp_file}, 错误: {cleanup_error}"
+ )
+
+
+def _load_json_config(path: Path) -> dict[str, Any]:
+ """
+ 加载 JSON 配置文件。
+
+ Args:
+ path: 配置文件路径
+
+ Returns:
+ 配置字典,如果文件为空或格式错误则返回空字典
+ """
+ try:
+ raw_text = path.read_text(encoding="utf-8")
+ if not raw_text.strip():
+ logger.debug(f"配置文件为空: {path}")
+ return {}
+
+ loaded = json.loads(raw_text)
+ if not isinstance(loaded, dict):
+ logger.warning(f"配置文件不是字典类型: {path}, 类型: {type(loaded)}")
+ return {}
+
+ mapping = cast(dict[object, Any], loaded)
+ return {str(key): item for key, item in mapping.items()}
+ except json.JSONDecodeError as e:
+ logger.error(f"JSON 解析失败: {path}, 错误: {e}")
+ return {}
+ except OSError as e:
+ logger.error(f"读取 JSON 配置失败: {path}, 错误: {e}")
+ return {}
+
+
+def _load_toml_config(path: Path) -> dict[str, Any]:
+ """
+ 加载 TOML 配置文件。
+
+ Args:
+ path: 配置文件路径
+
+ Returns:
+ 配置字典,如果文件为空或格式错误则返回空字典
+ """
+ try:
+ raw_text = path.read_text(encoding="utf-8")
+ if not raw_text.strip():
+ logger.debug(f"配置文件为空: {path}")
+ return {}
+
+ loaded = tomllib.loads(raw_text)
+ mapping = cast(dict[object, Any], loaded)
+ return {str(key): item for key, item in mapping.items()}
+ except tomllib.TOMLDecodeError as e:
+ logger.error(f"TOML 解析失败: {path}, 错误: {e}")
+ return {}
+ except OSError as e:
+ logger.error(f"读取 TOML 配置失败: {path}, 错误: {e}")
+ return {}
+
+
+def _load_config_with_legacy_migration(
+ path: Path,
+) -> tuple[dict[str, Any], Path | None]:
+ """
+ 加载配置文件,支持从 JSON 迁移到 TOML。
+
+ 优先级:
+ 1. 如果存在 .json 文件且 .toml 文件不存在或为空,则加载 .json
+ 2. 否则加载 .toml 文件
+ 3. 如果 .toml 加载失败,回退到 .json
+
+ Args:
+ path: TOML 配置文件路径
+
+ Returns:
+ (配置字典, 旧版 JSON 文件路径或 None)
+ """
+ legacy_json_file = path.with_suffix(LEGACY_CONFIG_SUFFIX)
+
+ # 情况 1: JSON 存在且 TOML 不存在或为空
+ if legacy_json_file.exists() and (not path.exists() or path.stat().st_size == 0):
+ logger.info(f"从旧版 JSON 配置迁移: {legacy_json_file} -> {path}")
+ data = _load_json_config(legacy_json_file)
+ return data, legacy_json_file
+
+ # 情况 2: TOML 不存在
+ if not path.exists():
+ logger.debug(f"配置文件不存在: {path}")
+ return {}, legacy_json_file if legacy_json_file.exists() else None
+
+ # 情况 3: 尝试加载 TOML
+ data = _load_toml_config(path)
+ if data or not legacy_json_file.exists():
+ return data, legacy_json_file if legacy_json_file.exists() else None
+
+ # 情况 4: TOML 加载失败,回退到 JSON
+ logger.warning(f"TOML 加载失败,回退到 JSON: {legacy_json_file}")
+ return _load_json_config(legacy_json_file), legacy_json_file
+
+
+def _backup_legacy_config_if_needed(
+ current_file: Path, legacy_file: Path | None
+) -> None:
+ """
+ 备份旧版 JSON 配置文件。
+
+ 仅在以下条件同时满足时备份:
+ 1. 旧版文件存在
+ 2. 新版文件存在且非空
+ 3. 备份文件不存在
+
+ Args:
+ current_file: 当前 TOML 配置文件路径
+ legacy_file: 旧版 JSON 配置文件路径
+ """
+ if legacy_file is None or not legacy_file.exists():
+ return
+ if not current_file.exists() or current_file.stat().st_size == 0:
+ return
+
+ legacy_backup = legacy_file.with_suffix(f"{legacy_file.suffix}.bak")
+ if not legacy_backup.exists():
+ try:
+ legacy_file.replace(legacy_backup)
+ logger.info(f"已备份旧版配置: {legacy_file} -> {legacy_backup}")
+ except OSError as e:
+ logger.error(f"备份旧版配置失败: {legacy_file}, 错误: {e}")
+
+
+def load_config_with_legacy_migration(
+ path: Path,
+) -> tuple[dict[str, Any], Path | None]:
+ """公开的兼容加载入口,供其他模块调用。"""
+
+ return _load_config_with_legacy_migration(path)
+
+
+def backup_legacy_config_if_needed(
+ current_file: Path, legacy_file: Path | None
+) -> None:
+ """公开的旧版配置备份入口,供其他模块调用。"""
+
+ _backup_legacy_config_if_needed(current_file, legacy_file)
+
+
+class _ConfigLike(Protocol):
+ @property
+ def is_locked(self) -> bool: ...
+
+ async def add_save_method(
+ self, save_method: Callable[[], Coroutine[Any, Any, None]]
+ ) -> None: ...
+
+ async def load(self, data: dict[str, Any]) -> None: ...
+
+ async def toDict(
+ self,
+ if_decrypt: bool = True,
+ regenerate_uuids: bool = False,
+ skip_virtual: bool = False,
+ ) -> dict[str, Any]: ...
+
+ async def lock(self) -> None: ...
+
+ async def unlock(self) -> None: ...
+
+ def bind_owner_collection(
+ self, collection: "MultipleConfig[Any]", uid: uuid.UUID
+ ) -> None: ...
+
+
+T = TypeVar("T", bound=_ConfigLike)
+
+
+CollectionEventSlot = Callable[[Any], Any] | Callable[[Any], Coroutine[Any, Any, Any]]
+
+
+@dataclass(slots=True)
+class MultipleConfigAddEvent(Generic[T]):
+ """新增配置项事件。"""
+
+ collection: "MultipleConfig[T]"
+ uid: uuid.UUID
+ config: T
+
+
+@dataclass(slots=True)
+class MultipleConfigDeleteEvent(Generic[T]):
+ """删除配置项事件。"""
+
+ collection: "MultipleConfig[T]"
+ uid: uuid.UUID
+ config: T
+
+
+@dataclass(slots=True)
+class MultipleConfigReorderEvent(Generic[T]):
+ """重排配置项事件。"""
+
+ collection: "MultipleConfig[T]"
+ order: list[uuid.UUID]
+
+
+def _callback_identity(callback: CollectionEventSlot) -> object:
+ """生成回调唯一标识。"""
+
+ if inspect.ismethod(callback) and getattr(callback, "__self__", None) is not None:
+ return id(callback.__self__), callback.__func__
+ return callback
+
+
+@dataclass(slots=True)
+class _WeakCallbackSlot:
+ """容器事件弱引用回调槽。"""
+
+ identity: object
+ callback: CollectionEventSlot | None = None
+ weak_method: weakref.WeakMethod[Any] | None = None
+
+ @classmethod
+ def build(cls, callback: CollectionEventSlot) -> "_WeakCallbackSlot":
+ if (
+ inspect.ismethod(callback)
+ and getattr(callback, "__self__", None) is not None
+ ):
+ return cls(
+ identity=_callback_identity(callback),
+ weak_method=weakref.WeakMethod(callback),
+ )
+ return cls(identity=_callback_identity(callback), callback=callback)
+
+ def resolve(self) -> CollectionEventSlot | None:
+ if self.weak_method is not None:
+ resolved = self.weak_method()
+ if resolved is None:
+ return None
+ return resolved
+ return self.callback
+
+
+async def _emit_collection_slots(slots: list[_WeakCallbackSlot], event: Any) -> None:
+ """依次触发容器事件回调,并清理失效弱引用。"""
+
+ alive_slots: list[_WeakCallbackSlot] = []
+
+ for slot in slots:
+ callback = slot.resolve()
+ if callback is None:
+ continue
+
+ alive_slots.append(slot)
+ result = callback(event)
+ if inspect.isawaitable(result):
+ await result
+
+ slots[:] = alive_slots
+
+
+class MultipleConfig(Generic[T]):
+ """
+ 多配置项管理类。
+
+ 负责管理同一类或同一组配置实例,并统一提供加载、保存、增删改序等接口。
+ """
+
+ def __init__(self, sub_config_type: list[type[T]]):
+ if not sub_config_type:
+ raise ValueError("子配置项类型列表不能为空")
+
+ self.sub_config_type: dict[str, type[T]] = {
+ config_type.__name__: config_type for config_type in sub_config_type
+ }
+ self.file: Path | None = None
+ self.order: list[uuid.UUID] = []
+ self.data: dict[uuid.UUID, T] = {}
+ self.is_locked = False
+ self._save_methods: list[Callable[[], Coroutine[Any, Any, None]]] = []
+ self._transaction_depth = 0
+ self._pending_save = False
+ self._pending_sync = False
+ self._on_add_slots: list[_WeakCallbackSlot] = []
+ self._on_before_del_slots: list[_WeakCallbackSlot] = []
+ self._on_del_slots: list[_WeakCallbackSlot] = []
+ self._on_reorder_slots: list[_WeakCallbackSlot] = []
+ self._mutex = asyncio.Lock()
+
+ def __getitem__(self, key: uuid.UUID) -> T:
+ if key not in self.data:
+ raise KeyError(f"配置项 '{key}' 不存在")
+ return self.data[key]
+
+ def __contains__(self, key: uuid.UUID) -> bool:
+ return key in self.data
+
+ def __len__(self) -> int:
+ return len(self.data)
+
+ def __repr__(self) -> str:
+ return (
+ "MultipleConfig("
+ f"items={len(self.data)}, "
+ f"types={list(self.sub_config_type.keys())}"
+ ")"
+ )
+
+ def __str__(self) -> str:
+ return f"MultipleConfig with {len(self.data)} items"
+
+ async def connect(self, path: Path) -> None:
+ """
+ 将运行期配置连接到指定 TOML 文件。
+
+ Args:
+ path: 配置文件路径,必须以 .toml 结尾
+
+ Raises:
+ ValueError: 如果文件扩展名不是 .toml 或配置已锁定
+ """
+ if path.suffix != PRIMARY_CONFIG_SUFFIX:
+ raise ValueError(f"配置文件必须是 .toml 格式,当前: {path.suffix}")
+
+ if self.is_locked:
+ raise ValueError("配置已锁定,无法修改")
+
+ logger.info(f"连接配置文件: {path}")
+ self.file = path
+
+ if not self.file.exists():
+ self.file.parent.mkdir(parents=True, exist_ok=True)
+ self.file.touch()
+ logger.debug(f"创建新配置文件: {self.file}")
+
+ try:
+ data, legacy_file = _load_config_with_legacy_migration(self.file)
+ await self.load(data)
+ await self.add_save_method(self.save)
+ _backup_legacy_config_if_needed(self.file, legacy_file)
+ logger.info(f"配置加载成功: {path}, 项目数: {len(self.data)}")
+ except (OSError, ValueError, TypeError) as e:
+ logger.error(f"配置加载失败: {path}, 错误: {e}")
+ raise
+
+ async def add_save_method(
+ self, save_method: Callable[[], Coroutine[Any, Any, None]]
+ ) -> None:
+ """为当前管理器及其子配置添加级联保存方法。"""
+
+ if save_method != self.save and save_method not in self._save_methods:
+ self._save_methods.append(save_method)
+
+ for sub_config in self.data.values():
+ await sub_config.add_save_method(save_method)
+
+ @asynccontextmanager
+ async def transaction(self) -> AsyncGenerator["MultipleConfig[T]", None]:
+ """开启一个延迟保存事务。"""
+
+ self._transaction_depth += 1
+ try:
+ yield self
+ finally:
+ self._transaction_depth -= 1
+ if self._transaction_depth == 0:
+ await self._flush_pending_changes()
+
+ async def _flush_pending_changes(self) -> None:
+ """提交事务期间累积的保存请求。"""
+
+ if self._pending_save and self.file:
+ self._pending_save = False
+ await self._save_unlocked()
+
+ if self._pending_sync:
+ self._pending_sync = False
+ if self._save_methods:
+ await asyncio.gather(*(_() for _ in self._save_methods))
+
+ async def _save_unlocked(self) -> None:
+ """在已持有互斥锁时保存配置。"""
+
+ if not self.file:
+ raise ValueError("文件路径未设置,请先调用 connect() 方法")
+
+ content = dump_toml(await self.toDict(if_decrypt=False))
+ atomic_write_text(self.file, content)
+ logger.debug(f"配置保存成功: {self.file}, 大小: {len(content)} 字节")
+
+ async def load(self, data: dict[str, Any]) -> None:
+ """从字典加载多实例配置数据。"""
+
+ async with self._mutex:
+ if self.is_locked:
+ raise ValueError("配置已锁定, 无法修改")
+
+ self.order = []
+ self.data = {}
+
+ instances = data.get("instances")
+ if not isinstance(instances, list):
+ return
+ instances_list = cast(list[object], instances)
+
+ for instance in instances_list:
+ if isinstance(instance, str):
+ try:
+ parsed_instance = json.loads(instance)
+ except (json.JSONDecodeError, ValueError):
+ continue
+ if isinstance(parsed_instance, dict):
+ instance = parsed_instance
+
+ if not isinstance(instance, dict):
+ continue
+ instance_dict = cast(dict[object, Any], instance)
+
+ uid_str = instance_dict.get("uid")
+ type_name = instance_dict.get("type")
+ if not isinstance(uid_str, str) or not isinstance(type_name, str):
+ continue
+ if type_name not in self.sub_config_type:
+ continue
+
+ instance_data = data.get(uid_str)
+ if not isinstance(instance_data, dict):
+ continue
+ instance_data_dict = cast(dict[str, Any], instance_data)
+
+ try:
+ uid = uuid.UUID(uid_str)
+ except (TypeError, ValueError):
+ continue
+
+ config = self.sub_config_type[type_name]()
+ config.bind_owner_collection(self, uid)
+ self.order.append(uid)
+ self.data[uid] = config
+ await config.load(instance_data_dict)
+
+ should_save = bool(self.file)
+ should_cascade = bool(self._save_methods)
+
+ if should_save:
+ await self._save_unlocked()
+
+ if should_cascade:
+ await asyncio.gather(*(_() for _ in self._save_methods))
+
+ async def toDict(
+ self,
+ if_decrypt: bool = True,
+ regenerate_uuids: bool = False,
+ skip_virtual: bool = False,
+ ) -> dict[str, Any]:
+ """将全部子配置序列化为统一字典结构。"""
+
+ uuid_book: dict[uuid.UUID, uuid.UUID] = {
+ uid: uuid.uuid4() if regenerate_uuids else uid for uid in self.order
+ }
+
+ data: dict[str, Any] = {
+ "instances": [
+ {"uid": str(uuid_book[uid]), "type": type(self.data[uid]).__name__}
+ for uid in self.order
+ ]
+ }
+
+ for uid, config in self.items():
+ data[str(uuid_book[uid])] = await config.toDict(
+ if_decrypt, regenerate_uuids, skip_virtual
+ )
+
+ return data
+
+ @overload
+ async def get(self, uid: uuid.UUID) -> dict[str, Any]: ...
+
+ @overload
+ async def get(self, uid: None = None) -> dict[str, Any]: ...
+
+ async def get(self, uid: uuid.UUID | None = None) -> dict[str, Any]:
+ """获取指定 UID 的单个配置。"""
+
+ if uid is None:
+ return await self.toDict()
+
+ if uid not in self.data:
+ raise ValueError(f"配置项 '{uid}' 不存在。")
+
+ data: dict[str, Any] = {
+ "instances": [
+ {"uid": str(current_uid), "type": type(self.data[current_uid]).__name__}
+ for current_uid in self.order
+ if current_uid == uid
+ ],
+ str(uid): await self.data[uid].toDict(),
+ }
+ return data
+
+ async def save(self) -> None:
+ """
+ 保存当前多实例配置到文件。
+
+ 如果在事务中,则延迟保存;否则立即写入文件。
+
+ Raises:
+ ValueError: 如果文件路径未设置
+ OSError: 如果文件写入失败
+ """
+ if not self.file:
+ raise ValueError("文件路径未设置,请先调用 connect() 方法")
+
+ if self._transaction_depth > 0:
+ self._pending_save = True
+ logger.debug(f"事务中,延迟保存: {self.file}")
+ return
+
+ async with self._mutex:
+ try:
+ await self._save_unlocked()
+ except (OSError, ValueError, TypeError) as e:
+ logger.error(f"配置保存失败: {self.file}, 错误: {e}")
+ raise
+
+ async def add(self, config_type: type[T]) -> tuple[uuid.UUID, T]:
+ """
+ 新增一个指定类型的子配置实例。
+
+ Args:
+ config_type: 配置类型,必须在允许的类型列表中
+
+ Returns:
+ (新配置的 UUID, 配置实例)
+
+ Raises:
+ ValueError: 如果配置类型不被允许或配置已锁定
+ """
+ async with self._mutex:
+ if config_type not in self.sub_config_type.values():
+ raise ValueError(f"配置类型 {config_type.__name__} 不被允许")
+ if self.is_locked:
+ raise ValueError("配置已锁定,无法修改")
+
+ uid = uuid.uuid4()
+ config = config_type()
+ config.bind_owner_collection(self, uid)
+ self.order.append(uid)
+ self.data[uid] = config
+
+ logger.info(f"新增配置: {config_type.__name__}, UID: {uid}")
+
+ for save_method in self._save_methods:
+ await config.add_save_method(save_method)
+
+ if self.file:
+ await config.add_save_method(self.save)
+ self._pending_save = True
+
+ if self._save_methods:
+ self._pending_sync = True
+
+ if self._transaction_depth == 0:
+ await self._flush_pending_changes()
+
+ await _emit_collection_slots(
+ self._on_add_slots, MultipleConfigAddEvent(self, uid, config)
+ )
+
+ return uid, config
+
+ async def remove(self, uid: uuid.UUID) -> None:
+ """
+ 移除一个子配置实例。
+
+ Args:
+ uid: 要移除的配置 UUID
+
+ Raises:
+ ValueError: 如果配置不存在、已锁定或父容器已锁定
+ """
+ async with self._mutex:
+ if self.is_locked:
+ raise ValueError("配置已锁定,无法修改")
+ if uid not in self.data:
+ raise ValueError(f"配置项 '{uid}' 不存在")
+ if self.data[uid].is_locked:
+ raise ValueError(f"配置项 '{uid}' 已锁定,无法移除")
+
+ config = self.data[uid]
+ logger.info(f"移除配置: {type(config).__name__}, UID: {uid}")
+
+ await _emit_collection_slots(
+ self._on_before_del_slots, MultipleConfigDeleteEvent(self, uid, config)
+ )
+
+ self.data.pop(uid)
+ self.order.remove(uid)
+
+ if self.file:
+ self._pending_save = True
+ if self._save_methods:
+ self._pending_sync = True
+
+ if self._transaction_depth == 0:
+ await self._flush_pending_changes()
+
+ await _emit_collection_slots(
+ self._on_del_slots, MultipleConfigDeleteEvent(self, uid, config)
+ )
+
+ async def setOrder(self, order: list[uuid.UUID]) -> None: # noqa: N802
+ """设置子配置实例顺序。"""
+
+ async with self._mutex:
+ if set(order) != set(self.data.keys()):
+ raise ValueError("顺序与当前配置项不匹配")
+ if self.is_locked:
+ raise ValueError("配置已锁定, 无法修改")
+
+ self.order = order
+
+ if self.file:
+ self._pending_save = True
+ if self._save_methods:
+ self._pending_sync = True
+
+ if self._transaction_depth == 0:
+ await self._flush_pending_changes()
+
+ await _emit_collection_slots(
+ self._on_reorder_slots, MultipleConfigReorderEvent(self, list(order))
+ )
+
+ async def get_item(
+ self, uid_str: str | None = None
+ ) -> tuple[list[dict[str, str]], dict[str, Any]]:
+ """通用查询:返回 (index_list, data_dict)。"""
+
+ if uid_str is None:
+ data = await self.toDict()
+ else:
+ data = await self.get(uuid.UUID(uid_str))
+ index = data.pop("instances", [])
+ return list(index), data
+
+ async def update_item(
+ self, uid_str: str, data: dict[str, dict[str, Any]]
+ ) -> None:
+ """通用更新:根据 UID 字符串更新子配置。"""
+
+ await self[uuid.UUID(uid_str)].set_many(data)
+
+ async def del_item(self, uid_str: str) -> None:
+ """通用删除:根据 UID 字符串删除子配置。"""
+
+ await self.remove(uuid.UUID(uid_str))
+
+ async def reorder_items(self, index_list: list[str]) -> None:
+ """通用排序:根据 UID 字符串列表重新排序。"""
+
+ await self.setOrder([uuid.UUID(uid) for uid in index_list])
+
+ async def lock(self) -> None:
+ """锁定当前管理器及全部子配置。"""
+
+ self.is_locked = True
+ for item in self.values():
+ await item.lock()
+
+ async def unlock(self) -> None:
+ """解锁当前管理器及全部子配置。"""
+
+ self.is_locked = False
+ for item in self.values():
+ await item.unlock()
+
+ def keys(self) -> Iterator[uuid.UUID]:
+ """返回全部 UID。"""
+
+ return iter(tuple(self.order))
+
+ def values(self) -> Iterator[T]:
+ """按顺序返回全部子配置实例。"""
+
+ if not self.data:
+ return iter(())
+ order_snapshot = tuple(self.order)
+ return iter(tuple(self.data[uid] for uid in order_snapshot if uid in self.data))
+
+ def items(self) -> Iterator[tuple[uuid.UUID, T]]:
+ """按顺序返回 `(uid, config)` 对。"""
+
+ order_snapshot = tuple(self.order)
+ return iter(
+ tuple((uid, self.data[uid]) for uid in order_snapshot if uid in self.data)
+ )
+
+ def bind_add(self, slot: CollectionEventSlot) -> None:
+ """绑定新增事件。"""
+
+ identity = _callback_identity(slot)
+ if any(item.identity == identity for item in self._on_add_slots):
+ return
+ self._on_add_slots.append(_WeakCallbackSlot.build(slot))
+
+ def bind_before_del(self, slot: CollectionEventSlot) -> None:
+ """绑定删除前事件。"""
+
+ identity = _callback_identity(slot)
+ if any(item.identity == identity for item in self._on_before_del_slots):
+ return
+ self._on_before_del_slots.append(_WeakCallbackSlot.build(slot))
+
+ def bind_del(self, slot: CollectionEventSlot) -> None:
+ """绑定删除后事件。"""
+
+ identity = _callback_identity(slot)
+ if any(item.identity == identity for item in self._on_del_slots):
+ return
+ self._on_del_slots.append(_WeakCallbackSlot.build(slot))
+
+ def bind_reorder(self, slot: CollectionEventSlot) -> None:
+ """绑定重排事件。"""
+
+ identity = _callback_identity(slot)
+ if any(item.identity == identity for item in self._on_reorder_slots):
+ return
+ self._on_reorder_slots.append(_WeakCallbackSlot.build(slot))
+
+ def unbind_add(self, slot: CollectionEventSlot) -> None:
+ """解绑新增事件。"""
+
+ identity = _callback_identity(slot)
+ self._on_add_slots = [
+ item for item in self._on_add_slots if item.identity != identity
+ ]
+
+ def unbind_before_del(self, slot: CollectionEventSlot) -> None:
+ """解绑删除前事件。"""
+
+ identity = _callback_identity(slot)
+ self._on_before_del_slots = [
+ item for item in self._on_before_del_slots if item.identity != identity
+ ]
+
+ def unbind_del(self, slot: CollectionEventSlot) -> None:
+ """解绑删除后事件。"""
+
+ identity = _callback_identity(slot)
+ self._on_del_slots = [
+ item for item in self._on_del_slots if item.identity != identity
+ ]
+
+ def unbind_reorder(self, slot: CollectionEventSlot) -> None:
+ """解绑重排事件。"""
+
+ identity = _callback_identity(slot)
+ self._on_reorder_slots = [
+ item for item in self._on_reorder_slots if item.identity != identity
+ ]
diff --git a/app/core/config/fields.py b/app/core/config/fields.py
new file mode 100644
index 00000000..fe87fb9d
--- /dev/null
+++ b/app/core/config/fields.py
@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Any, Protocol
+
+
+class RefDeleteAction(str, Enum):
+ RESTRICT = "restrict"
+ SET_DEFAULT = "set_default"
+ CASCADE = "cascade"
+ CUSTOM = "custom"
+VirtualDependency = tuple[str, str]
+
+
+class OnDeleteCallback(Protocol):
+ """引用字段删除回调协议。"""
+
+ def __call__(self, owner: Any, deleted_value: Any) -> Any:
+ """
+ 当引用的对象被删除时调用。
+
+ Args:
+ owner: 拥有引用字段的配置对象
+ deleted_value: 被删除的引用值
+
+ Returns:
+ 新的字段值
+ """
+ ...
+
+
+@dataclass(frozen=True, slots=True)
+class RefField:
+ """
+ 声明式引用字段元数据。
+
+ 用于标记一个字段引用另一个配置对象,并定义引用完整性约束。
+
+ Attributes:
+ target: 引用目标的配置类型名称
+ default: 引用失效时的默认值
+ allow_values: 允许的特殊值(如 "-" 表示未选择)
+ on_delete: 删除策略
+ on_delete_callback: 自定义删除回调(仅当 on_delete="custom" 时有效)
+ """
+
+ target: str
+ default: Any
+ allow_values: tuple[Any, ...] = ()
+ on_delete: RefDeleteAction = RefDeleteAction.SET_DEFAULT
+ on_delete_callback: OnDeleteCallback | str | None = None
+
+ def __post_init__(self) -> None:
+ """验证字段配置。"""
+ if self.on_delete == RefDeleteAction.CUSTOM and self.on_delete_callback is None:
+ raise ValueError("on_delete='custom' 时必须提供 on_delete_callback")
+ if self.on_delete != RefDeleteAction.CUSTOM and self.on_delete_callback is not None:
+ raise ValueError("on_delete_callback 仅在 on_delete='custom' 时有效")
+
+
+class VirtualFieldGetter(Protocol):
+ """虚拟字段 getter 协议。"""
+
+ def __call__(self, owner: Any) -> Any:
+ """
+ 获取虚拟字段的值。
+
+ Args:
+ owner: 拥有虚拟字段的配置对象
+
+ Returns:
+ 虚拟字段的计算值
+ """
+ ...
+
+
+class VirtualFieldSetter(Protocol):
+ """虚拟字段 setter 协议。"""
+
+ def __call__(self, owner: Any, value: Any) -> Any:
+ """
+ 设置虚拟字段的值。
+
+ Args:
+ owner: 拥有虚拟字段的配置对象
+ value: 要设置的值
+
+ Returns:
+ 实际设置的值
+ """
+ ...
+
+
+@dataclass(frozen=True, slots=True)
+class VirtualField:
+ """
+ 声明式虚拟字段元数据。
+
+ 虚拟字段不直接存储数据,而是通过 getter/setter 计算或代理到其他字段。
+
+ Attributes:
+ getter: 获取字段值的函数或方法名
+ setter: 设置字段值的函数或方法名(可选)
+ depends_on: 依赖的字段列表,格式为 (group, field)
+ """
+
+ getter: VirtualFieldGetter | str
+ setter: VirtualFieldSetter | str | None = None
+ depends_on: tuple[VirtualDependency, ...] = field(default_factory=tuple)
diff --git a/app/core/config/manager.py b/app/core/config/manager.py
new file mode 100644
index 00000000..d58a4b6b
--- /dev/null
+++ b/app/core/config/manager.py
@@ -0,0 +1,1437 @@
+# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
+# Copyright © 2024-2025 DLmaster361
+# Copyright © 2025 MoeSnowyFox
+# Copyright © 2025-2026 AUTO-MAS Team
+
+# This file is part of AUTO-MAS.
+
+# AUTO-MAS is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of
+# the License, or (at your option) any later version.
+
+# AUTO-MAS is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty
+# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
+# the GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with AUTO-MAS. If not, see .
+
+# Contact: DLmaster_361@163.com
+
+import os
+import sys
+import httpx
+import shutil
+import asyncio
+import uvicorn
+import sqlite3
+import tomllib
+import truststore
+from pathlib import Path
+from fastapi import WebSocket
+from jinja2 import Environment, FileSystemLoader
+from datetime import datetime, timedelta, date
+from typing import Literal, Optional, Dict, Any, List, ClassVar, cast
+import uuid
+import json
+
+from app.models.common import EmulatorConfig, QueueConfig, QueueItem, TimeSet, Webhook
+from app.models.general import GeneralConfig, GeneralUserConfig
+from app.models.global_config import GlobalConfig
+from app.models.maa import MaaConfig, MaaPlanConfig, MaaUserConfig
+from app.models.maaend import MaaEndConfig, MaaEndUserConfig
+from app.models.src import SrcConfig, SrcUserConfig
+from .base import dump_toml
+from app.models.shared import WebSocketMessage
+from app.utils.constants import (
+ UTC4,
+ UTC8,
+ RESOURCE_STAGE_INFO,
+ RESOURCE_STAGE_DROP_INFO,
+ TYPE_BOOK,
+ RESOURCE_STAGE_DATE_TEXT,
+)
+from app.utils import get_logger
+from app.services.git_service import GitService
+from app.services.log_service import LogService
+from app.services.migration import MigrationService
+
+logger = get_logger("配置管理")
+
+ScriptConfigClass = (
+ type[MaaConfig] | type[SrcConfig] | type[GeneralConfig] | type[MaaEndConfig]
+)
+ScriptConfigData = MaaConfig | SrcConfig | GeneralConfig | MaaEndConfig
+
+if (Path.cwd() / "environment/git/bin/git.exe").exists():
+ os.environ["GIT_PYTHON_GIT_EXECUTABLE"] = str(
+ Path.cwd() / "environment/git/bin/git.exe"
+ )
+
+try:
+ from git import Repo
+except ImportError:
+ Repo = None
+
+
+class AppConfig(GlobalConfig):
+ VERSION: ClassVar[str] = "v5.2.0-beta.1"
+
+ def __init__(self) -> None:
+ super().__init__()
+
+ logger.info("")
+ logger.info("===================================")
+ logger.info("AUTO-MAS 后端应用程序")
+ logger.info(f"版本号: {self.VERSION}")
+ logger.info(f"工作目录: {Path.cwd()}")
+ logger.info("===================================")
+
+ object.__setattr__(self, "log_path", Path.cwd() / "debug/app.log")
+ object.__setattr__(self, "database_path", Path.cwd() / "data/data.db")
+ object.__setattr__(self, "config_path", Path.cwd() / "config")
+ object.__setattr__(self, "history_path", Path.cwd() / "history")
+ # 检查目录
+ self.log_path.parent.mkdir(parents=True, exist_ok=True)
+ self.database_path.parent.mkdir(parents=True, exist_ok=True)
+ self.config_path.mkdir(parents=True, exist_ok=True)
+ self.history_path.mkdir(parents=True, exist_ok=True)
+
+ # 初始化Git仓库(如果可用)
+ try:
+ if Repo is not None:
+ object.__setattr__(self, "repo", Repo(Path.cwd()))
+ else:
+ object.__setattr__(self, "repo", None)
+ except (OSError, ValueError) as e:
+ logger.warning(f"Git仓库初始化失败: {e}")
+ object.__setattr__(self, "repo", None)
+
+ object.__setattr__(
+ self,
+ "notify_env",
+ Environment(loader=FileSystemLoader(str(Path.cwd() / "res/html"))),
+ )
+
+ object.__setattr__(self, "server", None)
+ object.__setattr__(self, "websocket", None)
+ object.__setattr__(self, "web_connections", set())
+ object.__setattr__(self, "power_sign", "NoAction")
+ object.__setattr__(self, "temp_task", [])
+
+ object.__setattr__(self, "_migration_service", MigrationService(self))
+ object.__setattr__(self, "_log_service", LogService(self.history_path))
+
+ truststore.inject_into_ssl()
+
+ def _resolve_config_path(self, stem: str) -> Path:
+ """返回运行期 TOML 配置路径。"""
+
+ return self.config_path / f"{stem}.toml"
+
+ async def _connect_runtime_configs(self) -> None:
+ """连接运行期主配置文件。"""
+
+ await self.connect(self._resolve_config_path("Config"))
+ await self.EmulatorConfig.connect(self._resolve_config_path("EmulatorConfig"))
+ await self.PlanConfig.connect(self._resolve_config_path("PlanConfig"))
+ await self.ScriptConfig.connect(self._resolve_config_path("ScriptConfig"))
+ await self.PluginConfig.connect(self._resolve_config_path("PluginConfig"))
+ await self.QueueConfig.connect(self._resolve_config_path("QueueConfig"))
+ await self.ToolsConfig.connect(self._resolve_config_path("ToolsConfig"))
+
+ def _read_mapping_config(self, path: Path) -> dict[str, Any]:
+ """读取 TOML/JSON 字典配置文件并返回映射对象。"""
+
+ if not path.exists():
+ return {}
+
+ text = path.read_text(encoding="utf-8")
+ if not text.strip():
+ return {}
+
+ if path.suffix == ".toml":
+ data = tomllib.loads(text)
+ else:
+ data = json.loads(text)
+ if isinstance(data, dict):
+ return cast(dict[str, Any], data)
+ return {}
+
+ def _write_mapping_config(self, path: Path, data: dict[str, Any]) -> None:
+ """将字典配置写入 TOML/JSON 文件。"""
+
+ if path.suffix == ".toml":
+ path.write_text(dump_toml(data), encoding="utf-8")
+ return
+
+ path.write_text(
+ json.dumps(data, ensure_ascii=False, indent=4), encoding="utf-8"
+ )
+
+ async def init_config(self) -> None:
+ """初始化配置管理"""
+
+ await self.check_data()
+
+ await self._connect_runtime_configs()
+
+ from app.services import System
+
+ self.bind("Start", "IfSelfStart", System.set_SelfStart)
+ self.bind("Function", "IfAllowSleep", System.set_Sleep)
+ await System.set_SelfStart(self.get("Start", "IfSelfStart"))
+ await System.set_Sleep(self.get("Function", "IfAllowSleep"))
+
+ self.loop = asyncio.get_running_loop()
+ object.__setattr__(
+ self, "_git_service", GitService(self.repo, self.loop)
+ )
+
+ logger.info("程序初始化完成")
+
+ async def check_data(self) -> None:
+ """检查用户数据文件并处理数据文件版本更新"""
+
+ await self._migration_service.check_data()
+
+ async def send_json(self, data: dict[str, Any]) -> None:
+ """通过WebSocket发送JSON数据(桌面 + 所有 Web 连接)"""
+ if Config.websocket is None and not Config.web_connections:
+ logger.warning("WebSocket 未连接")
+ return
+
+ if Config.websocket is not None:
+ try:
+ await Config.websocket.send_json(data)
+ except Exception:
+ logger.warning("桌面 WebSocket 发送失败")
+
+ dead: list[WebSocket] = []
+ for ws in Config.web_connections:
+ try:
+ await ws.send_json(data)
+ except Exception:
+ dead.append(ws)
+ for ws in dead:
+ Config.web_connections.discard(ws)
+
+ async def send_websocket_message(
+ self,
+ id: str,
+ type: Literal["Update", "Message", "Info", "Signal"],
+ data: Dict[str, Any],
+ ) -> None:
+ """通过WebSocket发送消息(桌面 + 所有 Web 连接)"""
+ msg = WebSocketMessage(id=id, type=type, data=data).model_dump()
+ await self.send_json(msg)
+
+ async def get_git_version(self) -> tuple[bool, str, str]:
+ """获取Git版本信息,如果Git不可用则返回默认值"""
+
+ return await self._git_service.get_git_version()
+
+ async def add_script(
+ self,
+ script: Literal["MAA", "SRC", "General", "MaaEnd"],
+ script_id: str | None = None,
+ ) -> tuple[uuid.UUID, Any]:
+ """添加脚本配置"""
+
+ logger.info(f"添加脚本配置: {script}, 从 {script_id} 复制")
+
+ script_class_map: dict[
+ Literal["MAA", "SRC", "General", "MaaEnd"], ScriptConfigClass
+ ] = {
+ "MAA": MaaConfig,
+ "SRC": SrcConfig,
+ "General": GeneralConfig,
+ "MaaEnd": MaaEndConfig,
+ }
+ script_class = script_class_map[script]
+
+ if script_id is None:
+ return await self.ScriptConfig.add(script_class)
+ else:
+ script_uid = uuid.UUID(script_id)
+
+ if type(self.ScriptConfig[script_uid]) is not script_class:
+ raise TypeError(f"脚本配置类型不匹配: {script_id} {script}")
+
+ new_uid, new_config = await self.ScriptConfig.add(script_class)
+
+ await new_config.load(
+ await self.ScriptConfig[script_uid].toDict(regenerate_uuids=True)
+ )
+
+ # 复制用户数据
+ if (Path.cwd() / f"data/{script_id}").exists():
+ shutil.copytree(
+ Path.cwd() / f"data/{script_id}",
+ Path.cwd() / f"data/{new_uid}",
+ dirs_exist_ok=True,
+ )
+ for old_user, new_user in zip(
+ self.ScriptConfig[script_uid].UserData.keys(),
+ new_config.UserData.keys(),
+ ):
+ if (Path.cwd() / f"data/{new_uid}/{old_user}").exists():
+ (Path.cwd() / f"data/{new_uid}/{old_user}").rename(
+ Path.cwd() / f"data/{new_uid}/{new_user}"
+ )
+
+ return new_uid, new_config
+
+ async def get_script(
+ self, script_id: str | None
+ ) -> tuple[list[dict[str, str]], dict[str, Any]]:
+ """获取脚本配置"""
+
+ logger.info(f"获取脚本配置: {script_id}")
+ return await self.ScriptConfig.get_item(script_id)
+
+ async def update_script(
+ self, script_id: str, data: Dict[str, Dict[str, Any]]
+ ) -> None:
+ """更新脚本配置"""
+
+ logger.info(f"更新脚本配置: {script_id}")
+
+ uid = uuid.UUID(script_id)
+
+ if self.ScriptConfig[uid].is_locked:
+ raise RuntimeError(f"脚本 {script_id} 正在运行, 无法更新配置项")
+
+ await self.ScriptConfig.update_item(script_id, data)
+
+ async def del_script(self, script_id: str) -> None:
+ """删除脚本配置"""
+
+ logger.info(f"删除脚本配置: {script_id}")
+
+ uid = uuid.UUID(script_id)
+
+ if self.ScriptConfig[uid].is_locked:
+ raise RuntimeError(f"脚本 {script_id} 正在运行, 无法删除")
+
+ await self.ScriptConfig.del_item(script_id)
+ if (Path.cwd() / f"data/{uid}").exists():
+ shutil.rmtree(Path.cwd() / f"data/{uid}")
+
+ async def reorder_script(self, index_list: list[str]) -> None:
+ """重新排序脚本"""
+
+ logger.info(f"重新排序脚本: {index_list}")
+
+ await self.ScriptConfig.reorder_items(index_list)
+
+ async def import_script_from_file(self, script_id: str, jsonFile: str) -> None:
+ """从文件加载脚本配置"""
+
+ logger.info(f"从文件加载脚本配置: {script_id} - {jsonFile}")
+ uid = uuid.UUID(script_id)
+ file_path = Path(jsonFile)
+
+ if uid not in self.ScriptConfig:
+ logger.error(f"{script_id} 不存在")
+ raise KeyError(f"脚本 {script_id} 不存在")
+ if not isinstance(self.ScriptConfig[uid], GeneralConfig):
+ logger.error(f"{script_id} 不是通用脚本配置")
+ raise TypeError(f"脚本 {script_id} 不是通用脚本配置")
+ if not Path(file_path).exists():
+ logger.error(f"文件不存在: {file_path}")
+ raise FileNotFoundError(f"文件不存在: {file_path}")
+
+ data = json.loads(file_path.read_text(encoding="utf-8"))
+ await self.ScriptConfig[uid].load(data)
+
+ logger.success(f"{script_id} 配置加载成功")
+
+ async def export_script_to_file(self, script_id: str, jsonFile: str):
+ """导出脚本配置到文件"""
+
+ logger.info(f"导出配置到文件: {script_id} - {jsonFile}")
+
+ uid = uuid.UUID(script_id)
+ file_path = Path(jsonFile)
+
+ if uid not in self.ScriptConfig:
+ logger.error(f"{script_id} 不存在")
+ raise KeyError(f"脚本 {script_id} 不存在")
+ if not isinstance(self.ScriptConfig[uid], GeneralConfig):
+ logger.error(f"{script_id} 不是通用脚本配置")
+ raise TypeError(f"脚本 {script_id} 不是通用脚本配置")
+
+ temp = await self.ScriptConfig[uid].toDict(if_decrypt=False)
+ temp.pop("sub_configs_info", None)
+ temp = await self.remove_privacy_info(temp, Path(file_path).stem)
+
+ file_path.write_text(
+ json.dumps(temp, ensure_ascii=False, indent=4), encoding="utf-8"
+ )
+
+ logger.success(f"{script_id} 配置导出成功")
+
+ async def import_script_from_web(self, script_id: str, url: str):
+ """从「AUTO-MAS 配置分享中心」导入配置"""
+
+ logger.info(f"从网络加载脚本配置: {script_id} - {url}")
+ uid = uuid.UUID(script_id)
+
+ if uid not in self.ScriptConfig:
+ logger.error(f"{script_id} 不存在")
+ raise KeyError(f"脚本 {script_id} 不存在")
+ if not isinstance(self.ScriptConfig[uid], GeneralConfig):
+ logger.error(f"{script_id} 不是通用脚本配置")
+ raise TypeError(f"脚本 {script_id} 不是通用脚本配置")
+
+ # 使用 httpx 异步请求
+ async with httpx.AsyncClient(
+ proxy=Config.proxy, follow_redirects=True
+ ) as client:
+ try:
+ response = await client.get(url)
+ if response.status_code == 200:
+ data = response.json()
+ else:
+ logger.warning(
+ f"无法从 AUTO-MAS 服务器获取配置内容: {response.text}"
+ )
+ raise ConnectionError(
+ f"无法从 AUTO-MAS 服务器获取配置内容: {response.status_code}"
+ )
+ except httpx.RequestError as e:
+ logger.warning(f"无法从 AUTO-MAS 服务器获取配置内容: {e}")
+ raise ConnectionError(f"无法从 AUTO-MAS 服务器获取配置内容: {e}")
+
+ if data.get("code", 200) == 500:
+ logger.error(f"从 AUTO-MAS 服务器获取配置内容失败: {data.get('message')}")
+ raise ConnectionError(
+ f"从 AUTO-MAS 服务器获取配置内容失败: {data.get('message')}"
+ )
+
+ await self.ScriptConfig[uid].load(data)
+
+ logger.success(f"{script_id} 配置加载成功")
+
+ async def upload_script_to_web(
+ self, script_id: str, config_name: str, author: str, description: str
+ ):
+ """上传配置到「AUTO-MAS 配置分享中心」"""
+
+ logger.info(f"上传配置到网络: {script_id} - {config_name} - {author}")
+
+ uid = uuid.UUID(script_id)
+
+ if uid not in self.ScriptConfig:
+ logger.error(f"{script_id} 不存在")
+ raise KeyError(f"脚本 {script_id} 不存在")
+ if not isinstance(self.ScriptConfig[uid], GeneralConfig):
+ logger.error(f"{script_id} 不是通用脚本配置")
+ raise TypeError(f"脚本 {script_id} 不是通用脚本配置")
+
+ temp = await self.ScriptConfig[uid].toDict(if_decrypt=False)
+ temp.pop("sub_configs_info", None)
+ temp = await self.remove_privacy_info(temp, config_name)
+
+ files = {
+ "file": (
+ f"{config_name}&&{author}&&{description}&&{int(datetime.now(tz=UTC8).timestamp() * 1000)}.json",
+ json.dumps(temp, ensure_ascii=False),
+ "application/json",
+ )
+ }
+ data = {"username": author, "description": description}
+
+ async with httpx.AsyncClient(
+ proxy=Config.proxy, follow_redirects=True
+ ) as client:
+ try:
+ response = await client.post(
+ "https://share.auto-mas.top/api/upload/share",
+ files=files,
+ data=data,
+ )
+
+ if response.status_code == 200:
+ logger.success("配置上传成功")
+ else:
+ logger.error(f"无法上传配置到 AUTO-MAS 服务器: {response.text}")
+ raise ConnectionError(
+ f"无法上传配置到 AUTO-MAS 服务器: {response.status_code} - {response.text}"
+ )
+ except httpx.RequestError as e:
+ logger.error(f"无法上传配置到 AUTO-MAS 服务器: {e}")
+ raise ConnectionError(f"无法上传配置到 AUTO-MAS 服务器: {e}")
+
+ async def remove_privacy_info(
+ self, confg: dict[str, Any], name: str
+ ) -> dict[str, Any]:
+ """移除配置中可能存在的隐私信息"""
+
+ confg["info"]["name"] = name
+ for path in ["script_path", "config_path", "log_path", "track_process_exe"]:
+ if Path(confg["script"][path]).is_relative_to(
+ Path(confg["info"]["root_path"])
+ ):
+ confg["script"][path] = str(
+ Path(r"C:/脚本根目录")
+ / Path(confg["script"][path]).relative_to(
+ Path(confg["info"]["root_path"])
+ )
+ )
+ if sys.platform == "win32" and Path(confg["script"][path]).is_relative_to(
+ Path(os.environ["APPDATA"])
+ ):
+ confg["script"][path] = (
+ f"%APPDATA%/{Path(confg['script'][path]).relative_to(Path(os.environ['APPDATA']))}"
+ )
+ confg["info"]["root_path"] = str(Path(r"C:/脚本根目录"))
+
+ return confg
+
+ async def get_user(
+ self, script_id: str, user_id: Optional[str]
+ ) -> tuple[list[dict[str, str]], dict[str, Any]]:
+ """获取用户配置"""
+
+ logger.info(f"获取用户配置: {script_id} - {user_id}")
+
+ return await self.ScriptConfig[uuid.UUID(script_id)].UserData.get_item(user_id)
+
+ async def add_user(self, script_id: str) -> tuple[uuid.UUID, Any]:
+ """添加用户配置"""
+
+ logger.info(f"{script_id} 添加用户配置")
+
+ script_config = self.ScriptConfig[uuid.UUID(script_id)]
+
+ # 根据脚本类型选择添加对应用户配置
+ if isinstance(script_config, MaaConfig):
+ uid, config = await script_config.UserData.add(MaaUserConfig)
+ elif isinstance(script_config, SrcConfig):
+ uid, config = await script_config.UserData.add(SrcUserConfig)
+ elif isinstance(script_config, GeneralConfig):
+ uid, config = await script_config.UserData.add(GeneralUserConfig)
+ else:
+ uid, config = await script_config.UserData.add(MaaEndUserConfig)
+
+ return uid, config
+
+ async def update_user(
+ self, script_id: str, user_id: str, data: Dict[str, Dict[str, Any]]
+ ) -> None:
+ """更新用户配置"""
+
+ logger.info(f"{script_id} 更新用户配置: {user_id}")
+
+ await self.ScriptConfig[uuid.UUID(script_id)].UserData.update_item(user_id, data)
+
+ async def del_user(self, script_id: str, user_id: str) -> None:
+ """删除用户配置"""
+
+ logger.info(f"{script_id} 删除用户配置: {user_id}")
+
+ script_uid = uuid.UUID(script_id)
+ user_uid = uuid.UUID(user_id)
+
+ await self.ScriptConfig[script_uid].UserData.remove(user_uid)
+ if (Path.cwd() / f"data/{script_id}/{user_id}").exists():
+ shutil.rmtree(Path.cwd() / f"data/{script_id}/{user_id}")
+
+ async def reorder_user(self, script_id: str, index_list: list[str]) -> None:
+ """重新排序用户"""
+
+ logger.info(f"{script_id} 重新排序用户: {index_list}")
+
+ await self.ScriptConfig[uuid.UUID(script_id)].UserData.reorder_items(index_list)
+
+ async def set_infrastructure(
+ self, script_id: str, user_id: str, jsonFile: str
+ ) -> None:
+ logger.info(f"{script_id} - {user_id} 设置基建配置: {jsonFile}")
+
+ script_uid = uuid.UUID(script_id)
+ user_uid = uuid.UUID(user_id)
+ json_path = Path(jsonFile)
+
+ if not json_path.exists():
+ raise FileNotFoundError(f"文件未找到: {json_path}")
+
+ if not isinstance(self.ScriptConfig[script_uid], MaaConfig):
+ raise TypeError(f"脚本 {script_id} 不是 MAA 脚本, 无法设置基建配置")
+
+ infrast_data = json.loads(json_path.read_text(encoding="utf-8"))
+
+ if len(infrast_data.get("plans", [])) == 0:
+ raise ValueError("未找到有效的基建排班信息")
+
+ # 如果标题为默认标题, 则使用文件名作为标题
+ if infrast_data.get("title", "文件标题") == "文件标题":
+ infrast_data["title"] = json_path.stem
+
+ await (
+ self.ScriptConfig[script_uid]
+ .UserData[user_uid]
+ .set("Data", "CustomInfrast", json.dumps(infrast_data, ensure_ascii=False))
+ )
+
+ async def get_user_combox_infrastructure(
+ self, script_id: str, user_id: str
+ ) -> list[dict[str, str]]:
+ logger.info(f"获取用户自定义基建排班下拉框信息: {script_id} - {user_id}")
+
+ script_uid = uuid.UUID(script_id)
+ user_uid = uuid.UUID(user_id)
+
+ script_config = self.ScriptConfig[script_uid]
+
+ # 根据脚本类型选择添加对应用户配置
+ if not isinstance(script_config, MaaConfig):
+ raise TypeError(f"不支持的脚本配置类型: {type(script_config)}")
+
+ logger.info("开始获取用户自定义基建排班下拉框信息")
+
+ data: list[dict[str, str]] = []
+ for i, plan in enumerate(
+ json.loads(
+ script_config.UserData[user_uid].get("Data", "CustomInfrast")
+ ).get("plans", [])
+ ):
+ plan_mapping = cast(dict[str, Any], plan)
+ data.append(
+ {"label": str(plan_mapping.get("name", f"排班 {i+1}")), "value": str(i)}
+ )
+
+ logger.success("用户自定义基建排班下拉框信息获取成功")
+
+ return data
+
+ async def add_plan(
+ self, script: Literal["MaaPlan"]
+ ) -> tuple[uuid.UUID, MaaPlanConfig]:
+ """添加计划表"""
+
+ logger.info(f"添加计划表: {script}")
+
+ return await self.PlanConfig.add(MaaPlanConfig)
+
+ async def get_plan(
+ self, plan_id: Optional[str]
+ ) -> tuple[list[dict[str, str]], dict[str, Any]]:
+ """获取计划表配置"""
+
+ logger.info(f"获取计划表配置: {plan_id}")
+
+ return await self.PlanConfig.get_item(plan_id)
+
+ async def update_plan(self, plan_id: str, data: Dict[str, Dict[str, Any]]) -> None:
+ """更新计划表配置"""
+
+ logger.info(f"更新计划表配置: {plan_id}")
+
+ await self.PlanConfig.update_item(plan_id, data)
+
+ async def del_plan(self, plan_id: str) -> None:
+ """删除计划表配置"""
+
+ logger.info(f"删除计划表配置: {plan_id}")
+
+ await self.PlanConfig.del_item(plan_id)
+
+ async def reorder_plan(self, index_list: list[str]) -> None:
+ """重新排序计划表"""
+
+ logger.info(f"重新排序计划表: {index_list}")
+
+ await self.PlanConfig.reorder_items(index_list)
+
+ async def get_emulator(
+ self, emulator_id: Optional[str]
+ ) -> tuple[list[dict[str, str]], dict[str, Any]]:
+ """获取模拟器配置"""
+ logger.info(f"获取全局模拟器设置: {emulator_id}")
+
+ return await self.EmulatorConfig.get_item(emulator_id)
+
+ async def add_emulator(self) -> tuple[uuid.UUID, EmulatorConfig]:
+ """添加模拟器配置"""
+ logger.info("添加全局模拟器配置")
+
+ uid, config = await self.EmulatorConfig.add(EmulatorConfig)
+ return uid, config
+
+ async def update_emulator(
+ self, emulator_id: str, data: Dict[str, Dict[str, Any]]
+ ) -> None:
+ """更新模拟器配置"""
+
+ logger.info(f"更新模拟器配置: {emulator_id}")
+
+ await self.EmulatorConfig.update_item(emulator_id, data)
+
+ async def del_emulator(self, emulator_id: str) -> None:
+ """删除模拟器配置"""
+
+ logger.info(f"删除全局模拟器配置: {emulator_id}")
+
+ await self.EmulatorConfig.del_item(emulator_id)
+
+ async def reorder_emulator(self, index_list: list[str]) -> None:
+ """重新排序模拟器"""
+
+ logger.info(f"重新排序模拟器: {index_list}")
+
+ await self.EmulatorConfig.reorder_items(index_list)
+
+ async def add_queue(self) -> tuple[uuid.UUID, QueueConfig]:
+ """添加调度队列"""
+
+ logger.info("添加调度队列")
+
+ return await self.QueueConfig.add(QueueConfig)
+
+ async def get_queue(
+ self, queue_id: Optional[str]
+ ) -> tuple[list[dict[str, str]], dict[str, Any]]:
+ """获取调度队列配置"""
+
+ logger.info(f"获取调度队列配置: {queue_id}")
+
+ return await self.QueueConfig.get_item(queue_id)
+
+ async def update_queue(
+ self, queue_id: str, data: Dict[str, Dict[str, Any]]
+ ) -> None:
+ """更新调度队列配置"""
+
+ logger.info(f"更新调度队列配置: {queue_id}")
+
+ await self.QueueConfig.update_item(queue_id, data)
+
+ async def del_queue(self, queue_id: str) -> None:
+ """删除调度队列配置"""
+
+ logger.info(f"删除调度队列配置: {queue_id}")
+
+ await self.QueueConfig.del_item(queue_id)
+
+ async def reorder_queue(self, index_list: list[str]) -> None:
+ """重新排序调度队列"""
+
+ logger.info(f"重新排序调度队列: {index_list}")
+
+ await self.QueueConfig.reorder_items(index_list)
+
+ async def get_time_set(
+ self, queue_id: str, time_set_id: Optional[str]
+ ) -> tuple[list[dict[str, str]], dict[str, Any]]:
+ """获取时间设置配置"""
+
+ logger.info(f"获取队列的时间配置: {queue_id} - {time_set_id}")
+
+ return await self.QueueConfig[uuid.UUID(queue_id)].TimeSet.get_item(time_set_id)
+
+ async def add_time_set(self, queue_id: str) -> tuple[uuid.UUID, TimeSet]:
+ """添加时间设置配置"""
+
+ logger.info(f"{queue_id} 添加时间设置配置")
+
+ queue_uid = uuid.UUID(queue_id)
+ uid, config = await self.QueueConfig[queue_uid].TimeSet.add(TimeSet)
+
+ return uid, config
+
+ async def update_time_set(
+ self, queue_id: str, time_set_id: str, data: Dict[str, Dict[str, Any]]
+ ) -> None:
+ """更新时间设置配置"""
+
+ logger.info(f"{queue_id} 更新时间设置配置: {time_set_id}")
+
+ await self.QueueConfig[uuid.UUID(queue_id)].TimeSet.update_item(time_set_id, data)
+
+ async def del_time_set(self, queue_id: str, time_set_id: str) -> None:
+ """删除时间设置配置"""
+
+ logger.info(f"{queue_id} 删除时间设置配置: {time_set_id}")
+
+ await self.QueueConfig[uuid.UUID(queue_id)].TimeSet.del_item(time_set_id)
+
+ async def reorder_time_set(self, queue_id: str, index_list: list[str]) -> None:
+ """重新排序时间设置"""
+
+ logger.info(f"{queue_id} 重新排序时间设置: {index_list}")
+
+ await self.QueueConfig[uuid.UUID(queue_id)].TimeSet.reorder_items(index_list)
+
+ async def get_queue_item(
+ self, queue_id: str, queue_item_id: Optional[str]
+ ) -> tuple[list[dict[str, str]], dict[str, Any]]:
+ """获取队列项配置"""
+
+ logger.info(f"获取队列的队列项配置: {queue_id} - {queue_item_id}")
+
+ return await self.QueueConfig[uuid.UUID(queue_id)].QueueItem.get_item(queue_item_id)
+
+ async def add_queue_item(self, queue_id: str) -> tuple[uuid.UUID, QueueItem]:
+ """添加队列项配置"""
+
+ logger.info(f"{queue_id} 添加队列项配置")
+
+ queue_uid = uuid.UUID(queue_id)
+
+ uid, config = await self.QueueConfig[queue_uid].QueueItem.add(QueueItem)
+
+ return uid, config
+
+ async def update_queue_item(
+ self, queue_id: str, queue_item_id: str, data: Dict[str, Dict[str, Any]]
+ ) -> None:
+ """更新队列项配置"""
+
+ logger.info(f"{queue_id} 更新队列项配置: {queue_item_id}")
+
+ await self.QueueConfig[uuid.UUID(queue_id)].QueueItem.update_item(queue_item_id, data)
+
+ async def del_queue_item(self, queue_id: str, queue_item_id: str) -> None:
+ """删除队列项配置"""
+
+ logger.info(f"{queue_id} 删除队列项配置: {queue_item_id}")
+
+ await self.QueueConfig[uuid.UUID(queue_id)].QueueItem.del_item(queue_item_id)
+
+ async def reorder_queue_item(self, queue_id: str, index_list: list[str]) -> None:
+ """重新排序队列项"""
+
+ logger.info(f"{queue_id} 重新排序队列项: {index_list}")
+
+ await self.QueueConfig[uuid.UUID(queue_id)].QueueItem.reorder_items(index_list)
+
+ async def get_tools(self) -> Dict[str, Any]:
+ """获取工具设置"""
+
+ logger.debug("获取工具设置")
+
+ return await self.ToolsConfig.toDict()
+
+ async def update_tools(self, data: Dict[str, Dict[str, Any]]) -> None:
+ """更新工具设置"""
+
+ logger.info("更新工具设置")
+
+ await self.ToolsConfig.set_many(data)
+
+ logger.success("工具设置更新成功")
+
+ async def get_setting(self) -> Dict[str, Any]:
+ """获取全局设置"""
+
+ logger.info("获取全局设置")
+
+ return await self.toDict()
+
+ async def update_setting(self, data: Dict[str, Dict[str, Any]]) -> None:
+ """更新全局设置"""
+
+ logger.info("更新全局设置")
+
+ await self.set_many(data)
+
+ logger.success("全局设置更新成功")
+
+ async def get_webhook(
+ self,
+ script_id: Optional[str],
+ user_id: Optional[str],
+ webhook_id: Optional[str],
+ ) -> tuple[list[dict[str, str]], dict[str, Any]]:
+ """获取webhook配置"""
+
+ if script_id is None and user_id is None:
+ logger.info(f"获取全局webhook设置: {webhook_id}")
+
+ if webhook_id is None:
+ data = await self.Notify_CustomWebhooks.toDict()
+ else:
+ data = await self.Notify_CustomWebhooks.get(uuid.UUID(webhook_id))
+
+ else:
+ logger.info(f"获取webhook设置: {script_id} - {user_id} - {webhook_id}")
+
+ script_uid = uuid.UUID(script_id)
+ user_uid = uuid.UUID(user_id)
+
+ if webhook_id is None:
+ data = (
+ await self.ScriptConfig[script_uid]
+ .UserData[user_uid]
+ .Notify_CustomWebhooks.toDict()
+ )
+ else:
+ data = (
+ await self.ScriptConfig[script_uid]
+ .UserData[user_uid]
+ .Notify_CustomWebhooks.get(uuid.UUID(webhook_id))
+ )
+
+ index = data.pop("instances", [])
+ return list(index), data
+
+ async def add_webhook(
+ self, script_id: Optional[str], user_id: Optional[str]
+ ) -> tuple[uuid.UUID, Webhook]:
+ """添加webhook配置"""
+
+ if script_id is None and user_id is None:
+ logger.info("添加全局webhook配置")
+
+ uid, config = await self.Notify_CustomWebhooks.add(Webhook)
+ return uid, config
+
+ else:
+ logger.info(f"添加webhook配置: {script_id} - {user_id}")
+
+ script_uid = uuid.UUID(script_id)
+ user_uid = uuid.UUID(user_id)
+
+ uid, config = (
+ await self.ScriptConfig[script_uid]
+ .UserData[user_uid]
+ .Notify_CustomWebhooks.add(Webhook)
+ )
+ return uid, config
+
+ async def update_webhook(
+ self,
+ script_id: Optional[str],
+ user_id: Optional[str],
+ webhook_id: str,
+ data: Dict[str, Dict[str, Any]],
+ ) -> None:
+ """更新 webhook 配置"""
+
+ webhook_uid = uuid.UUID(webhook_id)
+
+ if script_id is None and user_id is None:
+ logger.info(f"更新 webhook 全局配置: {webhook_id}")
+
+ await self.Notify_CustomWebhooks[webhook_uid].set_many(data)
+
+ else:
+ logger.info(f"更新 webhook 配置: {script_id} - {user_id} - {webhook_id}")
+
+ script_uid = uuid.UUID(script_id)
+ user_uid = uuid.UUID(user_id)
+
+ await (
+ self.ScriptConfig[script_uid]
+ .UserData[user_uid]
+ .Notify_CustomWebhooks[webhook_uid]
+ .set_many(data)
+ )
+
+ async def del_webhook(
+ self, script_id: Optional[str], user_id: Optional[str], webhook_id: str
+ ) -> None:
+ """删除 webhook 配置"""
+
+ webhook_uid = uuid.UUID(webhook_id)
+
+ if script_id is None and user_id is None:
+ logger.info(f"删除全局 webhook 配置: {webhook_id}")
+
+ await self.Notify_CustomWebhooks.remove(webhook_uid)
+
+ else:
+ logger.info(f"删除 webhook 配置: {script_id} - {user_id} - {webhook_id}")
+
+ script_uid = uuid.UUID(script_id)
+ user_uid = uuid.UUID(user_id)
+
+ await (
+ self.ScriptConfig[script_uid]
+ .UserData[user_uid]
+ .Notify_CustomWebhooks.remove(webhook_uid)
+ )
+
+ async def reorder_webhook(
+ self, script_id: Optional[str], user_id: Optional[str], index_list: list[str]
+ ) -> None:
+ """重新排序 webhook"""
+
+ if script_id is None and user_id is None:
+ logger.info(f"重新排序全局 webhook: {index_list}")
+
+ await self.Notify_CustomWebhooks.setOrder(list(map(uuid.UUID, index_list)))
+
+ else:
+ logger.info(f"重新排序 webhook: {script_id} - {user_id} - {index_list}")
+
+ script_uid = uuid.UUID(script_id)
+ user_uid = uuid.UUID(user_id)
+
+ await (
+ self.ScriptConfig[script_uid]
+ .UserData[user_uid]
+ .Notify_CustomWebhooks.setOrder(list(map(uuid.UUID, index_list)))
+ )
+
+ @property
+ def proxy(self) -> Optional[httpx.Proxy]:
+ """获取代理设置,返回适用于 httpx 的代理对象"""
+ proxy_addr = self.get("Update", "ProxyAddress")
+ if not proxy_addr:
+ return None
+
+ # 如果地址不包含协议,默认为 http
+ if not proxy_addr.startswith(("http://", "https://", "socks5://", "socks4://")):
+ proxy_addr = f"http://{proxy_addr}"
+
+ try:
+ return httpx.Proxy(proxy_addr)
+ except ValueError as e:
+ logger.warning(f"代理配置无效: {proxy_addr}, 错误: {e}")
+ return None
+
+ async def get_stage_info(
+ self,
+ type: Literal[
+ "User",
+ "Today",
+ "ALL",
+ "Monday",
+ "Tuesday",
+ "Wednesday",
+ "Thursday",
+ "Friday",
+ "Saturday",
+ "Sunday",
+ "Info",
+ ],
+ ) -> dict[str, Any] | list[dict[str, str]]:
+ """获取关卡信息"""
+
+ stage_cache = cast(dict[str, Any], json.loads(self.get("Data", "Stage")))
+ if stage_cache != {}:
+ task = asyncio.create_task(self.get_stage())
+ self.temp_task.append(task)
+ task.add_done_callback(
+ lambda t: self.temp_task.remove(t) if t in self.temp_task else None
+ )
+ else:
+ refreshed = await self.get_stage()
+ stage_cache = cast(
+ dict[str, Any], refreshed if refreshed is not None else {}
+ )
+
+ if type == "Info":
+ today = datetime.now(tz=UTC4).isoweekday()
+ res_stage_info: list[dict[str, Any]] = []
+ for stage in RESOURCE_STAGE_INFO:
+ days = stage.get("days")
+ if (
+ isinstance(days, list)
+ and today in days
+ and stage["value"] in RESOURCE_STAGE_DROP_INFO
+ ):
+ res_stage_info.append(RESOURCE_STAGE_DROP_INFO[stage["value"]])
+ return {
+ "Activity": stage_cache.get("Info", []),
+ "Resource": res_stage_info,
+ }
+ elif type == "User":
+ data = cast(list[dict[str, str]], stage_cache.get("ALL", []))
+ for combox in data:
+ combox["label"] = RESOURCE_STAGE_DATE_TEXT.get(
+ combox["value"], combox["label"]
+ )
+ return data
+ elif type == "Today":
+ return cast(
+ list[dict[str, str]],
+ stage_cache.get(datetime.now(tz=UTC4).strftime("%A"), []),
+ )
+ else:
+ return cast(list[dict[str, str]], stage_cache.get(type, []))
+
+ async def get_proxy_overview(self) -> Dict[str, Any]:
+ """获取代理情况概览信息"""
+
+ logger.info("获取代理情况概览信息")
+
+ history_index = await self.search_history(
+ "DAILY", datetime.now(tz=UTC4).date(), datetime.now(tz=UTC4).date()
+ )
+ if datetime.now(tz=UTC4).strftime("%Y-%m-%d") not in history_index:
+ return {}
+ today_records = history_index[datetime.now(tz=UTC4).strftime("%Y-%m-%d")]
+ merged_list = await asyncio.gather(
+ *(self.merge_statistic_info(v) for v in today_records.values())
+ )
+ history_data = {
+ user: merged
+ for user, merged in zip(today_records.keys(), merged_list, strict=False)
+ }
+ overview: dict[str, dict[str, Any]] = {}
+ for user, data in history_data.items():
+ index_data = data.get("index", [])
+ if index_data:
+ last_proxy_date = max(
+ datetime.strptime(_["date"], "%Y-%m-%d %H:%M:%S")
+ for _ in index_data
+ ).strftime("%Y-%m-%d %H:%M:%S")
+ else:
+ last_proxy_date = "暂无代理数据"
+ proxy_times = len(data.get("index", []))
+ error_info = data.get("error_info", {})
+ error_times = len(error_info)
+ overview[user] = {
+ "LastProxyDate": last_proxy_date,
+ "ProxyTimes": proxy_times,
+ "ErrorTimes": error_times,
+ "ErrorInfo": error_info,
+ }
+ return overview
+
+ async def get_stage(self) -> Optional[Dict[str, List[Dict[str, str]]]]:
+ """更新活动关卡信息"""
+
+ if datetime.now() - timedelta(hours=1) < datetime.strptime(
+ self.get("Data", "LastStageUpdated"), "%Y-%m-%d %H:%M:%S"
+ ):
+ logger.info("一小时内已进行过一次检查, 直接使用缓存的活动关卡信息")
+ return json.loads(self.get("Data", "Stage"))
+
+ logger.info("开始获取活动关卡信息")
+ try:
+ async with httpx.AsyncClient(
+ proxy=self.proxy, follow_redirects=True
+ ) as client:
+ response = await client.get(
+ "https://api.maa.plus/MaaAssistantArknights/api/gui/StageActivityV2.json",
+ headers={"If-None-Match": self.get("Data", "StageETag")},
+ )
+
+ if response.status_code == 304:
+ logger.info("关卡信息未更新,使用本地缓存的活动关卡信息")
+ await self.set(
+ "Data",
+ "LastStageUpdated",
+ datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ )
+ elif response.status_code == 200:
+ logger.success("成功获取远端活动关卡信息")
+ await self.set(
+ "Data",
+ "LastStageUpdated",
+ datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ )
+ await self.set(
+ "Data",
+ "StageETag",
+ response.headers.get("ETag")
+ or response.headers.get("etag")
+ or "",
+ )
+ await self.set(
+ "Data",
+ "StageData",
+ json.dumps(
+ response.json()
+ .get("Official", {})
+ .get("sideStoryStage", {}),
+ ensure_ascii=False,
+ ),
+ )
+ else:
+ logger.warning(f"无法从MAA服务器获取活动关卡信息:{response.text}")
+ except (httpx.HTTPError, ValueError, json.JSONDecodeError) as e:
+ logger.warning(f"无法从MAA服务器获取活动关卡信息: {e}")
+
+ return json.loads(self.get("Data", "Stage"))
+
+ async def get_script_combox(self):
+ """获取脚本下拉框信息"""
+
+ logger.info("开始获取脚本下拉框信息")
+ data = [{"label": "未选择", "value": "-"}]
+ for uid, script in self.ScriptConfig.items():
+ data.append(
+ {
+ "label": f"{TYPE_BOOK[type(script).__name__]} - {script.get('Info', 'Name')}",
+ "value": str(uid),
+ }
+ )
+ logger.success("脚本下拉框信息获取成功")
+
+ return data
+
+ async def get_task_combox(self):
+ """获取任务下拉框信息"""
+
+ logger.info("开始获取任务下拉框信息")
+ data = [{"label": "未选择", "value": None}]
+ for uid, queue in self.QueueConfig.items():
+ data.append(
+ {
+ "label": f"队列 - {queue.get('Info', 'Name')}",
+ "value": str(uid),
+ }
+ )
+ for uid, script in self.ScriptConfig.items():
+ if not script.is_locked:
+ data.append(
+ {
+ "label": f"脚本 - {TYPE_BOOK[type(script).__name__]} - {script.get('Info', 'Name')}",
+ "value": str(uid),
+ }
+ )
+ logger.success("任务下拉框信息获取成功")
+
+ return data
+
+ async def get_plan_combox(self):
+ """获取计划下拉框信息"""
+
+ logger.info("开始获取计划下拉框信息")
+ data = [{"label": "固定", "value": "Fixed"}]
+ for uid, plan in self.PlanConfig.items():
+ data.append({"label": plan.get("Info", "Name"), "value": str(uid)})
+ logger.success("计划下拉框信息获取成功")
+
+ return data
+
+ async def get_emulator_combox(self):
+ """获取模拟器下拉框信息"""
+
+ logger.info("开始获取模拟器下拉框信息")
+ data = [{"label": "未选择", "value": "-"}]
+ for uid, emulator in self.EmulatorConfig.items():
+ data.append({"label": emulator.get("Info", "Name"), "value": str(uid)})
+ logger.success("模拟器下拉框信息获取成功")
+ return data
+
+ async def get_emulator_devices_combox(
+ self, emulator_id: str
+ ) -> list[dict[str, str]]:
+ """获取模拟器多开实例下拉框信息"""
+
+ logger.info("开始获取模拟器下拉框信息")
+
+ if self.EmulatorConfig[uuid.UUID(emulator_id)].get("Info", "Type") == "general":
+ logger.info("通用模拟器不支持扫描多开实例, 返回空列表")
+ return []
+
+ data: list[dict[str, str]] = [{"label": "未选择", "value": "-"}]
+
+ from ..emulator_manager import EmulatorManager
+
+ for index, device in (
+ await (await EmulatorManager.get_emulator_instance(emulator_id)).getInfo(
+ None
+ )
+ ).items():
+ data.append({"label": device.title, "value": index})
+
+ logger.success("模拟器下拉框信息获取成功")
+
+ return data
+
+ async def get_notice(self) -> tuple[bool, Dict[str, str]]:
+ """获取公告信息"""
+
+ if datetime.now() - timedelta(hours=1) < datetime.strptime(
+ self.get("Data", "LastNoticeUpdated"), "%Y-%m-%d %H:%M:%S"
+ ):
+ logger.info("一小时内已进行过一次检查, 直接使用缓存的公告信息")
+ return False, json.loads(self.get("Data", "Notice")).get("notice_dict", {})
+
+ logger.info("开始从 AUTO-MAS 服务器获取公告信息")
+ try:
+ async with httpx.AsyncClient(
+ proxy=self.proxy, follow_redirects=True
+ ) as client:
+ response = await client.get(
+ "https://api.auto-mas.top/file/Server/notice.json",
+ headers={"If-None-Match": self.get("Data", "NoticeETag")},
+ )
+ if response.status_code == 304:
+ logger.info("公告未更新,使用本地缓存的公告信息")
+ await self.set(
+ "Data",
+ "LastNoticeUpdated",
+ datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ )
+ elif response.status_code == 200:
+ logger.info("公告已更新,要求展示公告信息")
+ await self.set(
+ "Data",
+ "LastNoticeUpdated",
+ datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ )
+ await self.set(
+ "Data",
+ "NoticeETag",
+ response.headers.get("ETag")
+ or response.headers.get("etag")
+ or "",
+ )
+ await self.set("Data", "IfShowNotice", True)
+ await self.set(
+ "Data",
+ "Notice",
+ json.dumps(response.json(), ensure_ascii=False),
+ )
+ else:
+ logger.warning(
+ f"无法从 AUTO-MAS 服务器获取公告信息:{response.text}"
+ )
+ except (httpx.HTTPError, ValueError, json.JSONDecodeError) as e:
+ logger.warning(f"无法从 AUTO-MAS 服务器获取公告信息: {e}")
+
+ return self.get("Data", "IfShowNotice"), json.loads(
+ self.get("Data", "Notice")
+ ).get("notice_dict", {})
+
+ async def get_web_config(self):
+ """获取「AUTO-MAS 配置分享中心」配置"""
+
+ local_web_config = json.loads(self.get("Data", "WebConfig"))
+ if datetime.now() - timedelta(hours=1) < datetime.strptime(
+ self.get("Data", "LastWebConfigUpdated"), "%Y-%m-%d %H:%M:%S"
+ ):
+ logger.info("一小时内已进行过一次检查, 直接使用缓存的配置分享中心信息")
+ return local_web_config
+
+ logger.info("开始从 AUTO-MAS 服务器获取配置分享中心信息")
+
+ try:
+ async with httpx.AsyncClient(
+ proxy=self.proxy, follow_redirects=True
+ ) as client:
+ response = await client.get(
+ "https://share.auto-mas.top/api/list/config/general"
+ )
+ if response.status_code == 200:
+ remote_web_config = response.json()
+ else:
+ logger.warning(
+ f"无法从 AUTO-MAS 服务器获取配置分享中心信息:{response.text}"
+ )
+ remote_web_config = None
+ except (httpx.HTTPError, ValueError, json.JSONDecodeError) as e:
+ logger.warning(f"无法从 AUTO-MAS 服务器获取配置分享中心信息: {e}")
+ remote_web_config = None
+
+ if remote_web_config is None:
+ logger.warning("使用本地配置分享中心信息")
+ return local_web_config
+
+ await self.set(
+ "Data", "LastWebConfigUpdated", datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ )
+ await self.set(
+ "Data", "WebConfig", json.dumps(remote_web_config, ensure_ascii=False)
+ )
+
+ return remote_web_config
+
+ async def save_maa_log(
+ self, log_path: Path, logs: list[str], maa_result: str
+ ) -> bool:
+ """
+ 保存MAA日志并生成对应统计数据
+
+ Args:
+ log_path (Path): 日志文件保存路径
+ logs (list): 日志列表
+ maa_result (str): MAA任务结果
+ Returns:
+ bool: 是否存在高资
+ """
+
+ return await self._log_service.save_maa_log(log_path, logs, maa_result)
+
+ async def save_maaend_log(
+ self, log_path: Path, logs: list[str], maaend_result: str
+ ) -> None:
+ """
+ Save MaaEnd logs and generate basic statistics data.
+
+ Args:
+ log_path (Path): Target log file path.
+ logs (list[str]): Log lines.
+ maaend_result (str): Result label for this run.
+ """
+
+ await self._log_service.save_maaend_log(log_path, logs, maaend_result)
+
+ async def save_src_log(
+ self, log_path: Path, logs: list[str], src_result: str
+ ) -> None:
+ """
+ 保存SRC日志并生成对应统计数据
+
+ Args:
+ log_path (Path): 日志文件保存路径
+ logs (list): 日志内容列表
+ src_result (str): 待保存的日志结果信息
+ """
+
+ await self._log_service.save_src_log(log_path, logs, src_result)
+
+ async def save_general_log(
+ self, log_path: Path, logs: list[str], general_result: str
+ ) -> None:
+ """
+ 保存通用日志并生成对应统计数据
+
+ :param log_path: 日志文件保存路径
+ :param logs: 日志内容列表
+ :param general_result: 待保存的日志结果信息
+ """
+
+ await self._log_service.save_general_log(log_path, logs, general_result)
+
+ async def merge_statistic_info(
+ self, statistic_path_list: List[Path]
+ ) -> dict[str, Any]:
+ """
+ 合并指定数据统计信息文件
+
+ Args:
+ statistic_path_list (List[Path]): 数据统计信息文件列表
+
+ Returns:
+ dict: 合并后的数据统计信息
+ """
+
+ return await self._log_service.merge_statistic_info(statistic_path_list)
+
+ async def search_history(
+ self,
+ mode: Literal["DAILY", "WEEKLY", "MONTHLY"],
+ start_date: date,
+ end_date: date,
+ ) -> dict[str, dict[str, list[Path]]]:
+ """
+ 搜索指定时间范围内的历史记录
+
+ Args:
+ mode (Literal["DAILY", "WEEKLY", "MONTHLY"]): 合并模式
+ start_date (date): 开始日期
+ end_date (date): 结束日期
+ """
+
+ return await self._log_service.search_history(mode, start_date, end_date)
+
+ async def clean_old_history(self):
+ """删除超过用户设定天数的历史记录文件(基于目录日期)"""
+
+ await self._log_service.clean_old_history(self.get("Function", "HistoryRetentionTime"))
+
+
+Config = AppConfig()
diff --git a/app/core/config/pydantic.py b/app/core/config/pydantic.py
new file mode 100644
index 00000000..6c2dfd2c
--- /dev/null
+++ b/app/core/config/pydantic.py
@@ -0,0 +1,1155 @@
+from __future__ import annotations
+
+import asyncio
+import ast
+import difflib
+import inspect
+import re
+import textwrap
+import uuid
+from contextlib import asynccontextmanager
+from pathlib import Path
+from collections.abc import Callable, Coroutine
+from collections.abc import AsyncGenerator
+from typing import Any, ClassVar, TypeVar, cast
+
+from pydantic import AliasChoices, AliasPath, BaseModel, ConfigDict, PrivateAttr
+
+from .base import (
+ MultipleConfig,
+ MultipleConfigDeleteEvent,
+ atomic_write_text,
+ backup_legacy_config_if_needed,
+ dump_toml,
+ load_config_with_legacy_migration,
+)
+from .fields import RefField, VirtualField
+from .types import EncryptedFieldMarker, decrypt_encrypted_string
+
+from app.utils import get_logger
+
+logger = get_logger("配置基类")
+
+
+SaveMethod = Callable[[], Coroutine[Any, Any, None]]
+Slot = Callable[[Any], Any] | Callable[[Any], Coroutine[Any, Any, Any]]
+
+
+def _default_save_methods() -> list[SaveMethod]:
+ return []
+
+
+def _default_bindings() -> dict[tuple[str, str], list[Slot]]:
+ return {}
+
+
+def _default_pending_bindings() -> dict[tuple[str, str], Any]:
+ return {}
+
+
+def _default_registered_ref_targets() -> set[str]:
+ return set()
+
+
+def _default_virtual_dependencies_cache() -> (
+ dict[tuple[str, str], tuple[tuple[str, str], ...]]
+):
+ return {}
+
+
+def _default_ref_validation_cache() -> dict[tuple[str, str, tuple[str, ...], str], Any]:
+ return {}
+
+
+def _normalize_mapping(value: Any) -> dict[str, Any]:
+ """将任意映射值规范化为 `dict[str, Any]`。"""
+
+ if not isinstance(value, dict):
+ return {}
+ mapping = cast(dict[object, Any], value)
+ return {str(key): item for key, item in mapping.items()}
+
+
+_SNAKE_1 = re.compile(r"(.)([A-Z][a-z]+)")
+_SNAKE_2 = re.compile(r"([a-z0-9])([A-Z])")
+
+
+def _to_snake_case(name: str) -> str:
+ """将 ``PascalCase``/``camelCase`` 名称转换为 ``snake_case``。
+
+ 该函数用于统一配置协议键名输出,并在输入解析时做名称归一化匹配。
+
+ Args:
+ name: 原始名称。
+
+ Returns:
+ snake_case 格式名称。
+ """
+
+ first_pass = _SNAKE_1.sub(r"\1_\2", name)
+ return _SNAKE_2.sub(r"\1_\2", first_pass).lower()
+
+
+def _resolve_alias_paths(validation_alias: Any) -> list[tuple[str, ...]]:
+ if validation_alias is None:
+ return []
+
+ if isinstance(validation_alias, str):
+ return [(validation_alias,)]
+
+ if isinstance(validation_alias, AliasPath):
+ path = tuple(
+ item
+ for item in validation_alias.path
+ if isinstance(item, str) and item != ""
+ )
+ return [path] if path else []
+
+ if isinstance(validation_alias, AliasChoices):
+ alias_paths: list[tuple[str, ...]] = []
+ for choice in validation_alias.choices:
+ alias_paths.extend(_resolve_alias_paths(choice))
+ return alias_paths
+
+ return []
+
+
+def _try_resolve_alias_value(
+ raw: dict[str, Any],
+ group_name: str,
+ group_data: dict[str, Any],
+ validation_alias: Any,
+) -> tuple[Any, bool]:
+ for alias_path in _resolve_alias_paths(validation_alias):
+ if not alias_path:
+ continue
+
+ if len(alias_path) == 1:
+ alias_key = alias_path[0]
+ if alias_key in group_data:
+ return group_data[alias_key], True
+ continue
+
+ current: Any
+ root_key = alias_path[0]
+ if root_key == group_name:
+ current = group_data
+ walk_keys = alias_path[1:]
+ else:
+ current = _normalize_mapping(raw.get(root_key, {}))
+ walk_keys = alias_path[1:]
+
+ matched = True
+ for key in walk_keys:
+ if not isinstance(current, dict):
+ matched = False
+ break
+
+ current_dict = cast(dict[str, Any], current)
+ if key not in current_dict:
+ matched = False
+ break
+
+ current = current_dict[key]
+
+ if matched:
+ return cast(Any, current), True
+
+ return None, False
+
+
+FieldMarkerT = TypeVar("FieldMarkerT")
+
+
+def _get_field_marker(
+ group_model: BaseModel, field_name: str, marker_type: type[FieldMarkerT]
+) -> FieldMarkerT | None:
+ """获取字段上声明的特定元数据。"""
+
+ field = type(group_model).model_fields.get(field_name)
+ if field is None:
+ return None
+
+ for marker in field.metadata:
+ if isinstance(marker, marker_type):
+ return marker
+
+ return None
+
+
+def _is_encrypted_field(group_model: BaseModel, field_name: str) -> bool:
+ """判断字段是否为对外需要自动解密的字符串字段。"""
+
+ return _get_field_marker(group_model, field_name, EncryptedFieldMarker) is not None
+
+
+def _export_group_model(
+ owner: "PydanticConfigBase",
+ group_name: str,
+ group_model: BaseModel,
+ if_decrypt: bool,
+ skip_virtual: bool,
+) -> dict[str, Any]:
+ """将分组模型导出为字典,并按需解密加密字段。"""
+
+ data: dict[str, Any] = {}
+
+ for field_name in type(group_model).model_fields:
+ virtual_field = _get_field_marker(group_model, field_name, VirtualField)
+ if virtual_field is not None:
+ if skip_virtual:
+ continue
+ data[_to_snake_case(field_name)] = owner.get_virtual_value(
+ group_name, field_name, virtual_field
+ )
+ continue
+
+ value = getattr(group_model, field_name)
+
+ if isinstance(value, BaseModel):
+ data[_to_snake_case(field_name)] = _export_group_model(
+ owner, field_name, value, if_decrypt, skip_virtual
+ )
+ continue
+
+ if if_decrypt and _is_encrypted_field(group_model, field_name):
+ data[_to_snake_case(field_name)] = decrypt_encrypted_string(str(value))
+ continue
+
+ data[_to_snake_case(field_name)] = value
+
+ return data
+
+
+class PydanticConfigBase(BaseModel):
+ """基于 pydantic v2 的配置基类,兼容旧版 ConfigBase 常用接口。"""
+
+ model_config = ConfigDict(extra="forbid", validate_assignment=True)
+
+ LEGACY_FIELD_MAP: ClassVar[dict[tuple[str, str], tuple[str, str]]] = {}
+ related_config: ClassVar[dict[str, MultipleConfig[Any]]] = {}
+ _class_virtual_dependencies: ClassVar[
+ dict[
+ type["PydanticConfigBase"],
+ dict[tuple[str, str], tuple[tuple[str, str], ...]],
+ ]
+ ] = {}
+
+ @classmethod
+ def __init_subclass__(cls, **kwargs: Any) -> None:
+ super().__init_subclass__(**kwargs)
+ cls.related_config = {}
+ cls._class_virtual_dependencies = {}
+
+ _file: Path | None = PrivateAttr(default=None)
+ _is_locked: bool = PrivateAttr(default=False)
+ _save_methods: list[SaveMethod] = PrivateAttr(default_factory=_default_save_methods)
+ _bindings: dict[tuple[str, str], list[Slot]] = PrivateAttr(
+ default_factory=_default_bindings
+ )
+ _transaction_depth: int = PrivateAttr(default=0)
+ _pending_save: bool = PrivateAttr(default=False)
+ _pending_sync: bool = PrivateAttr(default=False)
+ _pending_bindings: dict[tuple[str, str], Any] = PrivateAttr(
+ default_factory=_default_pending_bindings
+ )
+ _registered_ref_targets: set[str] = PrivateAttr(
+ default_factory=_default_registered_ref_targets
+ )
+ _owner_collection: MultipleConfig[Any] | None = PrivateAttr(default=None)
+ _owner_uid: uuid.UUID | None = PrivateAttr(default=None)
+ _mutex: asyncio.Lock = PrivateAttr(default_factory=asyncio.Lock)
+ _virtual_dependencies_cache: dict[tuple[str, str], tuple[tuple[str, str], ...]] = (
+ PrivateAttr(default_factory=_default_virtual_dependencies_cache)
+ )
+ _ref_validation_cache: dict[tuple[str, str, tuple[str, ...], str], Any] = (
+ PrivateAttr(default_factory=_default_ref_validation_cache)
+ )
+ _ref_validation_cache_enabled: bool = PrivateAttr(default=False)
+ _group_index_cache: dict[str, BaseModel] | None = PrivateAttr(default=None)
+ _multiple_config_index_cache: dict[str, MultipleConfig[Any]] | None = PrivateAttr(
+ default=None
+ )
+
+ @property
+ def file(self) -> Path | None:
+ return self._file
+
+ @property
+ def is_locked(self) -> bool:
+ return self._is_locked
+
+ def model_post_init(self, __context: Any) -> None:
+ self._build_virtual_dependency_cache()
+ self._register_ref_bindings()
+
+ def _suggest_candidates(self, value: str, candidates: list[str]) -> list[str]:
+ if not candidates:
+ return []
+
+ snake = _to_snake_case(value)
+ exact_snake_matches = [
+ candidate for candidate in candidates if _to_snake_case(candidate) == snake
+ ]
+ if exact_snake_matches:
+ return exact_snake_matches[:3]
+
+ close_matches = difflib.get_close_matches(value, candidates, n=3, cutoff=0.5)
+ if close_matches:
+ return close_matches
+
+ snake_matches = difflib.get_close_matches(
+ snake,
+ [_to_snake_case(candidate) for candidate in candidates],
+ n=3,
+ cutoff=0.5,
+ )
+ mapped: list[str] = []
+ for snake_match in snake_matches:
+ for candidate in candidates:
+ if _to_snake_case(candidate) == snake_match and candidate not in mapped:
+ mapped.append(candidate)
+ return mapped[:3]
+
+ def _resolve_group_name(self, group: str) -> str:
+ """将调用侧传入的分组名解析为模型中的真实分组名。
+
+ 支持两类匹配:
+ - 精确匹配(如 ``Info``)
+ - snake_case 等价匹配(如 ``info``)
+
+ Args:
+ group: 外部输入的分组名。
+
+ Returns:
+ 配置模型中的真实分组名。
+
+ Raises:
+ AttributeError: 分组不存在时抛出。
+ """
+
+ groups = self._group_index()
+ if group in groups:
+ return group
+
+ snake = _to_snake_case(group)
+ for candidate in groups:
+ if _to_snake_case(candidate) == snake:
+ return candidate
+
+ suggestions = self._suggest_candidates(group, list(groups.keys()))
+ if suggestions:
+ raise AttributeError(
+ f"配置分组 '{group}' 不存在。你可能想用: {', '.join(suggestions)}"
+ )
+ raise AttributeError(
+ f"配置分组 '{group}' 不存在。可用分组: {', '.join(groups.keys())}"
+ )
+
+ def _resolve_field_name(self, group: str, name: str) -> str:
+ """将字段名解析为指定分组中的真实字段名。
+
+ 先解析分组,再在该分组下执行字段名精确匹配与 snake_case 等价匹配。
+
+ Args:
+ group: 分组名(可为原名或 snake_case)。
+ name: 字段名(可为原名或 snake_case)。
+
+ Returns:
+ 配置模型中的真实字段名。
+
+ Raises:
+ AttributeError: 分组或字段不存在时抛出。
+ """
+
+ resolved_group = self._resolve_group_name(group)
+ group_model = self._group_index().get(resolved_group)
+ if group_model is None:
+ raise AttributeError(f"配置分组 '{group}' 不存在")
+
+ fields = type(group_model).model_fields
+ if name in fields:
+ return name
+
+ snake = _to_snake_case(name)
+ for candidate in fields:
+ if _to_snake_case(candidate) == snake:
+ return candidate
+
+ field_candidates = list(fields.keys())
+ suggestions = self._suggest_candidates(name, field_candidates)
+ if suggestions:
+ raise AttributeError(
+ f"配置项 '{group}.{name}' 不存在。你可能想用: "
+ f"{resolved_group}.{suggestions[0]}"
+ )
+ raise AttributeError(
+ f"配置项 '{group}.{name}' 不存在。"
+ f"可用字段: {', '.join(field_candidates)}"
+ )
+
+ def _normalize_dependency(self, dependency: tuple[str, str]) -> tuple[str, str]:
+ """将虚拟字段依赖项规范化为真实 ``(group, field)`` 对。
+
+ 主要用于把手工声明依赖或自动推导依赖统一为内部可比较的标准形式。
+
+ Args:
+ dependency: 原始依赖项。
+
+ Returns:
+ 规范化后的依赖项。
+ """
+
+ group, name = dependency
+ resolved_group = self._resolve_group_name(group)
+ resolved_name = self._resolve_field_name(resolved_group, name)
+ return resolved_group, resolved_name
+
+ def _infer_virtual_dependencies(self, getter: str) -> tuple[tuple[str, str], ...]:
+ """从虚拟字段 getter 源码中自动推导依赖字段。
+
+ 推导策略:
+ - 解析方法 AST;
+ - 扫描 ``self.get("Group", "Field")`` 形式调用;
+ - 对提取结果执行名称归一化与去重。
+
+ 注意:若源码不可获取、语法不可解析或调用参数非常量字符串,
+ 对应依赖将被安全忽略。
+
+ Args:
+ getter: getter 方法名。
+
+ Returns:
+ 推导出的依赖项元组。
+ """
+
+ method = getattr(type(self), getter, None)
+ if method is None:
+ return ()
+
+ try:
+ source = inspect.getsource(method)
+ except (OSError, TypeError):
+ return ()
+
+ try:
+ tree = ast.parse(textwrap.dedent(source))
+ except SyntaxError:
+ return ()
+
+ dependencies: list[tuple[str, str]] = []
+ seen: set[tuple[str, str]] = set()
+
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Call):
+ continue
+ if not isinstance(node.func, ast.Attribute):
+ continue
+ if node.func.attr != "get":
+ continue
+ if (
+ not isinstance(node.func.value, ast.Name)
+ or node.func.value.id != "self"
+ ):
+ continue
+ if len(node.args) < 2:
+ continue
+
+ arg_group = node.args[0]
+ arg_name = node.args[1]
+ if not (
+ isinstance(arg_group, ast.Constant)
+ and isinstance(arg_group.value, str)
+ and isinstance(arg_name, ast.Constant)
+ and isinstance(arg_name.value, str)
+ ):
+ continue
+
+ try:
+ dep = self._normalize_dependency((arg_group.value, arg_name.value))
+ except AttributeError:
+ continue
+
+ if dep in seen:
+ continue
+ seen.add(dep)
+ dependencies.append(dep)
+
+ return tuple(dependencies)
+
+ def _build_virtual_dependency_cache(self) -> None:
+ """构建虚拟字段依赖缓存。
+
+ 缓存键为 ``(group, field)``,值为该虚拟字段依赖列表。
+ 规则:
+ - 优先使用显式 ``depends_on``;
+ - 否则对字符串 getter 执行自动推导;
+ - 其他情况依赖为空。
+
+ 缓存在 ``model_post_init`` 阶段构建,用于 ``set`` 触发时高效判断
+ 哪些虚拟字段需要重新计算并派发绑定事件。
+ """
+
+ cls = type(self)
+ class_cache = cls._class_virtual_dependencies.get(cls)
+ if class_cache is not None:
+ self._virtual_dependencies_cache = class_cache
+ return
+
+ class_cache = {}
+
+ for group_name, group_model in self._group_index().items():
+ for field_name in type(group_model).model_fields:
+ virtual_field = _get_field_marker(group_model, field_name, VirtualField)
+ if virtual_field is None:
+ continue
+
+ if virtual_field.depends_on:
+ deps = tuple(
+ self._normalize_dependency(dep)
+ for dep in virtual_field.depends_on
+ )
+ elif isinstance(virtual_field.getter, str):
+ deps = self._infer_virtual_dependencies(virtual_field.getter)
+ if deps:
+ logger.debug(
+ f"虚拟字段 {group_name}.{field_name} 通过 AST 自动推导依赖: {deps}。"
+ f"建议显式声明 depends_on 以提高可靠性。"
+ )
+ else:
+ deps = ()
+
+ class_cache[(group_name, field_name)] = deps
+
+ cls._class_virtual_dependencies[cls] = class_cache
+ self._virtual_dependencies_cache = class_cache
+
+ def _multiple_config_index(self) -> dict[str, MultipleConfig[Any]]:
+ if self._multiple_config_index_cache is not None:
+ return self._multiple_config_index_cache
+ result: dict[str, MultipleConfig[Any]] = {}
+ for name, value in self.__dict__.items():
+ if isinstance(value, MultipleConfig):
+ result[name] = value
+ self._multiple_config_index_cache = result
+ return result
+
+ def _group_index(self) -> dict[str, BaseModel]:
+ if self._group_index_cache is not None:
+ return self._group_index_cache
+ result: dict[str, BaseModel] = {}
+ for name in type(self).model_fields:
+ value = getattr(self, name, None)
+ if isinstance(value, BaseModel):
+ result[name] = value
+ self._group_index_cache = result
+ return result
+
+ def _normalize_value(self, group: str, name: str, value: Any) -> Any:
+ ref_field = self._get_ref_field(group, name)
+ if ref_field is not None:
+ if self._ref_validation_cache_enabled:
+ return self._normalize_ref_value_cached(ref_field, value)
+ return self._normalize_ref_value(ref_field, value)
+ return value
+
+ def _normalize_ref_value_cached(self, spec: RefField, value: Any) -> Any:
+ cache_key = (
+ spec.target,
+ str(spec.default),
+ tuple(str(item) for item in spec.allow_values),
+ str(value),
+ )
+ if cache_key in self._ref_validation_cache:
+ return self._ref_validation_cache[cache_key]
+
+ normalized = self._normalize_ref_value(spec, value)
+ self._ref_validation_cache[cache_key] = normalized
+ return normalized
+
+ def _bind_owner_collection(
+ self, collection: MultipleConfig[Any], uid: uuid.UUID
+ ) -> None:
+ """记录当前配置项所属容器。"""
+
+ self._owner_collection = collection
+ self._owner_uid = uid
+
+ def bind_owner_collection(
+ self, collection: MultipleConfig[Any], uid: uuid.UUID
+ ) -> None:
+ """公开所属容器绑定入口,供 `MultipleConfig` 调用。"""
+
+ self._bind_owner_collection(collection, uid)
+
+ def _resolve_related_collection(self, target: str) -> MultipleConfig[Any] | None:
+ value = getattr(self, target, None)
+ if isinstance(value, MultipleConfig):
+ return cast(MultipleConfig[Any], value)
+
+ target_collection = type(self).related_config.get(target)
+ if isinstance(target_collection, MultipleConfig):
+ return target_collection
+
+ return None
+
+ def _get_ref_field(self, group: str, name: str) -> RefField | None:
+ group_model = self._group_index().get(group)
+ if group_model is None:
+ return None
+ return _get_field_marker(group_model, name, RefField)
+
+ def _get_virtual_field(self, group: str, name: str) -> VirtualField | None:
+ group_model = self._group_index().get(group)
+ if group_model is None:
+ return None
+ return _get_field_marker(group_model, name, VirtualField)
+
+ def _iter_ref_fields(self):
+ for group_name, group_model in self._group_index().items():
+ for field_name in type(group_model).model_fields:
+ ref_field = _get_field_marker(group_model, field_name, RefField)
+ if ref_field is not None:
+ yield group_name, field_name, ref_field
+
+ def _iter_virtual_dependents(self, dependency: tuple[str, str]):
+ for group_name, group_model in self._group_index().items():
+ for field_name in type(group_model).model_fields:
+ virtual_field = _get_field_marker(group_model, field_name, VirtualField)
+ if virtual_field is None:
+ continue
+ deps = self._virtual_dependencies_cache.get(
+ (group_name, field_name), ()
+ )
+ if dependency in deps:
+ yield group_name, field_name, virtual_field
+
+ def _get_virtual_value(
+ self, group: str, name: str, spec: VirtualField | None = None
+ ) -> Any:
+ spec = spec or self._get_virtual_field(group, name)
+ if spec is None:
+ raise AttributeError(f"配置项 '{group}.{name}' 不是虚拟字段")
+
+ getter = spec.getter
+ if isinstance(getter, str):
+ result = getattr(self, getter)()
+ else:
+ result = getter(self)
+
+ if inspect.isawaitable(result):
+ raise TypeError(f"虚拟配置项 '{group}.{name}' 的 getter 不能是异步方法")
+
+ return result
+
+ def get_virtual_value(
+ self, group: str, name: str, spec: VirtualField | None = None
+ ) -> Any:
+ """公开只读虚拟值访问入口,便于模块内辅助函数调用。"""
+
+ return self._get_virtual_value(group, name, spec)
+
+ async def _set_virtual_value(self, group: str, name: str, value: Any) -> None:
+ spec = self._get_virtual_field(group, name)
+ if spec is None:
+ raise AttributeError(f"配置项 '{group}.{name}' 不是虚拟字段")
+ if spec.setter is None:
+ raise ValueError(f"虚拟配置项 '{group}.{name}' 为只读")
+
+ setter = spec.setter
+ if isinstance(setter, str):
+ result = getattr(self, setter)(value)
+ else:
+ result = setter(self, value)
+
+ if inspect.isawaitable(result):
+ await result
+
+ def _normalize_ref_value(self, spec: RefField, value: Any) -> Any:
+ if value in spec.allow_values:
+ return value
+ if value == spec.default:
+ return spec.default
+
+ text = value if isinstance(value, str) else str(value)
+ try:
+ uid = uuid.UUID(text)
+ except (TypeError, ValueError):
+ return spec.default
+
+ target_collection = self._resolve_related_collection(spec.target)
+ if target_collection is None or uid not in target_collection:
+ return spec.default
+
+ return str(uid)
+
+ def _display_name(self) -> str:
+ info_model = self._group_index().get("Info")
+ if info_model is not None and hasattr(info_model, "Name"):
+ name = getattr(info_model, "Name")
+ if isinstance(name, str) and name:
+ return name
+ if self._owner_uid is not None:
+ return str(self._owner_uid)
+ return type(self).__name__
+
+ def _register_ref_bindings(self) -> None:
+ for _, _, ref_field in self._iter_ref_fields():
+ if ref_field.target in self._registered_ref_targets:
+ continue
+
+ target_collection = self._resolve_related_collection(ref_field.target)
+ if target_collection is None:
+ continue
+
+ target_collection.bind_before_del(self._on_related_config_deleted)
+ self._registered_ref_targets.add(ref_field.target)
+
+ async def _on_related_config_deleted(
+ self, event: MultipleConfigDeleteEvent[Any]
+ ) -> None:
+ matched_fields: list[tuple[str, str, RefField]] = []
+
+ for group_name, field_name, ref_field in self._iter_ref_fields():
+ target_collection = self._resolve_related_collection(ref_field.target)
+ if target_collection is not event.collection:
+ continue
+
+ group_model = self._group_index().get(group_name)
+ if group_model is None:
+ continue
+
+ if getattr(group_model, field_name) != str(event.uid):
+ continue
+
+ matched_fields.append((group_name, field_name, ref_field))
+
+ if not matched_fields:
+ return
+
+ for group_name, field_name, ref_field in matched_fields:
+ action = ref_field.on_delete
+
+ if action == "restrict":
+ raise RuntimeError(
+ f"{self._display_name()} 正在引用 {event.uid}, 无法删除"
+ )
+
+ if self.is_locked:
+ raise RuntimeError(
+ f"{self._display_name()} 正在引用 {event.uid} 且已锁定, 无法删除"
+ )
+
+ if action == "set_default":
+ await self.set(group_name, field_name, ref_field.default)
+ continue
+
+ if action == "cascade":
+ if self._owner_collection is None or self._owner_uid is None:
+ raise RuntimeError(
+ f"{self._display_name()} 缺少所属容器信息, 无法级联删除"
+ )
+ await self._owner_collection.remove(self._owner_uid)
+ return
+
+ if action == "custom":
+ callback = ref_field.on_delete_callback
+ if callback is None:
+ raise RuntimeError(
+ f"{group_name}.{field_name} 未声明自定义删除回调"
+ )
+
+ if isinstance(callback, str):
+ result = getattr(self, callback)(event)
+ else:
+ result = callback(self, event)
+
+ if inspect.isawaitable(result):
+ await result
+ continue
+
+ raise ValueError(f"不支持的引用删除策略: {action}")
+
+ async def _queue_binding(self, group: str, name: str, value: Any) -> None:
+ self._pending_bindings[(group, name)] = value
+ if self._transaction_depth == 0:
+ await self._flush_pending_bindings()
+
+ async def _flush_pending_bindings(self) -> None:
+ while self._pending_bindings:
+ pending_items = list(self._pending_bindings.items())
+ self._pending_bindings.clear()
+ for (group, name), value in pending_items:
+ await self._emit_bindings(group, name, value)
+
+ @asynccontextmanager
+ async def transaction(self) -> AsyncGenerator["PydanticConfigBase", None]:
+ """开启一个延迟保存事务。"""
+
+ is_outermost = self._transaction_depth == 0
+ if is_outermost:
+ self._ref_validation_cache_enabled = True
+ self._ref_validation_cache.clear()
+
+ self._transaction_depth += 1
+ try:
+ yield self
+ finally:
+ self._transaction_depth -= 1
+ if self._transaction_depth == 0:
+ await self._flush_pending_changes()
+ if is_outermost:
+ self._ref_validation_cache_enabled = False
+ self._ref_validation_cache.clear()
+
+ async def _flush_pending_changes(self) -> None:
+ await self._flush_pending_bindings()
+
+ if self._pending_save and self._file:
+ self._pending_save = False
+ await self._save_unlocked()
+
+ if self._pending_sync:
+ self._pending_sync = False
+ if self._save_methods:
+ await asyncio.gather(*(_() for _ in self._save_methods))
+
+ async def _save_unlocked(self) -> None:
+ if not self._file:
+ raise ValueError("文件路径未设置, 请先调用 `connect` 方法连接配置文件")
+
+ content = dump_toml(await self.toDict(if_decrypt=False, skip_virtual=True))
+ atomic_write_text(self._file, content)
+
+ async def connect(self, path: Path) -> None:
+ if path.suffix != ".toml":
+ raise ValueError("配置文件必须是扩展名为 '.toml' 的 TOML 文件")
+
+ if self._is_locked:
+ raise ValueError("配置已锁定, 无法修改")
+
+ self._file = path
+
+ if not self._file.exists():
+ self._file.parent.mkdir(parents=True, exist_ok=True)
+ self._file.touch()
+
+ data, legacy_file = load_config_with_legacy_migration(self._file)
+ await self.load(data)
+ await self.add_save_method(self.save)
+ backup_legacy_config_if_needed(self._file, legacy_file)
+
+ async def add_save_method(self, save_method: SaveMethod) -> None:
+ if save_method != self.save and save_method not in self._save_methods:
+ self._save_methods.append(save_method)
+
+ for sub_config in self._multiple_config_index().values():
+ await sub_config.add_save_method(save_method)
+
+ async def load(self, data: dict[str, Any]) -> None:
+ """从外部字典加载配置,支持新旧命名协议混读。
+
+ 支持内容:
+ - 分组与字段的 PascalCase / snake_case 双格式读取;
+ - ``sub_configs_info`` 与 ``SubConfigsInfo`` 兼容;
+ - 字段 ``validation_alias`` 回退解析;
+ - ``LEGACY_FIELD_MAP`` 旧字段映射回填。
+
+ 加载完成后会触发持久化与级联保存方法,保证内存态与磁盘态一致。
+
+ Args:
+ data: 待加载的原始配置字典。
+ """
+
+ async with self._mutex:
+ if self._is_locked:
+ raise ValueError("配置已锁定, 无法修改")
+
+ raw: dict[str, Any] = dict(data)
+
+ sub_configs = _normalize_mapping(
+ raw.pop("sub_configs_info", raw.pop("SubConfigsInfo", {}))
+ )
+
+ for name, sub_config in self._multiple_config_index().items():
+ data_for_sub = sub_configs.get(
+ _to_snake_case(name), sub_configs.get(name)
+ )
+ if isinstance(data_for_sub, dict):
+ await sub_config.load(_normalize_mapping(data_for_sub))
+
+ for group_name, group_model in self._group_index().items():
+ group_data = _normalize_mapping(
+ raw.get(_to_snake_case(group_name), raw.get(group_name, {}))
+ )
+
+ for field_name in list(type(group_model).model_fields.keys()):
+ if self._get_virtual_field(group_name, field_name) is not None:
+ continue
+
+ candidate: Any = None
+ has_value = False
+
+ if field_name in group_data:
+ candidate = group_data[field_name]
+ has_value = True
+ elif _to_snake_case(field_name) in group_data:
+ candidate = group_data[_to_snake_case(field_name)]
+ has_value = True
+ else:
+ field_info = type(group_model).model_fields.get(field_name)
+ if field_info is not None:
+ candidate, has_value = _try_resolve_alias_value(
+ raw,
+ group_name,
+ group_data,
+ field_info.validation_alias,
+ )
+
+ if not has_value:
+ legacy = self.LEGACY_FIELD_MAP.get((group_name, field_name))
+ if legacy is not None:
+ legacy_group, legacy_name = legacy
+ legacy_data = _normalize_mapping(raw.get(legacy_group, {}))
+ if legacy_name in legacy_data:
+ candidate = legacy_data[legacy_name]
+ has_value = True
+
+ if not has_value:
+ continue
+
+ candidate = self._normalize_value(group_name, field_name, candidate)
+ try:
+ setattr(group_model, field_name, candidate)
+ except (TypeError, ValueError) as e:
+ raise ValueError(
+ f"加载配置项失败: {group_name}.{field_name}={candidate!r}"
+ ) from e
+
+ should_save = bool(self._file)
+ should_cascade = bool(self._save_methods)
+
+ if should_save:
+ await self._save_unlocked()
+
+ if should_cascade:
+ await asyncio.gather(*(_() for _ in self._save_methods))
+
+ async def toDict(
+ self,
+ if_decrypt: bool = True,
+ regenerate_uuids: bool = False,
+ skip_virtual: bool = False,
+ ) -> dict[str, Any]:
+ """将当前配置导出为 snake_case 协议字典。
+
+ 导出规则:
+ - 所有分组键与字段键均转换为 snake_case;
+ - 虚拟字段导出为实时计算值;
+ - 子配置统一放入 ``sub_configs_info``;
+ - 是否解密由 ``if_decrypt`` 控制。
+
+ Args:
+ if_decrypt: 是否在导出时自动解密加密字段。
+ regenerate_uuids: 透传给子配置容器的 UUID 重生参数。
+
+ Returns:
+ 可序列化的配置字典。
+ """
+
+ data: dict[str, Any] = {}
+
+ for group_name, group_model in self._group_index().items():
+ data[_to_snake_case(group_name)] = _export_group_model(
+ self, group_name, group_model, if_decrypt, skip_virtual
+ )
+
+ for name, item in self._multiple_config_index().items():
+ if "sub_configs_info" not in data:
+ data["sub_configs_info"] = {}
+ data["sub_configs_info"][_to_snake_case(name)] = await item.toDict(
+ if_decrypt, regenerate_uuids, skip_virtual
+ )
+
+ return data
+
+ def get(self, group: str, name: str) -> Any:
+ """读取单个配置项,支持 snake_case 与原字段名。
+
+ 读取流程:
+ 1. 解析分组和字段真实名称;
+ 2. 若为虚拟字段,返回 getter 计算结果;
+ 3. 若为加密字段,返回自动解密值;
+ 4. 否则返回原值。
+
+ Args:
+ group: 分组名。
+ name: 字段名。
+
+ Returns:
+ 配置值。
+ """
+
+ resolved_group = self._resolve_group_name(group)
+ resolved_name = self._resolve_field_name(resolved_group, name)
+
+ group_model = self._group_index().get(resolved_group)
+ if group_model is None or not hasattr(group_model, resolved_name):
+ raise AttributeError(f"配置项 '{group}.{name}' 不存在")
+
+ virtual_field = self._get_virtual_field(resolved_group, resolved_name)
+ if virtual_field is not None:
+ return self._get_virtual_value(resolved_group, resolved_name, virtual_field)
+
+ value = getattr(group_model, resolved_name)
+ if _is_encrypted_field(group_model, resolved_name):
+ return decrypt_encrypted_string(str(value))
+ return value
+
+ async def set(self, group: str, name: str, value: Any) -> None:
+ """设置单个配置项,并联动依赖虚拟字段与绑定回调。
+
+ 关键行为:
+ - 支持 snake_case 名称解析;
+ - 对引用字段执行归一化(UUID/默认值校验);
+ - 计算受影响虚拟字段的前后值差异,按需触发绑定事件;
+ - 根据事务状态决定立即保存或延迟提交。
+
+ Args:
+ group: 分组名。
+ name: 字段名。
+ value: 新值。
+ """
+
+ async with self._mutex:
+ resolved_group = self._resolve_group_name(group)
+ resolved_name = self._resolve_field_name(resolved_group, name)
+
+ group_model = self._group_index().get(resolved_group)
+ if group_model is None or not hasattr(group_model, resolved_name):
+ raise AttributeError(f"配置项 '{group}.{name}' 不存在")
+
+ if self._is_locked:
+ raise ValueError("配置已锁定, 无法修改")
+
+ virtual_field = self._get_virtual_field(resolved_group, resolved_name)
+ if virtual_field is not None:
+ await self._set_virtual_value(resolved_group, resolved_name, value)
+ return
+
+ old_value = getattr(group_model, resolved_name)
+ virtual_old_values = {
+ (virtual_group, virtual_name): self._get_virtual_value(
+ virtual_group, virtual_name, virtual_field
+ )
+ for virtual_group, virtual_name, virtual_field in self._iter_virtual_dependents(
+ (resolved_group, resolved_name)
+ )
+ }
+ value = self._normalize_value(resolved_group, resolved_name, value)
+
+ try:
+ setattr(group_model, resolved_name, value)
+ except (TypeError, ValueError) as e:
+ raise ValueError(
+ f"设置配置项失败: {resolved_group}.{resolved_name}={value!r}"
+ ) from e
+
+ new_value = getattr(group_model, resolved_name)
+ if old_value != new_value:
+ await self._queue_binding(resolved_group, resolved_name, new_value)
+
+ for (
+ virtual_group,
+ virtual_name,
+ ), old_virtual_value in virtual_old_values.items():
+ new_virtual_value = self.get(virtual_group, virtual_name)
+ if old_virtual_value != new_virtual_value:
+ await self._queue_binding(
+ virtual_group, virtual_name, new_virtual_value
+ )
+
+ if self._file:
+ self._pending_save = True
+ if self._save_methods:
+ self._pending_sync = True
+
+ if self._transaction_depth == 0:
+ await self._flush_pending_changes()
+
+ async def set_many(self, values: dict[str, dict[str, Any]]) -> None:
+ """批量更新多个配置项。"""
+
+ async with self.transaction():
+ for group, items in values.items():
+ for name, value in items.items():
+ await self.set(group, name, value)
+
+ def bind(self, group: str, name: str, slot: Slot) -> None:
+ resolved_group = self._resolve_group_name(group)
+ resolved_name = self._resolve_field_name(resolved_group, name)
+
+ group_model = self._group_index().get(resolved_group)
+ if group_model is None or not hasattr(group_model, resolved_name):
+ raise AttributeError(f"配置项 '{group}.{name}' 不存在")
+
+ if self._is_locked:
+ raise ValueError("配置已锁定, 无法修改")
+
+ key = (resolved_group, resolved_name)
+ if key not in self._bindings:
+ self._bindings[key] = []
+ if slot not in self._bindings[key]:
+ self._bindings[key].append(slot)
+
+ def unbind(self, group: str, name: str, slot: Slot) -> None:
+ resolved_group = self._resolve_group_name(group)
+ resolved_name = self._resolve_field_name(resolved_group, name)
+
+ group_model = self._group_index().get(resolved_group)
+ if group_model is None or not hasattr(group_model, resolved_name):
+ raise AttributeError(f"配置项 '{group}.{name}' 不存在")
+
+ if self._is_locked:
+ raise ValueError("配置已锁定, 无法修改")
+
+ key = (resolved_group, resolved_name)
+ if key in self._bindings and slot in self._bindings[key]:
+ self._bindings[key].remove(slot)
+
+ async def _emit_bindings(self, group: str, name: str, value: Any) -> None:
+ key = (group, name)
+ slots = self._bindings.get(key)
+ if slots is None:
+ return
+ for slot in slots:
+ result = slot(value)
+ if inspect.isawaitable(result):
+ await result
+
+ async def save(self) -> None:
+ if not self._file:
+ raise ValueError("文件路径未设置, 请先调用 `connect` 方法连接配置文件")
+
+ if self._transaction_depth > 0:
+ self._pending_save = True
+ return
+
+ async with self._mutex:
+ await self._save_unlocked()
+
+ async def lock(self) -> None:
+ self._is_locked = True
+ for config in self._multiple_config_index().values():
+ await config.lock()
+
+ async def unlock(self) -> None:
+ self._is_locked = False
+ for config in self._multiple_config_index().values():
+ await config.unlock()
+
+
+class PluginConfigBase(PydanticConfigBase):
+ """插件配置模型锚点基类。"""
+
+ model_config = ConfigDict(extra="allow", validate_assignment=True)
diff --git a/app/core/config/shortcuts.py b/app/core/config/shortcuts.py
new file mode 100644
index 00000000..e77109fa
--- /dev/null
+++ b/app/core/config/shortcuts.py
@@ -0,0 +1,254 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any, TypeVar, cast
+
+if TYPE_CHECKING:
+ _ClsT = TypeVar("_ClsT")
+from .base import MultipleConfig
+from .fields import (
+ OnDeleteCallback,
+ RefDeleteAction,
+ RefField,
+ VirtualDependency,
+ VirtualField,
+ VirtualFieldGetter,
+ VirtualFieldSetter,
+)
+from .types import EncryptedFieldMarker
+
+
+@dataclass(frozen=True, slots=True)
+class SubConfigSpec:
+ """子配置挂载描述。"""
+
+ types: tuple[type[Any], ...]
+ singleton: bool = False
+
+
+def _normalize_sub_config_spec(value: Any) -> SubConfigSpec:
+ """将多种子配置声明形式统一规整为 ``SubConfigSpec``。
+
+ 支持以下输入:
+ - 已构造的 ``SubConfigSpec``
+ - ``list[type]`` / ``tuple[type, ...]``(表示多实例子配置集合)
+ - 单个 ``type``(会转为仅含一个类型的多实例声明)
+
+ Args:
+ value: 装饰器参数中声明的子配置描述。
+
+ Returns:
+ 规范化后的 ``SubConfigSpec``。
+
+ Raises:
+ TypeError: 传入类型不受支持时抛出。
+ """
+
+ if isinstance(value, SubConfigSpec):
+ return value
+
+ if isinstance(value, list):
+ return SubConfigSpec(types=tuple(cast(list[type[Any]], value)), singleton=False)
+
+ if isinstance(value, tuple):
+ return SubConfigSpec(types=cast(tuple[type[Any], ...], value), singleton=False)
+
+ if isinstance(value, type):
+ return SubConfigSpec(types=(value,), singleton=False)
+
+ raise TypeError(f"不支持的子配置声明类型: {type(value)!r}")
+
+
+def ref(
+ target: str,
+ *,
+ default: Any,
+ allow_values: tuple[Any, ...] = (),
+ on_delete: RefDeleteAction = RefDeleteAction.SET_DEFAULT,
+ on_delete_callback: OnDeleteCallback | str | None = None,
+) -> RefField:
+ """创建引用字段元数据(RefField 语法糖)。"""
+
+ return RefField(
+ target=target,
+ default=default,
+ allow_values=allow_values,
+ on_delete=on_delete,
+ on_delete_callback=on_delete_callback,
+ )
+
+
+def virtual(
+ getter: VirtualFieldGetter | str,
+ *,
+ setter: VirtualFieldSetter | str | None = None,
+ depends_on: tuple[VirtualDependency, ...] = (),
+) -> VirtualField:
+ """创建虚拟字段元数据(VirtualField 语法糖)。"""
+
+ return VirtualField(getter=getter, setter=setter, depends_on=depends_on)
+
+
+def encrypted() -> EncryptedFieldMarker:
+ """创建加密字段标记(EncryptedFieldMarker 语法糖)。"""
+
+ return EncryptedFieldMarker()
+
+
+def singleton(config_type: type[Any]) -> SubConfigSpec:
+ """声明单例子配置。"""
+
+ return SubConfigSpec(types=(config_type,), singleton=True)
+
+
+def sub_configs(**configs: Any):
+ """声明配置类上的子配置挂载信息。
+
+ 该装饰器仅负责“记录元数据”,实际实例化发生在 ``@config`` 注入的
+ ``model_post_init`` 阶段。
+
+ Example:
+ ``@sub_configs(UserData=[UserConfig], Tools=singleton(ToolsConfig))``
+
+ Args:
+ **configs: 属性名到子配置声明的映射。
+
+ Returns:
+ 类装饰器,返回原类并附加 ``__config_sub_configs__`` 元数据。
+ """
+
+ normalized = {
+ name: _normalize_sub_config_spec(spec) for name, spec in configs.items()
+ }
+
+ def _decorator(cls: type[Any]) -> type[Any]:
+ inherited = dict(getattr(cls, "__config_sub_configs__", {}))
+ inherited.update(normalized)
+ setattr(cls, "__config_sub_configs__", inherited)
+ return cls
+
+ return _decorator
+
+
+def relates_to(**targets: str):
+ """声明配置类的引用目标映射。
+
+ 该映射用于在实例初始化时把 ``related_config`` 中的逻辑别名指向
+ 实例属性上的 ``MultipleConfig`` 容器。
+
+ Example:
+ ``@relates_to(PlanConfig="PlanConfig", ScriptConfig="ScriptConfig")``
+
+ Args:
+ **targets: 逻辑别名 -> 实例属性名。
+
+ Returns:
+ 类装饰器,返回原类并附加 ``__config_relates_to__`` 元数据。
+ """
+
+ def _decorator(cls: type[Any]) -> type[Any]:
+ inherited = dict(getattr(cls, "__config_relates_to__", {}))
+ inherited.update(targets)
+ setattr(cls, "__config_relates_to__", inherited)
+ return cls
+
+ return _decorator
+
+
+def _init_sub_configs(instance: Any) -> None:
+ """根据 ``__config_sub_configs__`` 元数据初始化子配置属性。
+
+ 行为规则:
+ - 若实例已存在同名属性,则跳过(避免覆盖手工注入对象);
+ - ``singleton=True`` 时直接构造单个子配置实例;
+ - 否则构造 ``MultipleConfig`` 容器。
+
+ Args:
+ instance: 当前配置对象实例。
+ """
+
+ instance_type = cast(type[Any], type(instance))
+ specs: dict[str, SubConfigSpec] = dict(
+ cast(
+ dict[str, SubConfigSpec],
+ getattr(instance_type, "__config_sub_configs__", {}),
+ )
+ )
+
+ for name, spec in specs.items():
+ if hasattr(instance, name):
+ continue
+
+ if spec.singleton:
+ object.__setattr__(instance, name, spec.types[0]())
+ else:
+ object.__setattr__(instance, name, MultipleConfig(list(spec.types)))
+
+
+def _init_related_targets(instance: Any) -> None:
+ """将关系映射写入类级 ``related_config``。
+
+ 仅当目标属性是 ``MultipleConfig`` 时才会写入,避免错误类型污染
+ 引用解析表。
+
+ Args:
+ instance: 当前配置对象实例。
+ """
+
+ instance_type = cast(type[Any], type(instance))
+ mapping: dict[str, str] = dict(
+ cast(dict[str, str], getattr(instance_type, "__config_relates_to__", {}))
+ )
+ related = getattr(instance_type, "related_config", None)
+ if not isinstance(related, dict):
+ return
+
+ for alias, attr_name in mapping.items():
+ target = getattr(instance, attr_name, None)
+ if isinstance(target, MultipleConfig):
+ related[alias] = target
+
+
+if TYPE_CHECKING:
+ def config(cls: _ClsT) -> _ClsT: ... # type: ignore[misc]
+else:
+ def config(cls: type[Any]) -> type[Any]:
+ """配置类总装饰器:在 ``model_post_init`` 阶段完成运行期装配。
+
+ 装配顺序:
+ 1. 初始化子配置(``_init_sub_configs``);
+ 2. 建立引用目标映射(``_init_related_targets``);
+ 3. 调用原有 ``model_post_init``(如果存在)。
+
+ 这样可以确保原有后置初始化逻辑执行时,子配置和引用关系已可用。
+
+ Args:
+ cls: 被装饰的配置类。
+
+ Returns:
+ 注入初始化逻辑后的原类。
+ """
+
+ original_model_post_init = getattr(cls, "model_post_init", None)
+
+ def _model_post_init(self: Any, context: Any) -> None:
+ _init_sub_configs(self)
+ _init_related_targets(self)
+
+ if callable(original_model_post_init):
+ original_model_post_init(self, context)
+
+ setattr(cls, "model_post_init", _model_post_init)
+ return cls
+
+
+__all__ = [
+ "SubConfigSpec",
+ "ref",
+ "virtual",
+ "encrypted",
+ "singleton",
+ "sub_configs",
+ "relates_to",
+ "config",
+]
diff --git a/app/core/config/types.py b/app/core/config/types.py
new file mode 100644
index 00000000..a2f17bcb
--- /dev/null
+++ b/app/core/config/types.py
@@ -0,0 +1,300 @@
+from __future__ import annotations
+
+import json
+from datetime import datetime
+from typing import Annotated, Any
+from urllib.parse import urlparse
+
+import pyautogui
+from pydantic import AfterValidator, Field
+
+from app.utils import get_logger
+from app.utils.constants import DEFAULT_DATETIME
+from app.utils.security import dpapi_decrypt, dpapi_encrypt
+
+
+logger = get_logger("配置类型")
+
+
+class EncryptedFieldMarker:
+ """标记需要在对外读取时自动解密的字段。"""
+
+
+def _to_string(value: Any) -> str:
+ """
+ 将任意值转换为字符串。
+
+ Args:
+ value: 要转换的值
+
+ Returns:
+ 字符串表示,None 返回空字符串
+ """
+ if value is None:
+ return ""
+ if isinstance(value, str):
+ return value
+ return str(value)
+
+
+def _validate_json_dict_string(value: Any) -> str:
+ """
+ 校验并规范化 JSON 字典字符串。
+
+ Args:
+ value: 输入值
+
+ Returns:
+ 有效的 JSON 字典字符串,失败时返回 "{ }"
+
+ Raises:
+ ValidationError: 如果输入不是字符串且无法转换
+ """
+ text = _to_string(value)
+ if not text:
+ raise ValueError("JSON 字典字符串不能为空")
+
+ try:
+ parsed = json.loads(text)
+ if not isinstance(parsed, dict):
+ logger.warning(f"JSON 不是字典类型: {text[:50]}...")
+ raise ValueError("JSON 不是字典类型")
+ return text
+ except json.JSONDecodeError as e:
+ logger.warning(f"JSON 解析失败: {e}, 输入: {text[:50]}...")
+ raise ValueError("JSON 字典字符串解析失败") from e
+
+
+def _validate_json_list_string(value: Any) -> str:
+ """
+ 校验并规范化 JSON 列表字符串。
+
+ Args:
+ value: 输入值
+
+ Returns:
+ 有效的 JSON 列表字符串,失败时返回 "[ ]"
+ """
+ text = _to_string(value)
+ if not text:
+ raise ValueError("JSON 列表字符串不能为空")
+
+ try:
+ parsed = json.loads(text)
+ if not isinstance(parsed, list):
+ logger.warning(f"JSON 不是列表类型: {text[:50]}...")
+ raise ValueError("JSON 不是列表类型")
+ return text
+ except json.JSONDecodeError as e:
+ logger.warning(f"JSON 解析失败: {e}, 输入: {text[:50]}...")
+ raise ValueError("JSON 列表字符串解析失败") from e
+
+
+def _validate_hhmm_string(value: Any) -> str:
+ """
+ 校验并规范化 HH:MM 时间字符串。
+
+ Args:
+ value: 输入值
+
+ Returns:
+ 有效的 HH:MM 格式字符串,失败时返回默认时间
+ """
+ text = _to_string(value)
+ if not text:
+ return DEFAULT_DATETIME.strftime("%H:%M")
+
+ try:
+ datetime.strptime(text, "%H:%M")
+ return text
+ except ValueError as e:
+ logger.warning(f"时间格式错误: {e}, 输入: {text}")
+ return DEFAULT_DATETIME.strftime("%H:%M")
+
+
+def _validate_ymd_hm_string(value: Any) -> str:
+ """校验并规范化 YYYY-MM-DD HH:MM 日期时间字符串。"""
+ text = _to_string(value)
+ if not text:
+ return DEFAULT_DATETIME.strftime("%Y-%m-%d %H:%M")
+
+ try:
+ datetime.strptime(text, "%Y-%m-%d %H:%M")
+ return text
+ except ValueError as e:
+ logger.warning(f"日期时间格式错误: {e}, 输入: {text}")
+ return DEFAULT_DATETIME.strftime("%Y-%m-%d %H:%M")
+
+
+def _validate_ymd_string(value: Any) -> str:
+ """校验并规范化 YYYY-MM-DD 日期字符串。"""
+ text = _to_string(value)
+ if not text:
+ return DEFAULT_DATETIME.strftime("%Y-%m-%d")
+
+ try:
+ datetime.strptime(text, "%Y-%m-%d")
+ return text
+ except ValueError as e:
+ logger.warning(f"日期格式错误: {e}, 输入: {text}")
+ return DEFAULT_DATETIME.strftime("%Y-%m-%d")
+
+
+def _validate_ymd_hms_string(value: Any) -> str:
+ """校验并规范化 YYYY-MM-DD HH:MM:SS 日期时间字符串。"""
+ text = _to_string(value)
+ if not text:
+ return DEFAULT_DATETIME.strftime("%Y-%m-%d %H:%M:%S")
+
+ try:
+ datetime.strptime(text, "%Y-%m-%d %H:%M:%S")
+ return text
+ except ValueError as e:
+ logger.warning(f"日期时间格式错误: {e}, 输入: {text}")
+ return DEFAULT_DATETIME.strftime("%Y-%m-%d %H:%M:%S")
+
+
+def _validate_url_string(value: Any) -> str:
+ """
+ 校验并规范化 URL 字符串。
+
+ Args:
+ value: 输入值
+
+ Returns:
+ 有效的 URL 字符串,失败时返回空字符串
+ """
+ text = _to_string(value)
+ if not text:
+ raise ValueError("URL 不能为空")
+
+ try:
+ parsed = urlparse(text)
+ if not parsed.scheme or not parsed.netloc:
+ logger.warning(f"URL 格式错误: {text}")
+ raise ValueError("URL 格式错误")
+ return text
+ except ValueError:
+ raise
+ except TypeError as e:
+ logger.warning(f"URL 解析失败: {e}, 输入: {text}")
+ raise ValueError("URL 解析失败") from e
+
+
+def _validate_keyboard_key(value: Any) -> str:
+ """
+ 校验键盘按键字符串。
+
+ Args:
+ value: 输入值
+
+ Returns:
+ 有效的按键字符串,失败时返回空字符串
+ """
+ text = _to_string(value).lower()
+ if not text:
+ raise ValueError("键盘按键不能为空")
+
+ if text not in pyautogui.KEYBOARD_KEYS:
+ logger.warning(f"无效的键盘按键: {text}")
+ raise ValueError(f"无效的键盘按键: {text}")
+
+ return text
+
+
+_ENCRYPTED_PREFIX = "DPAPI:"
+
+
+def _normalize_encrypted_string(value: Any) -> str:
+ """
+ 规范化加密字符串。
+
+ 如果输入已加密,则保持不变;否则加密。
+
+ Args:
+ value: 输入值
+
+ Returns:
+ 加密后的字符串
+ """
+ text = _to_string(value)
+ if not text:
+ return ""
+
+ if text.startswith(_ENCRYPTED_PREFIX):
+ return text
+
+ try:
+ plaintext = dpapi_decrypt(text)
+ return _ENCRYPTED_PREFIX + dpapi_encrypt(plaintext)
+ except (ValueError, Exception):
+ pass
+
+ try:
+ return _ENCRYPTED_PREFIX + dpapi_encrypt(text)
+ except (ValueError, TypeError) as e:
+ logger.error(f"加密失败: {e}, 输入长度: {len(text)}")
+ raise ValueError("加密失败") from e
+
+
+def decrypt_encrypted_string(value: str) -> str:
+ """
+ 解密加密字符串。
+
+ Args:
+ value: 加密的字符串
+
+ Returns:
+ 解密后的明文,失败时抛出异常
+ """
+ if not value:
+ return ""
+
+ cipher = value[len(_ENCRYPTED_PREFIX):] if value.startswith(_ENCRYPTED_PREFIX) else value
+
+ try:
+ return dpapi_decrypt(cipher)
+ except ValueError as e:
+ logger.error(f"解密失败: {e}")
+ raise ValueError("数据损坏,请重新设置") from e
+
+
+# 类型别名定义
+JsonDictString = Annotated[str, AfterValidator(_validate_json_dict_string)]
+JsonListString = Annotated[str, AfterValidator(_validate_json_list_string)]
+HHMMString = Annotated[str, AfterValidator(_validate_hhmm_string)]
+YmdHmString = Annotated[str, AfterValidator(_validate_ymd_hm_string)]
+YmdString = Annotated[str, AfterValidator(_validate_ymd_string)]
+YmdHmsString = Annotated[str, AfterValidator(_validate_ymd_hms_string)]
+UrlString = Annotated[str, AfterValidator(_validate_url_string)]
+""" URL 字符串类型 """
+KeyboardKeyString = Annotated[str, AfterValidator(_validate_keyboard_key)]
+""" 键盘按键字符串类型 (必须是 pyautogui 支持的按键) """
+EncryptedString = Annotated[
+ str, EncryptedFieldMarker(), AfterValidator(_normalize_encrypted_string)
+]
+""" 加密字符串类型 """
+NonNegativeInt = Annotated[int, Field(ge=0)]
+""" 非负整数类型(0及以上)"""
+PositiveInt = Annotated[int, Field(ge=1)]
+""" 正整数类型(1及以上)"""
+DayCount = Annotated[int, Field(ge=-1, le=9999)]
+""" 天数类型(-1为永久,0为今天)"""
+
+
+__all__ = [
+ "JsonDictString",
+ "JsonListString",
+ "HHMMString",
+ "YmdHmString",
+ "YmdString",
+ "YmdHmsString",
+ "UrlString",
+ "KeyboardKeyString",
+ "EncryptedString",
+ "NonNegativeInt",
+ "PositiveInt",
+ "DayCount",
+ "EncryptedFieldMarker",
+ "decrypt_encrypted_string",
+]
diff --git a/app/core/emulator_manager.py b/app/core/emulator_manager.py
index 95dc8987..ac3d4645 100644
--- a/app/core/emulator_manager.py
+++ b/app/core/emulator_manager.py
@@ -28,9 +28,9 @@
from typing import Dict, Literal
from .config import Config
-from app.models.config import EmulatorConfig
+from app.models import EmulatorConfig
from app.models.emulator import DeviceBase
-from app.models.schema import DeviceInfo as SchemaDeviceInfo
+from app.models.shared import DeviceInfo as SchemaDeviceInfo
from app.utils import ProcessRunner, EMULATOR_TYPE_BOOK
from app.utils.constants import EMULATOR_SPLASH_ADS_PATH_BOOK
@@ -44,14 +44,12 @@ class _EmulatorManager:
"""模拟器实例管理器"""
async def get_emulator_instance(self, emulator_id: str) -> DeviceBase:
-
emulator_uid = uuid.UUID(emulator_id)
config = EmulatorConfig()
await config.load(await Config.EmulatorConfig[emulator_uid].toDict())
if config.get("Info", "Type") in EMULATOR_TYPE_BOOK:
-
# 设置模拟器广告
with suppress(Exception):
if config.get("Info", "Type") in EMULATOR_SPLASH_ADS_PATH_BOOK:
@@ -80,17 +78,13 @@ async def get_emulator_instance(self, emulator_id: str) -> DeviceBase:
async def operate_emulator(
self, operate: Literal["open", "close", "show"], emulator_id: str, index: str
):
-
asyncio.create_task(self.operate_emulator_task(operate, emulator_id, index))
async def operate_emulator_task(
self, operate: Literal["open", "close", "show"], emulator_id: str, index: str
):
-
try:
temp_emulator = await self.get_emulator_instance(emulator_id)
- if temp_emulator is None:
- raise KeyError(f"未找到UUID为 {emulator_id} 的模拟器配置")
if operate == "open":
await temp_emulator.open(index)
@@ -108,19 +102,18 @@ async def operate_emulator_task(
async def get_status(
self, emulator_id: str | None = None
) -> Dict[str, Dict[str, SchemaDeviceInfo]]:
-
if emulator_id is None:
emulator_range = list(map(str, Config.EmulatorConfig.keys()))
else:
emulator_range = [emulator_id]
- data = {}
+ data: Dict[str, Dict[str, SchemaDeviceInfo]] = {}
for emulator_id in emulator_range:
temp_emulator = await self.get_emulator_instance(emulator_id)
emulator_device_info = await temp_emulator.getInfo(None)
# 转换 EmulatorDeviceInfo 到 SchemaDeviceInfo
- converted_devices = {}
+ converted_devices: Dict[str, SchemaDeviceInfo] = {}
for device_index, device_info in emulator_device_info.items():
converted_devices[device_index] = SchemaDeviceInfo(
title=device_info.title,
diff --git a/app/core/maa_manager.py b/app/core/maa_manager.py
index a77ae1d8..558e3e45 100644
--- a/app/core/maa_manager.py
+++ b/app/core/maa_manager.py
@@ -51,9 +51,7 @@
class _MaaFWManager:
-
def __init__(self):
-
self.resource = Resource()
(Config.config_path / "maa_option.json").write_text(
@@ -90,8 +88,8 @@ async def do_job(job: Job | JobWithResult) -> Any:
if job.failed:
if isinstance(result, JobWithResult):
raise RuntimeError(f"任务执行失败, 执行信息: {result.get()}")
- elif isinstance(result, Job):
- raise RuntimeError(f"任务执行失败")
+ else:
+ raise RuntimeError("任务执行失败")
async def get_win32_tasker(
self,
@@ -287,7 +285,6 @@ async def reconnect_adb_tasker(
@MaaFWManager.resource.custom_action("DisableLog")
class DisableLog(CustomAction):
-
def run(self, context: Context, argv: CustomAction.RunArg) -> bool:
"""
自定义动作: 临时禁用日志输出
@@ -307,7 +304,6 @@ def run(self, context: Context, argv: CustomAction.RunArg) -> bool:
@MaaFWManager.resource.custom_action("EnableLog")
class EnableLog(CustomAction):
-
def run(self, context: Context, argv: CustomAction.RunArg) -> bool:
"""
自定义动作: 启用日志输出
diff --git a/app/core/plugins/__init__.py b/app/core/plugins/__init__.py
index 59629be0..d04d250b 100644
--- a/app/core/plugins/__init__.py
+++ b/app/core/plugins/__init__.py
@@ -1,7 +1,8 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2025-2026 AUTO-MAS Team
-from .context import PluginContext, PluginConfigProxy, RuntimeFacade, PluginEventFacade
+from app.core.config import PluginConfigBase
+from .context import PluginContext, RuntimeFacade, PluginEventFacade
from .cache_store import PluginCacheManager, JsonPluginCache
from .config_store import PluginConfigStore
from .event_bus import EventBus
@@ -36,7 +37,7 @@
__all__ = [
"PluginContext",
- "PluginConfigProxy",
+ "PluginConfigBase",
"PluginEventFacade",
"RuntimeFacade",
"PluginCacheManager",
diff --git a/app/core/plugins/cache_store.py b/app/core/plugins/cache_store.py
index d16a5ae7..39f8eb21 100644
--- a/app/core/plugins/cache_store.py
+++ b/app/core/plugins/cache_store.py
@@ -6,9 +6,11 @@
import math
import re
import threading
-from datetime import datetime
+from datetime import datetime, timezone
from pathlib import Path
-from typing import Any, Dict, Literal
+from typing import Any, Dict, Literal, TypedDict, cast
+
+from app.utils.logger import LoggerLike
LimitMode = Literal["count", "bytes"]
@@ -24,9 +26,49 @@
}
+class CacheItem(TypedDict, total=False):
+ value: Any
+ updated_at: str
+
+
+class CachePayload(TypedDict):
+ items: dict[str, CacheItem]
+ updated_at: str
+
+
def _utc_now_iso() -> str:
"""返回当前 UTC 时间字符串。"""
- return datetime.utcnow().isoformat() + "Z"
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
+
+
+def _empty_payload() -> CachePayload:
+ return {"items": {}, "updated_at": _utc_now_iso()}
+
+
+def _normalize_cache_item(value: object) -> CacheItem | None:
+ if not isinstance(value, dict):
+ return None
+
+ data = cast(dict[object, object], value)
+ item: CacheItem = {}
+ if "value" in data:
+ item["value"] = data["value"]
+ item["updated_at"] = str(data.get("updated_at") or "")
+ return item
+
+
+def _normalize_cache_items(value: object) -> dict[str, CacheItem]:
+ if not isinstance(value, dict):
+ return {}
+
+ mapping = cast(dict[object, object], value)
+ result: dict[str, CacheItem] = {}
+ for raw_key, raw_item in mapping.items():
+ item = _normalize_cache_item(raw_item)
+ if item is None:
+ continue
+ result[str(raw_key)] = item
+ return result
def _safe_instance_dir_name(instance_id: str) -> str:
@@ -60,24 +102,23 @@ def __init__(
if not self.file_path.exists():
self._write_store({"items": {}, "updated_at": _utc_now_iso()})
- def _read_store(self) -> Dict[str, Any]:
+ def _read_store(self) -> CachePayload:
"""读取缓存数据结构。"""
try:
text = self.file_path.read_text(encoding="utf-8")
payload = json.loads(text)
if not isinstance(payload, dict):
- return {"items": {}, "updated_at": _utc_now_iso()}
- items = payload.get("items", {})
- if not isinstance(items, dict):
- items = {}
+ return _empty_payload()
+
+ payload_dict = cast(dict[object, object], payload)
return {
- "items": items,
- "updated_at": payload.get("updated_at") or _utc_now_iso(),
+ "items": _normalize_cache_items(payload_dict.get("items")),
+ "updated_at": str(payload_dict.get("updated_at") or _utc_now_iso()),
}
except Exception:
- return {"items": {}, "updated_at": _utc_now_iso()}
+ return _empty_payload()
- def _write_store(self, payload: Dict[str, Any]) -> None:
+ def _write_store(self, payload: CachePayload) -> None:
"""原子写入缓存文件。"""
payload["updated_at"] = _utc_now_iso()
temp_path = self.file_path.with_suffix(f"{self.file_path.suffix}.tmp")
@@ -87,10 +128,10 @@ def _write_store(self, payload: Dict[str, Any]) -> None:
)
temp_path.replace(self.file_path)
- def _cleanup_if_needed(self, payload: Dict[str, Any]) -> Dict[str, Any]:
+ def _cleanup_if_needed(self, payload: CachePayload) -> CachePayload:
"""按配置阈值执行清理,优先淘汰最久未更新的数据。"""
- items = payload.get("items", {})
- if not isinstance(items, dict) or not items:
+ items = payload["items"]
+ if not items:
payload["items"] = {}
return payload
@@ -99,7 +140,7 @@ def _cleanup_if_needed(self, payload: Dict[str, Any]) -> Dict[str, Any]:
return payload
sorted_keys = sorted(
items.keys(),
- key=lambda key: str(items.get(key, {}).get("updated_at", "")),
+ key=lambda key: items.get(key, {}).get("updated_at", ""),
)
overflow = len(items) - self.limit
for key in sorted_keys[:overflow]:
@@ -111,7 +152,7 @@ def _cleanup_if_needed(self, payload: Dict[str, Any]) -> Dict[str, Any]:
while len(json.dumps(payload, ensure_ascii=False).encode("utf-8")) > self.limit and items:
oldest_key = min(
items.keys(),
- key=lambda key: str(items.get(key, {}).get("updated_at", "")),
+ key=lambda key: items.get(key, {}).get("updated_at", ""),
)
items.pop(oldest_key, None)
payload["items"] = items
@@ -131,7 +172,7 @@ def set(self, key: str, value: Any) -> None:
safe_key = str(key)
with self._lock:
payload = self._read_store()
- items = payload.setdefault("items", {})
+ items = payload["items"]
items[safe_key] = {
"value": value,
"updated_at": _utc_now_iso(),
@@ -154,8 +195,8 @@ def get(self, key: str, default: Any = None) -> Any:
safe_key = str(key)
with self._lock:
payload = self._read_store()
- item = payload.get("items", {}).get(safe_key)
- if not isinstance(item, dict):
+ item = payload["items"].get(safe_key)
+ if item is None:
return default
return item.get("value", default)
@@ -172,7 +213,7 @@ def delete(self, key: str) -> bool:
safe_key = str(key)
with self._lock:
payload = self._read_store()
- items = payload.get("items", {})
+ items = payload["items"]
if safe_key not in items:
return False
items.pop(safe_key, None)
@@ -193,7 +234,7 @@ def exists(self, key: str) -> bool:
safe_key = str(key)
with self._lock:
payload = self._read_store()
- return safe_key in payload.get("items", {})
+ return safe_key in payload["items"]
def update(self, mapping: Dict[str, Any]) -> None:
"""
@@ -208,11 +249,9 @@ def update(self, mapping: Dict[str, Any]) -> None:
Raises:
ValueError: mapping 不是字典时抛出。
"""
- if not isinstance(mapping, dict):
- raise ValueError("mapping 必须是字典")
with self._lock:
payload = self._read_store()
- items = payload.setdefault("items", {})
+ items = payload["items"]
now = _utc_now_iso()
for key, value in mapping.items():
items[str(key)] = {
@@ -233,8 +272,8 @@ def all(self) -> Dict[str, Any]:
with self._lock:
payload = self._read_store()
result: Dict[str, Any] = {}
- for key, item in payload.get("items", {}).items():
- if isinstance(item, dict) and "value" in item:
+ for key, item in payload["items"].items():
+ if "value" in item:
result[key] = item["value"]
return result
@@ -263,7 +302,7 @@ def stats(self) -> Dict[str, Any]:
"backend": "json",
"limit_mode": self.limit_mode,
"limit": self.limit,
- "count": len(payload.get("items", {})),
+ "count": len(payload["items"]),
"size_bytes": len(serialized),
"path": str(self.file_path),
}
@@ -278,7 +317,7 @@ def __init__(
plugin_name: str,
instance_id: str,
data_root: Path,
- logger,
+ logger: LoggerLike,
) -> None:
self.plugin_name = plugin_name
self.instance_id = instance_id
diff --git a/app/core/plugins/config_store.py b/app/core/plugins/config_store.py
index 10eeb983..f6f23620 100644
--- a/app/core/plugins/config_store.py
+++ b/app/core/plugins/config_store.py
@@ -1,58 +1,62 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2025-2026 AUTO-MAS Team
+from __future__ import annotations
+
import copy
import json
import uuid
from dataclasses import dataclass
from pathlib import Path
-from typing import Any, Dict, List
+from typing import Any, Mapping, cast
+
+from app.models.plugin import PluginInstanceConfig
from .schema import PluginSchemaManager
class PluginConfigStore:
- """负责读取插件配置,并结合 Schema 生成有效配置。"""
+ """负责读取插件实例配置,并结合 Schema 生成有效配置。"""
- @dataclass
+ @dataclass(slots=True)
class PluginInstance:
id: str
plugin: str
enabled: bool
name: str
- config: Dict[str, Any]
+ config: dict[str, Any]
+
+ @dataclass(slots=True)
+ class _ResolvedInstance:
+ uid: uuid.UUID
+ instance: "PluginConfigStore.PluginInstance"
def __init__(self, schema_manager: PluginSchemaManager | None = None) -> None:
self.schema_manager = schema_manager or PluginSchemaManager()
- def _extract_instance_suffix(self, plugin_name: str, instance_id: str) -> str:
- """从实例 ID 中提取实例号后缀。"""
- if isinstance(instance_id, str) and instance_id.startswith(f"{plugin_name}:"):
- suffix = instance_id.split(":", 1)[1].strip()
- if suffix:
- return suffix
- if isinstance(instance_id, str) and instance_id.strip():
- return instance_id.strip()
- return uuid.uuid4().hex[:5]
+ @staticmethod
+ def _normalize_plugin_name(plugin_name: str) -> str:
+ normalized = str(plugin_name or "").strip()
+ if not normalized:
+ raise ValueError("插件名不能为空")
+ return normalized
+
+ @staticmethod
+ def _normalize_suffix(suffix: str) -> str:
+ normalized = str(suffix or "").strip()
+ if not normalized:
+ raise ValueError("插件实例后缀不能为空")
+ return normalized
def _build_instance_id(self, plugin_name: str, suffix: str) -> str:
"""根据插件名和实例号后缀构造完整实例 ID。"""
- safe_plugin = str(plugin_name or "unknown_plugin").strip() or "unknown_plugin"
- safe_suffix = str(suffix or "").strip() or uuid.uuid4().hex[:5]
- return f"{safe_plugin}:{safe_suffix}"
+ return (
+ f"{self._normalize_plugin_name(plugin_name)}:"
+ f"{self._normalize_suffix(suffix)}"
+ )
def _resolve_plugin_path(self, plugin_name: str) -> Path:
- """根据插件名解析插件目录路径。
-
- 当插件名包含来源后缀(如 `test@local`)时,优先使用基础名目录
- `plugins/test`;若不存在则回退 `plugins/test@local`。
-
- Args:
- plugin_name (str): 插件名。
-
- Returns:
- Path: 解析得到的插件目录路径。
- """
+ """根据插件名解析插件目录路径。"""
plugins_root = Path.cwd() / "plugins"
raw_name = str(plugin_name or "").strip()
if not raw_name:
@@ -64,367 +68,285 @@ def _resolve_plugin_path(self, plugin_name: str) -> Path:
return base_path
return plugins_root / raw_name
- async def _read_root(self) -> Dict[str, Any]:
- """从插件独立配置读取统一配置根对象。"""
+ def _resolve_plugin_source_path(
+ self,
+ plugin_name: str,
+ discovered_plugins: Mapping[str, Any] | None = None,
+ ) -> Path | None:
+ if discovered_plugins is not None and plugin_name in discovered_plugins:
+ return getattr(discovered_plugins[plugin_name], "path", None)
+ return self._resolve_plugin_path(plugin_name)
+
+ @staticmethod
+ def _parse_config_value(value: Any, *, instance_id: str) -> dict[str, Any]:
+ if isinstance(value, dict):
+ return copy.deepcopy(cast(dict[str, Any], value))
+
+ if isinstance(value, str):
+ try:
+ parsed = json.loads(value)
+ except json.JSONDecodeError as exc:
+ raise ValueError(
+ f"插件实例配置不是合法 JSON 对象: {instance_id}"
+ ) from exc
+ if isinstance(parsed, dict):
+ return copy.deepcopy(cast(dict[str, Any], parsed))
+
+ raise ValueError(f"插件实例配置必须是对象: {instance_id}")
+
+ def _build_instance(
+ self,
+ uid: uuid.UUID,
+ instance_config: PluginInstanceConfig,
+ ) -> PluginInstance:
+ plugin_name = self._normalize_plugin_name(instance_config.get("Info", "Plugin"))
+ suffix = self._normalize_suffix(instance_config.get("Info", "Id"))
+ instance_id = self._build_instance_id(plugin_name, suffix)
+ name = str(instance_config.get("Info", "Name") or instance_id)
+ enabled = bool(instance_config.get("Info", "Enabled"))
+ config = self._parse_config_value(
+ instance_config.get("Data", "Config"),
+ instance_id=instance_id,
+ )
+
+ return self.PluginInstance(
+ id=instance_id,
+ plugin=plugin_name,
+ enabled=enabled,
+ name=name,
+ config=config,
+ )
+
+ def _resolve_instance(self, instance_id: str) -> _ResolvedInstance:
from app.core import Config
- instances: List[Dict[str, Any]] = []
- for instance_config in Config.PluginConfig.PluginInstances.values():
- plugin_name = str(instance_config.get("Info", "Plugin") or "").strip()
- suffix = str(instance_config.get("Info", "Id") or "").strip()
- enabled = bool(instance_config.get("Info", "Enabled"))
- name = str(instance_config.get("Info", "Name") or "未命名实例")
+ for uid, instance_config in Config.PluginConfig.items():
+ instance = self._build_instance(uid, instance_config)
+ if instance.id == instance_id:
+ return self._ResolvedInstance(uid=uid, instance=instance)
+ raise ValueError(f"未找到插件实例: {instance_id}")
- config_text = instance_config.get("Data", "Config")
- try:
- config = json.loads(config_text) if isinstance(config_text, str) else {}
- except Exception:
- raw_config_text = instance_config.get("Data", "ConfigRaw")
- config = (
- json.loads(raw_config_text)
- if isinstance(raw_config_text, str)
- else {}
- )
- if not isinstance(config, dict):
- config = {}
-
- if not plugin_name:
+ def _collect_existing_instance_ids(
+ self,
+ *,
+ exclude_uid: uuid.UUID | None = None,
+ ) -> set[str]:
+ from app.core import Config
+
+ result: set[str] = set()
+ for uid, instance_config in Config.PluginConfig.items():
+ if exclude_uid is not None and uid == exclude_uid:
continue
+ result.add(self._build_instance(uid, instance_config).id)
+ return result
+
+ def _allocate_unique_instance_id(
+ self,
+ plugin_name: str,
+ *,
+ exclude_uid: uuid.UUID | None = None,
+ ) -> str:
+ existing_ids = self._collect_existing_instance_ids(exclude_uid=exclude_uid)
+
+ for suffix_length in (5, 6, 7, 8):
+ for _ in range(32):
+ suffix = uuid.uuid4().hex[:suffix_length]
+ instance_id = self._build_instance_id(plugin_name, suffix)
+ if instance_id not in existing_ids:
+ return instance_id
- instances.append(
- {
- "id": self._build_instance_id(plugin_name, suffix),
- "plugin": plugin_name,
- "enabled": enabled,
- "name": name,
- "config": config,
- }
- )
-
- raw_version = Config.PluginConfig.get("Data", "Version")
-
- try:
- version = int(raw_version)
- except Exception:
- version = 1
-
- return {
- "version": max(1, version),
- "instances": instances,
- }
-
- async def _write_root(self, root: Dict[str, Any]) -> None:
- """写入插件独立配置中的统一配置根对象。"""
+ raise RuntimeError(f"插件实例 ID 生成失败: {plugin_name}")
+
+ def generate_instance_id(self, plugin_name: str) -> str:
+ """生成插件实例 ID。"""
+ normalized_plugin = self._normalize_plugin_name(plugin_name)
+ return self._allocate_unique_instance_id(normalized_plugin)
+
+ async def load_instances(self) -> list[PluginInstance]:
+ """读取并校验插件实例列表。"""
from app.core import Config
- version = int(root.get("version", 1))
- raw_instances = root.get("instances", [])
- if not isinstance(raw_instances, list):
- raise ValueError("插件统一配置中的 instances 必须是数组")
-
- instance_index: Dict[str, Dict[str, Any]] = {}
- instance_list: List[Dict[str, str]] = []
-
- for item in raw_instances:
- if not isinstance(item, dict):
- raise ValueError("instances 中存在非对象项")
-
- plugin_name = item.get("plugin")
- if not isinstance(plugin_name, str) or not plugin_name:
- raise ValueError("插件实例缺少有效的 plugin 字段")
-
- instance_id = item.get("id")
- if not isinstance(instance_id, str) or not instance_id:
- raise ValueError("插件实例缺少有效的 id 字段")
-
- enabled = item.get("enabled", True)
- if not isinstance(enabled, bool):
- raise ValueError(f"插件实例 {instance_id} 的 enabled 字段必须为布尔值")
-
- config = item.get("config", {})
- if not isinstance(config, dict):
- raise ValueError(f"插件实例 {instance_id} 的 config 必须是对象")
-
- name = str(item.get("name") or instance_id)
- suffix = self._extract_instance_suffix(plugin_name, instance_id)
-
- effective_config = self.load_effective_config(
- plugin_name,
- self._resolve_plugin_path(plugin_name),
- config,
- )
-
- uid = str(uuid.uuid4())
- instance_list.append(
- {
- "uid": uid,
- "type": "PluginInstanceConfig",
- }
- )
- instance_index[uid] = {
+ seen_ids: set[str] = set()
+ result: list[PluginConfigStore.PluginInstance] = []
+
+ for uid, instance_config in Config.PluginConfig.items():
+ instance = self._build_instance(uid, instance_config)
+ if instance.id in seen_ids:
+ raise ValueError(f"插件实例 id 重复: {instance.id}")
+ seen_ids.add(instance.id)
+ result.append(instance)
+
+ return result
+
+ async def create_instance(
+ self,
+ *,
+ plugin_name: str,
+ name: str | None = None,
+ enabled: bool = True,
+ raw_config: dict[str, Any] | None = None,
+ discovered_plugins: Mapping[str, Any] | None = None,
+ ) -> PluginInstance:
+ from app.core import Config
+
+ normalized_plugin = self._normalize_plugin_name(plugin_name)
+ if discovered_plugins is not None and normalized_plugin not in discovered_plugins:
+ raise ValueError(f"未发现插件: {normalized_plugin}")
+
+ effective_config = self.load_effective_config(
+ normalized_plugin,
+ self._resolve_plugin_source_path(normalized_plugin, discovered_plugins),
+ raw_config or {},
+ )
+ generated_instance_id = self.generate_instance_id(normalized_plugin)
+ _, suffix = generated_instance_id.split(":", 1)
+ uid, plugin_config = await Config.PluginConfig.add(PluginInstanceConfig)
+ await plugin_config.set_many(
+ {
"Info": {
- "Plugin": plugin_name,
+ "Plugin": normalized_plugin,
"Id": suffix,
"Enabled": enabled,
- "Name": name,
+ "Name": name or f"{normalized_plugin} 实例",
},
"Data": {
- "ConfigRaw": json.dumps(effective_config, ensure_ascii=False),
+ "Config": json.dumps(effective_config, ensure_ascii=False),
},
}
-
- payload: Dict[str, Any] = {
- "Data": {
- "Version": max(1, version),
- },
- "SubConfigsInfo": {
- "PluginInstances": {
- "instances": instance_list,
- **instance_index,
- }
- },
- }
-
- await Config.PluginConfig.load(payload)
-
- def generate_instance_id(self, plugin_name: str) -> str:
- """
- 生成插件实例 ID。
-
- Args:
- plugin_name (str): 插件名。
-
- Returns:
- str: 形如 plugin_name:xxxxx 的实例 ID。
- """
- return f"{plugin_name}:{uuid.uuid4().hex[:5]}"
-
- async def get_root(
- self,
- plugins_dir,
- discovered_plugins,
- auto_create_missing: bool = False,
- ) -> Dict[str, Any]:
- """
- 读取统一插件配置根对象,并按需补齐缺失实例。
-
- Args:
- plugins_dir: 插件目录路径(当前实现中仅用于接口兼容)。
- discovered_plugins: 已发现插件映射。
- auto_create_missing (bool): 是否自动创建缺失插件的默认实例。
-
- Returns:
- Dict[str, Any]: 统一插件配置根对象。
- """
- return await self.ensure_instances(
- plugins_dir,
- discovered_plugins,
- auto_create_missing=auto_create_missing,
)
+ return self._build_instance(uid, plugin_config)
- async def save_root(self, plugins_dir, root: Dict[str, Any]) -> None:
- """
- 保存统一插件配置根对象到持久化配置。
-
- Args:
- plugins_dir: 插件目录路径(当前实现中仅用于接口兼容)。
- root (Dict[str, Any]): 待保存的配置根对象。
-
- Returns:
- None: 无返回值。
-
- Raises:
- ValueError: 在以下场景抛出:
- 1) root 不是字典对象;
- 2) root.instances 缺失或不是列表。
- """
- if not isinstance(root, dict):
- raise ValueError("插件统一配置根对象必须是字典")
- instances = root.get("instances")
- if not isinstance(instances, list):
- raise ValueError("插件统一配置缺少 instances 列表")
- root.setdefault("version", 1)
- await self._write_root(root)
-
- async def ensure_instances(
- self,
- plugins_dir,
- discovered_plugins,
- auto_create_missing: bool = False,
- ) -> Dict[str, Any]:
- """
- 确保统一配置中的实例列表满足当前发现结果。
-
- Args:
- plugins_dir: 插件目录路径(当前实现中仅用于接口兼容)。
- discovered_plugins: 已发现插件映射。
- auto_create_missing (bool): 是否为缺失插件自动创建默认实例。
-
- Returns:
- Dict[str, Any]: 更新后的统一插件配置根对象。
- """
- root = await self._read_root()
- instances: List[Dict[str, Any]] = root.get("instances", [])
-
- existing_plugins = {
- item.get("plugin")
- for item in instances
- if isinstance(item, dict) and isinstance(item.get("plugin"), str)
- }
-
- changed = False
- if auto_create_missing:
- for plugin_name in discovered_plugins.keys():
- if plugin_name in existing_plugins:
- continue
- instances.append(
- {
- "id": self._generate_instance_id(plugin_name),
- "plugin": plugin_name,
- "enabled": True,
- "name": f"{plugin_name} 默认实例",
- "config": {},
- }
- )
- changed = True
-
- root["instances"] = instances
- if changed:
- await self._write_root(root)
-
- return root
-
- async def load_instances(
+ async def update_instance(
self,
- plugins_dir,
- discovered_plugins,
- auto_create_missing: bool = False,
- ) -> List[PluginInstance]:
- """
- 读取并校验插件实例列表。
-
- Args:
- plugins_dir: 插件目录路径(当前实现中仅用于接口兼容)。
- discovered_plugins: 已发现插件映射。
- auto_create_missing (bool): 是否自动创建缺失插件实例。
-
- Returns:
- List[PluginInstance]: 校验通过的插件实例对象列表。
-
- Raises:
- ValueError: 在以下场景抛出:
- 1) instances 中存在非对象项;
- 2) 实例 id 为空或不是字符串;
- 3) 实例 id 重复;
- 4) plugin 字段无效;
- 5) enabled 字段不是布尔值;
- 6) config 字段不是对象。
- """
- root = await self.ensure_instances(
- plugins_dir,
- discovered_plugins,
- auto_create_missing=auto_create_missing,
+ instance_id: str,
+ *,
+ plugin_name: str | None = None,
+ name: str | None = None,
+ enabled: bool | None = None,
+ raw_config: dict[str, Any] | None = None,
+ discovered_plugins: Mapping[str, Any] | None = None,
+ ) -> tuple[PluginInstance, PluginInstance]:
+ from app.core import Config
+
+ resolved = self._resolve_instance(instance_id)
+ current = resolved.instance
+ next_plugin = (
+ current.plugin
+ if plugin_name is None
+ else self._normalize_plugin_name(plugin_name)
+ )
+ if discovered_plugins is not None and next_plugin not in discovered_plugins:
+ raise ValueError(f"未发现插件: {next_plugin}")
+
+ next_name = current.name if name is None else str(name)
+ next_enabled = current.enabled if enabled is None else enabled
+ next_config_input = current.config if raw_config is None else raw_config
+ effective_config = self.load_effective_config(
+ next_plugin,
+ self._resolve_plugin_source_path(next_plugin, discovered_plugins),
+ next_config_input,
)
- result: List[PluginConfigStore.PluginInstance] = []
- seen_ids: set[str] = set()
- for item in root.get("instances", []):
- if not isinstance(item, dict):
- raise ValueError("instances 中存在非对象项")
-
- instance_id = item.get("id")
- plugin_name = item.get("plugin")
- enabled = item.get("enabled", True)
- name = item.get("name") or str(instance_id or "未命名实例")
- config = item.get("config", {})
-
- if not isinstance(instance_id, str) or not instance_id:
- raise ValueError("插件实例 id 必须是非空字符串")
- if instance_id in seen_ids:
- raise ValueError(f"插件实例 id 重复: {instance_id}")
- seen_ids.add(instance_id)
- if not isinstance(plugin_name, str) or not plugin_name:
- raise ValueError(f"插件实例 {instance_id} 的 plugin 字段无效")
- if not isinstance(enabled, bool):
- raise ValueError(f"插件实例 {instance_id} 的 enabled 字段必须为布尔值")
- if not isinstance(config, dict):
- raise ValueError(f"插件实例 {instance_id} 的 config 必须是对象")
-
- result.append(
- self.PluginInstance(
- id=instance_id,
- plugin=plugin_name,
- enabled=enabled,
- name=str(name),
- config=copy.deepcopy(config),
- )
- )
+ suffix = self._normalize_suffix(
+ Config.PluginConfig[resolved.uid].get("Info", "Id")
+ )
+ next_instance_id = self._build_instance_id(next_plugin, suffix)
+ if next_instance_id in self._collect_existing_instance_ids(exclude_uid=resolved.uid):
+ _, suffix = self._allocate_unique_instance_id(
+ next_plugin,
+ exclude_uid=resolved.uid,
+ ).split(":", 1)
+
+ await Config.PluginConfig[resolved.uid].set_many(
+ {
+ "Info": {
+ "Plugin": next_plugin,
+ "Id": suffix,
+ "Enabled": next_enabled,
+ "Name": next_name,
+ },
+ "Data": {
+ "Config": json.dumps(effective_config, ensure_ascii=False),
+ },
+ },
+ )
+ updated = self._build_instance(resolved.uid, Config.PluginConfig[resolved.uid])
+ return current, updated
- return result
+ async def delete_instance(self, instance_id: str) -> PluginInstance:
+ from app.core import Config
- def normalize_raw_config(self, plugin_name: str, raw_config: Dict[str, Any]) -> Dict[str, Any]:
- """
- 规范化并深拷贝原始配置对象。
+ resolved = self._resolve_instance(instance_id)
+ await Config.PluginConfig.remove(resolved.uid)
+ return resolved.instance
- Args:
- plugin_name (str): 插件名。
- raw_config (Dict[str, Any]): 原始配置对象。
+ async def repair_invalid_instances(
+ self,
+ *,
+ missing_instance_ids: set[str],
+ failed_instance_ids: set[str],
+ ) -> tuple[list[str], list[str]]:
+ from app.core import Config
- Returns:
- Dict[str, Any]: 规范化后的配置副本。
+ remove_ids = set(missing_instance_ids)
+ disable_ids = set(failed_instance_ids) - remove_ids
+ if not remove_ids and not disable_ids:
+ return [], []
+
+ removed_ids: list[str] = []
+ disabled_ids: list[str] = []
+ resolved_instances = [
+ self._ResolvedInstance(uid=uid, instance=self._build_instance(uid, config))
+ for uid, config in Config.PluginConfig.items()
+ ]
+
+ async with Config.PluginConfig.transaction():
+ for resolved in resolved_instances:
+ current_id = resolved.instance.id
+ if current_id in remove_ids:
+ await Config.PluginConfig.remove(resolved.uid)
+ removed_ids.append(current_id)
+ continue
- Raises:
- ValueError: 原始配置不是字典时抛出。
- """
- if not isinstance(raw_config, dict):
- raise ValueError(f"插件配置必须是对象: {plugin_name}")
+ if current_id in disable_ids and resolved.instance.enabled:
+ await Config.PluginConfig[resolved.uid].set_many(
+ {
+ "Info": {
+ "Enabled": False,
+ }
+ }
+ )
+ disabled_ids.append(current_id)
+
+ return removed_ids, disabled_ids
+
+ def normalize_raw_config(
+ self, plugin_name: str, raw_config: dict[str, Any]
+ ) -> dict[str, Any]:
+ """规范化并深拷贝原始配置对象。"""
return copy.deepcopy(raw_config)
- def load_schema(self, plugin_name: str, plugin_path: Path | None) -> Dict[str, Dict[str, Any]]:
- """
- 加载插件 Schema,兼容本地路径与 PyPI 安装模块。
-
- Args:
- plugin_name (str): 插件名。
- plugin_path (Path | None): 插件本地目录路径。
-
- Returns:
- Dict[str, Dict[str, Any]]: 插件 Schema 字段定义字典。
- """
+ def load_schema(
+ self,
+ plugin_name: str,
+ plugin_path: Path | None,
+ ) -> dict[str, dict[str, Any]]:
+ """加载插件 Schema,兼容本地路径与 PyPI 安装模块。"""
return self.schema_manager.load_schema(plugin_name, plugin_path)
def load_effective_config(
self,
plugin_name: str,
plugin_path: Path | None,
- raw_config: Dict[str, Any],
- ) -> Dict[str, Any]:
- """
- 基于 Schema 生成并校验插件有效配置。
-
- Args:
- plugin_name (str): 插件名。
- plugin_path (Path | None): 插件本地目录路径。
- raw_config (Dict[str, Any]): 原始配置对象。
-
- Returns:
- Dict[str, Any]: 通过校验并补齐默认值的有效配置。
-
- Raises:
- ValueError: 插件未声明 Schema 但 raw_config 含有配置项时抛出。
- PluginSchemaError: 在以下场景抛出:
- 1) Schema 加载失败或定义不合法;
- 2) 配置缺失必填项;
- 3) 配置项类型与 Schema 声明不匹配。
- """
+ raw_config: dict[str, Any],
+ ) -> dict[str, Any]:
+ """基于 Schema 生成并校验插件有效配置。"""
schema = self.load_schema(plugin_name, plugin_path)
normalized_config = self.normalize_raw_config(plugin_name, raw_config)
if not schema:
if normalized_config:
- raise ValueError(
- f"插件 {plugin_name} 使用了配置项但未声明 schema"
- )
+ raise ValueError(f"插件 {plugin_name} 使用了配置项但未声明 schema")
return normalized_config
return self.schema_manager.apply_defaults_and_validate(
diff --git a/app/core/plugins/context.py b/app/core/plugins/context.py
index 84ba6fd5..0186295e 100644
--- a/app/core/plugins/context.py
+++ b/app/core/plugins/context.py
@@ -1,18 +1,22 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2025-2026 AUTO-MAS Team
-from pathlib import Path
-from copy import deepcopy
-from typing import Any, Dict, Callable, Optional, Iterator
import asyncio
+from pathlib import Path
+from typing import Any, Callable, Generic, TypeVar
+from app.core.config import PluginConfigBase
from .cache_store import PluginCacheManager
+from .event_bus import EventBus
from .event_contract import EventErrorPolicy, EventScope
from .runtime_api import RuntimeAPI
+from app.utils.logger import LoggerLike
+
+TPluginConfig = TypeVar("TPluginConfig", bound=PluginConfigBase)
-class PluginContext:
+class PluginContext(Generic[TPluginConfig]):
"""面向插件的上下文对象,公开受控的 MAS 功能。"""
def __init__(
@@ -20,22 +24,22 @@ def __init__(
*,
plugin_name: str,
instance_id: str | None = None,
- config: Dict[str, Any],
- logger,
- events,
- runtime_capabilities: Optional[Dict[str, Callable[..., Any]]] = None,
+ config: TPluginConfig,
+ logger: LoggerLike,
+ events: EventBus,
+ runtime_capabilities: dict[str, Callable[..., Any]] | None = None,
) -> None:
# 基础必要属性
self.plugin_name = plugin_name
self.instance_id = instance_id or plugin_name
- self.config = PluginConfigProxy(config)
+ self.config = config
self.logger = logger
self.event = PluginEventFacade(
plugin_name=self.plugin_name,
instance_id=self.instance_id,
events=events,
)
-
+
# 解释器能力函数集合
self.runtime_api = RuntimeAPI(
plugin_name=self.plugin_name,
@@ -54,97 +58,6 @@ def __init__(
logger=self.logger,
)
-class PluginConfigProxy(dict):
- """插件配置代理,兼容字典访问并提供 set/update/reset 语义。"""
-
- def __init__(self, initial: Dict[str, Any] | None = None) -> None:
- data = deepcopy(initial) if isinstance(initial, dict) else {}
- super().__init__(data)
- self._source_config: Dict[str, Any] = deepcopy(data)
-
- def set(self, key: str, value: Any) -> None:
- """
- 设置单个配置项。
-
- Args:
- key (str): 配置键。
- value (Any): 配置值。
-
- Returns:
- None: 无返回值。
-
- Raises:
- TypeError: `key` 不是字符串时抛出。
- ValueError: `key` 为空字符串时抛出。
- """
- if not isinstance(key, str):
- raise TypeError("配置键必须是字符串")
- if not key.strip():
- raise ValueError("配置键不能为空字符串")
- self[key] = value
-
- def update(self, values: Dict[str, Any] | None = None, **kwargs: Any) -> None:
- """
- 批量更新配置项。
-
- Args:
- values (Dict[str, Any] | None): 待合并配置字典,可为 None。
- **kwargs (Any): 额外键值对参数。
-
- Returns:
- None: 无返回值。
-
- Raises:
- TypeError: `values` 非字典时抛出。
- """
- if values is not None and not isinstance(values, dict):
- raise TypeError("update(values) 的 values 必须是字典或 None")
-
- payload: Dict[str, Any] = {}
- if isinstance(values, dict):
- payload.update(values)
- if kwargs:
- payload.update(kwargs)
-
- for key, value in payload.items():
- self.set(key, value)
-
- def reset(self, values: Dict[str, Any] | None = None) -> Dict[str, Any]:
- """
- 删除源配置并以新配置重建当前配置。
-
- Args:
- values (Dict[str, Any] | None): 新配置对象;为 None 时重置为空字典。
-
- Returns:
- Dict[str, Any]: 重置后的配置快照。
-
- Raises:
- TypeError: `values` 非字典且非 None 时抛出。
- """
- if values is not None and not isinstance(values, dict):
- raise TypeError("reset(values) 的 values 必须是字典或 None")
-
- next_source = deepcopy(values) if isinstance(values, dict) else {}
- self._source_config = deepcopy(next_source)
- super().clear()
- super().update(deepcopy(next_source))
- return self.to_dict()
-
- def to_dict(self) -> Dict[str, Any]:
- """返回当前配置的深拷贝字典。"""
- return deepcopy(dict(self))
-
- def source_dict(self) -> Dict[str, Any]:
- """返回源配置的深拷贝字典。"""
- return deepcopy(self._source_config)
-
- def __iter__(self) -> Iterator[Any]:
- """返回配置键的迭代器。"""
- return super().__iter__()
-
-
-
class RuntimeFacade:
"""RuntimeAPI 语法糖包装层,提供更简洁的调用方式。"""
@@ -152,21 +65,21 @@ class RuntimeFacade:
def __init__(self, api: RuntimeAPI) -> None:
self._api = api
- def info(self, force_refresh: bool = False) -> Dict[str, Any]:
+ def info(self, force_refresh: bool = False) -> dict[str, Any]:
"""获取运行时环境信息。"""
return self._api.get_runtime_info(force_refresh=force_refresh)
def set(
self,
*,
- python_executable: Optional[str] = None,
- timeout_seconds: Optional[int] = None,
- options: Optional[Dict[str, Any]] = None,
- **kwargs,
- ) -> Dict[str, Any]:
+ python_executable: str | None = None,
+ timeout_seconds: int | None = None,
+ options: dict[str, Any] | None = None,
+ **kwargs: Any,
+ ) -> dict[str, Any]:
"""更新 runtime 配置选项。"""
- payload: Dict[str, Any] = {}
- if isinstance(options, dict):
+ payload: dict[str, Any] = {}
+ if options is not None:
payload.update(options)
if python_executable is not None:
payload["python_executable"] = python_executable
@@ -180,9 +93,9 @@ async def run(
self,
code: str,
*,
- python_executable: Optional[str] = None,
- timeout_seconds: Optional[int] = None,
- ) -> Dict[str, Any]:
+ python_executable: str | None = None,
+ timeout_seconds: int | None = None,
+ ) -> dict[str, Any]:
"""执行 Python 代码片段。"""
return await self._api.run_python_snippet(
code,
@@ -201,9 +114,9 @@ class PluginEventFacade:
def __init__(
self,
*,
- plugin_name: str,
- instance_id: str,
- events,
+ plugin_name: object,
+ instance_id: object,
+ events: EventBus,
) -> None:
"""
初始化插件事件门面。
@@ -232,10 +145,6 @@ def __init__(
if not instance_id.strip():
raise ValueError("instance_id 不能为空字符串")
- for method_name in ("on", "off", "emit"):
- if not hasattr(events, method_name):
- raise AttributeError(f"events 缺少必要方法: {method_name}")
-
self._plugin_name = plugin_name
self._instance_id = instance_id
self._events = events
@@ -328,7 +237,7 @@ async def emit_async(
ValueError: 底层事件总线校验参数失败时抛出。
Exception: 在 `error_policy="raise"` 且监听器失败时向上抛出。
"""
- kwargs: Dict[str, Any] = {
+ kwargs: dict[str, Any] = {
"scope": scope,
"error_policy": error_policy,
}
@@ -390,5 +299,4 @@ def off_all(self) -> None:
Returns:
None: 无返回值。
"""
- if hasattr(self._events, "off_by_instance"):
- self._events.off_by_instance(self._instance_id)
+ self._events.off_by_instance(self._instance_id)
diff --git a/app/core/plugins/decorators.py b/app/core/plugins/decorators.py
index 0b176115..acfa645b 100644
--- a/app/core/plugins/decorators.py
+++ b/app/core/plugins/decorators.py
@@ -2,7 +2,7 @@
# Copyright © 2025-2026 AUTO-MAS Team
from dataclasses import dataclass
-from typing import Any, Callable, Final, Literal
+from typing import Any, Callable, Final, Literal, cast
EventScope = Literal["global", "instance"]
@@ -27,7 +27,7 @@ class EventSubscription:
def on_event(
- event: str,
+ event: object,
*,
priority: int = 0,
scope: EventScope = "instance",
@@ -98,7 +98,7 @@ def get_event_subscriptions(target: Any) -> list[EventSubscription]:
return []
result: list[EventSubscription] = []
- for item in raw:
+ for item in cast(list[object], raw):
if isinstance(item, EventSubscription):
result.append(item)
return result
diff --git a/app/core/plugins/dsl_v2.py b/app/core/plugins/dsl_v2.py
deleted file mode 100644
index be6a0bcb..00000000
--- a/app/core/plugins/dsl_v2.py
+++ /dev/null
@@ -1,579 +0,0 @@
-# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
-# Copyright © 2025-2026 AUTO-MAS Team
-
-from __future__ import annotations
-
-from copy import deepcopy
-from dataclasses import dataclass, field
-from pathlib import Path as _Path
-from types import UnionType
-from typing import (
- Annotated,
- Any,
- Dict,
- List,
- Mapping,
- MutableMapping,
- Optional,
- Protocol,
- TypeVar,
- Union,
- cast,
- get_args,
- get_origin,
- get_type_hints,
-)
-
-from pydantic import BaseModel, ConfigDict
-from pydantic_core import PydanticUndefined
-
-
-NoneType = type(None)
-ModelT = TypeVar("ModelT", bound="PluginConfigModel")
-
-
-class Codec(Protocol):
- """字段编解码协议:前端值、运行时值、存储值三者互转。"""
-
- def from_frontend(self, value: Any) -> Any: ...
-
- def to_frontend(self, value: Any) -> Any: ...
-
- def from_storage(self, value: Any) -> Any: ...
-
- def to_storage(self, value: Any) -> Any: ...
-
-
-class IdentityCodec:
- """默认编解码器:不做转换。"""
-
- def from_frontend(self, value: Any) -> Any:
- return value
-
- def to_frontend(self, value: Any) -> Any:
- return value
-
- def from_storage(self, value: Any) -> Any:
- return value
-
- def to_storage(self, value: Any) -> Any:
- return value
-
-
-class PathCodec:
- """路径示例编解码器。
-
- 允许前端返回字符串或对象:
- - "C:/tmp/a.txt"
- - {"path": "C:/tmp/a.txt", ...}
- """
-
- def from_frontend(self, value: Any) -> Any:
- if isinstance(value, dict) and "path" in value:
- return str(value.get("path") or "")
- if value is None:
- return ""
- return str(value)
-
- def to_frontend(self, value: Any) -> Any:
- text = "" if value is None else str(value)
- return {"path": text}
-
- def from_storage(self, value: Any) -> Any:
- if value is None:
- return ""
- return str(value)
-
- def to_storage(self, value: Any) -> Any:
- text = "" if value is None else str(value)
- if not text:
- return text
- return str(_Path(text))
-
-
-class CodecRegistry:
- """Codec 注册表。"""
-
- def __init__(self) -> None:
- self._codecs: Dict[str, Codec] = {}
- self.register("identity", IdentityCodec())
- self.register("path", PathCodec())
-
- def register(self, name: str, codec: Codec) -> None:
- key = str(name).strip()
- if not key:
- raise ValueError("codec 名称不能为空")
- self._codecs[key] = codec
-
- def get(self, name: str) -> Codec:
- codec = self._codecs.get(name)
- if codec is None:
- raise KeyError(f"未注册的 codec: {name}")
- return codec
-
-
-GLOBAL_CODEC_REGISTRY = CodecRegistry()
-FIELD_UI_EXTRA_ATTR = "__plg_field_ui_extra__"
-
-
-@dataclass(frozen=True)
-class TypePreset:
- """类型预设元数据。
-
- 说明:
- - schema_type / item_type 对齐现有插件 schema 协议
- - ui 为前端组件元数据
- - codec 可为注册名或实例
- """
-
- schema_type: str
- item_type: str | None = None
- format: str | None = None
- codec: str | Codec | None = None
- ui: Dict[str, Any] = field(default_factory=dict)
-
-
-@dataclass(frozen=True)
-class Desc:
- """字段描述元数据,用于声明 description。"""
-
- text: str
-
-
-def preset(
- py_type: Any,
- *,
- schema_type: str,
- item_type: str | None = None,
- format: str | None = None,
- codec: str | Codec | None = None,
- widget: str | None = None,
- ui: Optional[Dict[str, Any]] = None,
-) -> Any:
- """声明一个可复用预定义类型。"""
- ui_data = deepcopy(ui or {})
- if widget and "widget" not in ui_data:
- ui_data["widget"] = widget
- return Annotated[
- py_type,
- TypePreset(
- schema_type=schema_type,
- item_type=item_type,
- format=format,
- codec=codec,
- ui=ui_data,
- ),
- ]
-
-
-def describe(py_type: Any, text: str) -> Any:
- """为类型附加 description 元数据。"""
- return Annotated[py_type, Desc(text)]
-
-
-def _unwrap_annotated(annotation: Any) -> tuple[Any, list[Any]]:
- meta: list[Any] = []
- current = annotation
- while get_origin(current) is Annotated:
- args = list(get_args(current))
- if not args:
- break
- current = args[0]
- meta.extend(args[1:])
- return current, meta
-
-
-def _unwrap_optional(annotation: Any) -> tuple[Any, bool]:
- origin = get_origin(annotation)
- if origin not in (Union, UnionType):
- return annotation, False
-
- args = get_args(annotation)
- non_none_args = [item for item in args if item is not NoneType]
- if len(non_none_args) == 1 and len(non_none_args) != len(args):
- return non_none_args[0], True
- return annotation, False
-
-
-def _infer_schema_type(annotation: Any) -> str:
- if annotation is bool:
- return "boolean"
- if annotation is str:
- return "string"
- if annotation in (int, float):
- return "number"
-
- origin = get_origin(annotation)
- if origin in (list, List):
- return "list"
- if origin in (dict, Dict, Mapping, MutableMapping):
- return "key_value"
-
- return "string"
-
-
-def _infer_item_type(annotation: Any) -> str:
- base, _ = _unwrap_annotated(annotation)
- base, _ = _unwrap_optional(base)
-
- if base is str:
- return "string"
- if base is bool:
- return "boolean"
- if base in (int, float):
- return "number"
- return "any"
-
-
-def _resolve_codec(codec: str | Codec | None, registry: CodecRegistry) -> Codec:
- if codec is None:
- return registry.get("identity")
- if isinstance(codec, str):
- return registry.get(codec)
- return codec
-
-
-def _invoke_default_factory(factory: Any) -> Any:
- """兼容 pydantic default_factory 的 0/1 参数签名。"""
- try:
- return factory()
- except TypeError:
- return factory({})
-
-
-@dataclass
-class _FieldRuntime:
- name: str
- schema_type: str
- item_type: str | None
- nullable: bool
- required: bool
- default: Any
- has_default: bool
- description: str | None
- format: str | None
- ui: Dict[str, Any]
- codec: Codec
- item_codec: Codec
-
-
-class PluginConfigModel(BaseModel):
- """DSL v2 配置模型基类。
-
- 设计目标:
- - 用类型声明配置字段(尽量保持语法干净)
- - 通过预定义类型 + codec 实现前后端/存储值转换
- - 允许在 Config 类中定义自定义校验与后处理
- """
-
- model_config = ConfigDict(extra="forbid")
-
- @classmethod
- def codec_registry(cls) -> CodecRegistry:
- return GLOBAL_CODEC_REGISTRY
-
- @classmethod
- def validate_config(cls, data: "PluginConfigModel") -> None:
- """自定义跨字段校验入口(可选覆写)。"""
-
- def post_load(self) -> "PluginConfigModel":
- """加载后处理(可选覆写)。"""
- return self
-
- def pre_save(self) -> "PluginConfigModel":
- """保存前处理(可选覆写)。"""
- return self
-
- @classmethod
- def _build_runtime_fields(cls) -> Dict[str, _FieldRuntime]:
- hints = get_type_hints(cls, include_extras=True)
- runtime_fields: Dict[str, _FieldRuntime] = {}
- registry = cls.codec_registry()
- class_ui_extra = getattr(cls, FIELD_UI_EXTRA_ATTR, {})
- if not isinstance(class_ui_extra, dict):
- class_ui_extra = {}
-
- # 支持将额外前端元数据直接写在 Config 类内:__extra__ = {...}
- inline_extra = getattr(cls, "__extra__", {})
- if not isinstance(inline_extra, dict):
- inline_extra = {}
-
- merged_ui_extra: Dict[str, Any] = deepcopy(class_ui_extra)
- for field_name, payload in inline_extra.items():
- if not isinstance(payload, dict):
- continue
- current = merged_ui_extra.get(field_name, {})
- if not isinstance(current, dict):
- current = {}
- current.update(deepcopy(payload))
- merged_ui_extra[field_name] = current
-
- for field_name, field_info in cls.model_fields.items():
- hint = hints.get(field_name, field_info.annotation)
-
- ann, nullable = _unwrap_optional(hint)
- ann_base, metas = _unwrap_annotated(ann)
- preset_meta = next((m for m in metas if isinstance(m, TypePreset)), None)
- desc_meta = next((m for m in metas if isinstance(m, Desc)), None)
-
- schema_type = (
- preset_meta.schema_type if preset_meta else _infer_schema_type(ann_base)
- )
- item_type = preset_meta.item_type if preset_meta else None
-
- item_codec: Codec = registry.get("identity")
- if schema_type in {"list", "table"}:
- origin = get_origin(ann_base)
- if origin in (list, List):
- item_ann = get_args(ann_base)[0] if get_args(ann_base) else Any
- item_base, item_metas = _unwrap_annotated(item_ann)
- item_preset = next(
- (m for m in item_metas if isinstance(m, TypePreset)),
- None,
- )
- if item_type is None:
- item_type = _infer_item_type(item_base)
- if item_preset is not None:
- item_codec = _resolve_codec(item_preset.codec, registry)
-
- if schema_type == "key_value":
- origin = get_origin(ann_base)
- if origin in (dict, Dict, Mapping, MutableMapping):
- args = get_args(ann_base)
- value_ann = args[1] if len(args) == 2 else Any
- value_base, value_metas = _unwrap_annotated(value_ann)
- value_preset = next(
- (m for m in value_metas if isinstance(m, TypePreset)),
- None,
- )
- if item_type is None:
- item_type = _infer_item_type(value_base)
- if value_preset is not None:
- item_codec = _resolve_codec(value_preset.codec, registry)
-
- if item_type is None and schema_type == "list":
- item_type = "any"
- if item_type is None and schema_type == "table":
- item_type = "object"
-
- required = field_info.is_required()
- has_default = (
- field_info.default is not PydanticUndefined
- or field_info.default_factory is not None
- )
-
- if field_info.default_factory is not None:
- default_value = _invoke_default_factory(field_info.default_factory)
- elif field_info.default is not PydanticUndefined:
- default_value = deepcopy(field_info.default)
- else:
- default_value = None
-
- runtime_fields[field_name] = _FieldRuntime(
- name=field_name,
- schema_type=schema_type,
- item_type=item_type,
- nullable=nullable,
- required=required,
- default=default_value,
- has_default=has_default,
- description=desc_meta.text if desc_meta else field_info.description,
- format=preset_meta.format if preset_meta else None,
- ui=deepcopy(preset_meta.ui) if preset_meta else {},
- codec=_resolve_codec(
- preset_meta.codec if preset_meta else None, registry
- ),
- item_codec=item_codec,
- )
-
- field_extra = merged_ui_extra.get(field_name)
- if isinstance(field_extra, dict):
- extra_copy = deepcopy(field_extra)
- if "description" in extra_copy and isinstance(
- extra_copy["description"], str
- ):
- runtime_fields[field_name].description = extra_copy.pop(
- "description"
- )
- if "format" in extra_copy and isinstance(extra_copy["format"], str):
- runtime_fields[field_name].format = extra_copy.pop("format")
- if "item_type" in extra_copy and isinstance(
- extra_copy["item_type"], str
- ):
- runtime_fields[field_name].item_type = extra_copy.pop("item_type")
-
- nested_ui = extra_copy.pop("ui", None)
- if isinstance(nested_ui, dict):
- runtime_fields[field_name].ui.update(deepcopy(nested_ui))
-
- runtime_fields[field_name].ui.update(extra_copy)
-
- return runtime_fields
-
- @classmethod
- def to_schema_dict(cls) -> Dict[str, Dict[str, Any]]:
- """导出与现有插件 schema 兼容的字典结构。"""
- result: Dict[str, Dict[str, Any]] = {}
-
- for name, runtime in cls._build_runtime_fields().items():
- item: Dict[str, Any] = {
- "type": runtime.schema_type,
- "required": runtime.required,
- }
-
- if runtime.has_default:
- item["default"] = deepcopy(runtime.default)
- if runtime.description:
- item["description"] = runtime.description
- if runtime.nullable:
- item["nullable"] = True
- if runtime.item_type is not None and runtime.schema_type in {
- "list",
- "key_value",
- "table",
- }:
- item["item_type"] = runtime.item_type
- if runtime.format:
- item["format"] = runtime.format
- if runtime.ui:
- item["ui"] = deepcopy(runtime.ui)
-
- result[name] = item
-
- return result
-
- @classmethod
- def _convert_input_values(cls, data: Dict[str, Any], source: str) -> Dict[str, Any]:
- converted = deepcopy(data)
- fields = cls._build_runtime_fields()
-
- for name, runtime in fields.items():
- if name not in converted:
- continue
-
- value = converted[name]
- if runtime.schema_type in {"list", "table"} and isinstance(value, list):
- if source == "frontend":
- converted[name] = [
- runtime.item_codec.from_frontend(item) for item in value
- ]
- else:
- converted[name] = [
- runtime.item_codec.from_storage(item) for item in value
- ]
- continue
-
- if runtime.schema_type == "key_value" and isinstance(value, dict):
- if source == "frontend":
- converted[name] = {
- str(key): runtime.item_codec.from_frontend(item)
- for key, item in value.items()
- }
- else:
- converted[name] = {
- str(key): runtime.item_codec.from_storage(item)
- for key, item in value.items()
- }
- continue
-
- if source == "frontend":
- converted[name] = runtime.codec.from_frontend(value)
- else:
- converted[name] = runtime.codec.from_storage(value)
-
- return converted
-
- def _convert_output_values(self, target: str) -> Dict[str, Any]:
- dumped = self.model_dump(mode="python")
- fields = self._build_runtime_fields()
-
- for name, runtime in fields.items():
- if name not in dumped:
- continue
-
- value = dumped[name]
- if runtime.schema_type in {"list", "table"} and isinstance(value, list):
- if target == "frontend":
- dumped[name] = [
- runtime.item_codec.to_frontend(item) for item in value
- ]
- else:
- dumped[name] = [
- runtime.item_codec.to_storage(item) for item in value
- ]
- continue
-
- if runtime.schema_type == "key_value" and isinstance(value, dict):
- if target == "frontend":
- dumped[name] = {
- str(key): runtime.item_codec.to_frontend(item)
- for key, item in value.items()
- }
- else:
- dumped[name] = {
- str(key): runtime.item_codec.to_storage(item)
- for key, item in value.items()
- }
- continue
-
- if target == "frontend":
- dumped[name] = runtime.codec.to_frontend(value)
- else:
- dumped[name] = runtime.codec.to_storage(value)
-
- return dumped
-
- @classmethod
- def from_frontend(cls: type[ModelT], raw: Dict[str, Any]) -> ModelT:
- """从前端载荷构建配置对象。"""
- converted = cls._convert_input_values(raw, source="frontend")
- model = cls.model_validate(converted)
- model = model.post_load()
- cls.validate_config(model)
- return cast(ModelT, model)
-
- @classmethod
- def from_storage(cls: type[ModelT], raw: Dict[str, Any]) -> ModelT:
- """从存储载荷构建配置对象。"""
- converted = cls._convert_input_values(raw, source="storage")
- model = cls.model_validate(converted)
- model = model.post_load()
- cls.validate_config(model)
- return cast(ModelT, model)
-
- def to_frontend(self) -> Dict[str, Any]:
- """导出前端可消费的配置值。"""
- model = self.pre_save()
- type(self).validate_config(model)
- return model._convert_output_values(target="frontend")
-
- def to_storage(self) -> Dict[str, Any]:
- """导出存储值。"""
- model = self.pre_save()
- type(self).validate_config(model)
- return model._convert_output_values(target="storage")
-
-
-class SchemaModelAdapter:
- """将 PluginConfigModel 适配为现有 schema 管理器可识别对象。"""
-
- def __init__(self, model_cls: type[PluginConfigModel]) -> None:
- self._model_cls = model_cls
-
- def to_dict(self) -> Dict[str, Dict[str, Any]]:
- return self._model_cls.to_schema_dict()
-
-
-__all__ = [
- "Codec",
- "CodecRegistry",
- "GLOBAL_CODEC_REGISTRY",
- "IdentityCodec",
- "PathCodec",
- "TypePreset",
- "Desc",
- "preset",
- "describe",
- "PluginConfigModel",
- "SchemaModelAdapter",
-]
diff --git a/app/core/plugins/event_bus.py b/app/core/plugins/event_bus.py
index f2585631..bb464b75 100644
--- a/app/core/plugins/event_bus.py
+++ b/app/core/plugins/event_bus.py
@@ -44,7 +44,7 @@ def __init__(self) -> None:
def on(
self,
- event: str,
+ event: object,
handler: Callable[[Any], Any],
*,
priority: int = 0,
diff --git a/app/core/plugins/event_contract.py b/app/core/plugins/event_contract.py
index 8e5514ab..2a425c62 100644
--- a/app/core/plugins/event_contract.py
+++ b/app/core/plugins/event_contract.py
@@ -58,7 +58,7 @@ def is_script_event(event: str) -> bool:
return event in SCRIPT_LIFECYCLE_EVENTS
-def is_valid_source(source: str) -> bool:
+def is_valid_source(source: object) -> bool:
"""
校验事件来源字符串是否满足基础格式约束。
diff --git a/app/core/plugins/lifecycle.py b/app/core/plugins/lifecycle.py
index 9078c164..e32636ef 100644
--- a/app/core/plugins/lifecycle.py
+++ b/app/core/plugins/lifecycle.py
@@ -3,12 +3,16 @@
from __future__ import annotations
-from typing import Any
+from typing import Generic, TypeVar
+from app.core.config import PluginConfigBase
from .context import PluginContext
-class PluginLifecycle:
+TPluginConfig = TypeVar("TPluginConfig", bound=PluginConfigBase)
+
+
+class PluginLifecycle(Generic[TPluginConfig]):
"""插件生命周期协议基类。
新协议要求插件模块导出 `Plugin` 类,默认采用“最小必选 + 高级可选”:
@@ -20,7 +24,7 @@ class PluginLifecycle:
- 加载器会以反射方式校验方法是否存在且可调用。
"""
- def __init__(self, ctx: PluginContext) -> None:
+ def __init__(self, ctx: PluginContext[TPluginConfig]) -> None:
"""初始化插件实例。
Args:
@@ -31,7 +35,7 @@ def __init__(self, ctx: PluginContext) -> None:
"""
self.ctx = ctx
- async def on_load(self, ctx: PluginContext) -> None:
+ async def on_load(self, ctx: PluginContext[TPluginConfig]) -> None:
"""生命周期(可选):代码加载后、启动前。"""
_ = ctx
diff --git a/app/core/plugins/loader.py b/app/core/plugins/loader.py
index 42d1beb3..e7df40d2 100644
--- a/app/core/plugins/loader.py
+++ b/app/core/plugins/loader.py
@@ -9,13 +9,15 @@
from datetime import datetime
from pathlib import Path
from types import ModuleType
-from typing import Any, Callable, Dict, Iterable, Optional, Literal
+from typing import Any, Callable, Dict, Iterable, Optional, Literal, cast
+from app.core.config import PluginConfigBase
from app.utils import get_logger
from app.utils.constants import UTC8
from .context import PluginContext
from .decorators import EventSubscription, get_event_subscriptions
+from .event_bus import EventBus
from .lifecycle import REQUIRED_LIFECYCLE_METHODS
from .pypi_site import (
ensure_pypi_site_packages_on_syspath,
@@ -27,6 +29,10 @@
logger = get_logger("插件加载器")
+def _new_listener_ids() -> list[str]:
+ return []
+
+
def _utc8_now_iso() -> str:
"""返回当前 UTC+8 时间的 ISO8601 字符串。"""
return datetime.now(tz=UTC8).isoformat()
@@ -44,7 +50,7 @@ class PluginRecord:
display_name: str = ""
status: str = "discovered"
module: Optional[ModuleType] = None
- context: Optional[PluginContext] = None
+ context: Optional[PluginContext[PluginConfigBase]] = None
error: Optional[str] = None
generation: int = 1
lifecycle_phase: str = "idle"
@@ -60,7 +66,7 @@ class PluginRecord:
unloaded_at: Optional[str] = None
last_error: Optional[str] = None
last_error_at: Optional[str] = None
- listener_ids: list[str] = field(default_factory=list)
+ listener_ids: list[str] = field(default_factory=_new_listener_ids)
plugin_instance: Any = None
@@ -78,11 +84,18 @@ class PluginSource:
distribution: Optional[str] = None
version: Optional[str] = None
- def __init__(self, events, runtime: Any = None, plugins_dir: Optional[Path] = None):
+ def __init__(
+ self,
+ events: EventBus,
+ runtime: dict[str, Callable[..., Any]] | None = None,
+ plugins_dir: Optional[Path] = None,
+ ):
self.events = events
- self.runtime = {} if runtime is None else runtime
+ self.runtime = {} if runtime is None else dict(runtime)
self.plugins_dir = plugins_dir or (Path.cwd() / "plugins")
- self.pypi_site_packages_dir = ensure_pypi_site_packages_on_syspath(self.plugins_dir)
+ self.pypi_site_packages_dir = ensure_pypi_site_packages_on_syspath(
+ self.plugins_dir
+ )
self.records: Dict[str, PluginRecord] = {}
self.discovered_plugins: Dict[str, PluginLoader.PluginSource] = {}
self.startup_failed_instances: Dict[str, str] = {}
@@ -133,7 +146,9 @@ def discover(self) -> Dict[str, PluginSource]:
logger.info(f"插件扫描完成,共发现 {len(discovered)} 个插件")
return discovered
- def _load_plugin_config(self, plugin_name: str, plugin_source: PluginSource) -> Dict[str, Any]:
+ def _load_plugin_config(
+ self, plugin_name: str, plugin_source: PluginSource
+ ) -> Dict[str, Any]:
if plugin_source.path is None:
return {}
@@ -146,14 +161,16 @@ def _load_plugin_config(self, plugin_name: str, plugin_source: PluginSource) ->
with config_path.open("r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
- return data
+ return cast(Dict[str, Any], data)
logger.warning(f"插件配置格式错误(非对象),已忽略: {plugin_name}")
return {}
except Exception as e:
logger.error(f"读取插件配置失败: {plugin_name}, error={e}")
return {}
- def _load_plugin_class_from_entry_point(self, plugin_name: str, entry_point: Any) -> tuple[Optional[ModuleType], type[Any]]:
+ def _load_plugin_class_from_entry_point(
+ self, plugin_name: str, entry_point: Any
+ ) -> tuple[Optional[ModuleType], type[Any]]:
"""从 PyPI Entry Point 加载插件类入口。
Args:
@@ -177,9 +194,13 @@ def _load_plugin_class_from_entry_point(self, plugin_name: str, entry_point: Any
module = inspect.getmodule(loaded)
return module, loaded
- raise PluginDefinitionError(f"插件 Entry Point 返回了不支持的对象(需要模块或类): {plugin_name}")
+ raise PluginDefinitionError(
+ f"插件 Entry Point 返回了不支持的对象(需要模块或类): {plugin_name}"
+ )
- def _clear_cached_pypi_module(self, plugin_name: str, plugin_source: PluginSource) -> None:
+ def _clear_cached_pypi_module(
+ self, plugin_name: str, plugin_source: PluginSource
+ ) -> None:
"""清理 PyPI 插件模块缓存,确保重载使用最新代码。"""
if plugin_source.source != "pypi":
return
@@ -224,7 +245,9 @@ def _resolve_plugin_module_and_class(
if plugin_source.entry_point is None:
raise PluginDefinitionError(f"PyPI 插件缺少 Entry Point: {plugin_name}")
self._clear_cached_pypi_module(plugin_name, plugin_source)
- return self._load_plugin_class_from_entry_point(plugin_name, plugin_source.entry_point)
+ return self._load_plugin_class_from_entry_point(
+ plugin_name, plugin_source.entry_point
+ )
@staticmethod
def _ensure_required_lifecycle_methods(plugin_name: str, instance: Any) -> None:
@@ -259,7 +282,9 @@ def _ensure_required_lifecycle_methods(plugin_name: str, instance: Any) -> None:
f"插件生命周期方法不可调用: plugin={plugin_name}, invalid={','.join(invalid)}"
)
- async def _call_lifecycle_method(self, instance: Any, method_name: str, *args: Any) -> None:
+ async def _call_lifecycle_method(
+ self, instance: Any, method_name: str, *args: Any
+ ) -> None:
"""调用插件生命周期方法,并兼容同步/异步实现。
Args:
@@ -285,7 +310,9 @@ async def _call_lifecycle_method(self, instance: Any, method_name: str, *args: A
if inspect.isawaitable(result):
await result
- async def _call_optional_lifecycle_method(self, instance: Any, method_name: str, *args: Any) -> bool:
+ async def _call_optional_lifecycle_method(
+ self, instance: Any, method_name: str, *args: Any
+ ) -> bool:
"""尝试调用可选生命周期方法。
Args:
@@ -311,7 +338,62 @@ async def _call_optional_lifecycle_method(self, instance: Any, method_name: str,
await result
return True
- def _create_plugin_instance(self, plugin_name: str, plugin_class: type[Any], context: PluginContext) -> Any:
+ def _resolve_plugin_config_model(
+ self,
+ plugin_name: str,
+ module: ModuleType | None,
+ plugin_class: type[Any],
+ ) -> type[PluginConfigBase]:
+ """解析并校验插件配置模型类型。"""
+ candidates: list[Any] = []
+ if module is not None:
+ candidates.append(getattr(module, "Config", None))
+ candidates.append(getattr(plugin_class, "Config", None))
+
+ checked_ids: set[int] = set()
+ for candidate in candidates:
+ if candidate is None:
+ continue
+
+ identity = id(candidate)
+ if identity in checked_ids:
+ continue
+ checked_ids.add(identity)
+
+ if not inspect.isclass(candidate):
+ raise PluginDefinitionError(
+ f"插件 Config 声明必须是类: plugin={plugin_name}, actual={type(candidate).__name__}"
+ )
+
+ if issubclass(candidate, PluginConfigBase):
+ return candidate
+
+ raise PluginDefinitionError(
+ f"插件 Config 必须继承 PluginConfigBase: plugin={plugin_name}, actual={candidate.__name__}"
+ )
+
+ raise PluginDefinitionError(f"插件缺少 Config 声明: {plugin_name}")
+
+ def _validate_plugin_config(
+ self,
+ plugin_name: str,
+ config_model: type[PluginConfigBase],
+ raw_config: dict[str, Any],
+ ) -> PluginConfigBase:
+ """使用插件配置模型校验实例配置。"""
+ try:
+ return config_model.model_validate(raw_config)
+ except Exception as e:
+ raise PluginDefinitionError(
+ f"插件配置校验失败: plugin={plugin_name}, model={config_model.__name__}, error={type(e).__name__}: {e}"
+ ) from e
+
+ def _create_plugin_instance(
+ self,
+ plugin_name: str,
+ plugin_class: type[Any],
+ context: PluginContext[PluginConfigBase],
+ ) -> Any:
"""构建插件实例并校验生命周期契约。
Args:
@@ -367,9 +449,6 @@ def _mark_lifecycle_phase(self, record: PluginRecord, phase: str) -> None:
TypeError: 当 phase 不是字符串时抛出。
ValueError: 当 phase 为空字符串时抛出。
"""
- if not isinstance(phase, str):
- raise TypeError("phase 必须是字符串")
-
normalized = phase.strip()
if not normalized:
raise ValueError("phase 不能为空字符串")
@@ -385,7 +464,11 @@ def _mark_error(self, record: PluginRecord, message: str) -> None:
record.status = "error"
@staticmethod
- def _invoke_with_context(handler: Callable[..., Any], payload: Any, context: PluginContext) -> Any:
+ def _invoke_with_context(
+ handler: Callable[..., Any],
+ payload: Any,
+ context: PluginContext[PluginConfigBase],
+ ) -> Any:
"""
按监听器签名将事件 payload 与上下文注入到处理函数。
@@ -406,12 +489,18 @@ def _invoke_with_context(handler: Callable[..., Any], payload: Any, context: Plu
if not params:
raise TypeError("事件监听器至少需要一个参数用于接收 payload")
- has_var_keyword = any(item.kind == inspect.Parameter.VAR_KEYWORD for item in params)
+ has_var_keyword = any(
+ item.kind == inspect.Parameter.VAR_KEYWORD for item in params
+ )
has_ctx_keyword = has_var_keyword or "ctx" in signature.parameters
positional_params = [
item
for item in params
- if item.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
+ if item.kind
+ in (
+ inspect.Parameter.POSITIONAL_ONLY,
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
+ )
]
if len(positional_params) >= 2:
@@ -429,7 +518,7 @@ def _build_context_bound_handler(
self,
*,
handler: Callable[..., Any],
- context: PluginContext,
+ context: PluginContext[PluginConfigBase],
) -> Callable[[Any], Any]:
"""
构建自动注入 `ctx` 的监听器包装函数。
@@ -442,6 +531,7 @@ def _build_context_bound_handler(
Callable[[Any], Any]: 仅接收 payload 的包装监听器。
"""
if inspect.iscoroutinefunction(handler):
+
@wraps(handler)
async def async_wrapper(payload: Any) -> Any:
result = self._invoke_with_context(handler, payload, context)
@@ -458,7 +548,9 @@ def sync_wrapper(payload: Any) -> Any:
return sync_wrapper
@staticmethod
- def _iter_decorated_members(target: Any) -> list[tuple[Callable[..., Any], EventSubscription]]:
+ def _iter_decorated_members(
+ target: Any,
+ ) -> list[tuple[Callable[..., Any], EventSubscription]]:
"""遍历对象成员并提取 `@on_event` 声明。"""
result: list[tuple[Callable[..., Any], EventSubscription]] = []
for _, member in inspect.getmembers(target):
@@ -560,11 +652,20 @@ async def load_plugin(self, plugin_name: str) -> PluginRecord:
plugin_name,
plugin_source,
)
+ config_model = self._resolve_plugin_config_model(
+ plugin_name,
+ record.module,
+ plugin_class,
+ )
self._mark_status(record, "loaded")
self._mark_lifecycle_phase(record, "loaded")
plugin_logger = get_logger(f"插件:{plugin_name}")
- plugin_config = self._load_plugin_config(plugin_name, plugin_source)
+ plugin_config = self._validate_plugin_config(
+ plugin_name,
+ config_model,
+ self._load_plugin_config(plugin_name, plugin_source),
+ )
record.context = PluginContext(
plugin_name=plugin_name,
@@ -577,7 +678,9 @@ async def load_plugin(self, plugin_name: str) -> PluginRecord:
if record.module is not None:
record.listener_ids.extend(
- self._register_decorated_handlers(record=record, target=record.module)
+ self._register_decorated_handlers(
+ record=record, target=record.module
+ )
)
record.plugin_instance = self._create_plugin_instance(
@@ -586,10 +689,14 @@ async def load_plugin(self, plugin_name: str) -> PluginRecord:
context=record.context,
)
record.listener_ids.extend(
- self._register_decorated_handlers(record=record, target=record.plugin_instance)
+ self._register_decorated_handlers(
+ record=record, target=record.plugin_instance
+ )
)
self._mark_lifecycle_phase(record, "on_load")
- await self._call_optional_lifecycle_method(record.plugin_instance, "on_load", record.context)
+ await self._call_optional_lifecycle_method(
+ record.plugin_instance, "on_load", record.context
+ )
self._mark_lifecycle_phase(record, "on_start")
await self._call_lifecycle_method(record.plugin_instance, "on_start")
@@ -660,14 +767,22 @@ async def load_instance(
plugin_name,
plugin_source,
)
+ config_model = self._resolve_plugin_config_model(
+ plugin_name,
+ record.module,
+ plugin_class,
+ )
self._mark_status(record, "loaded")
self._mark_lifecycle_phase(record, "loaded")
plugin_logger = get_logger(f"插件:{instance_id}")
+ typed_config = self._validate_plugin_config(
+ plugin_name, config_model, config
+ )
record.context = PluginContext(
plugin_name=plugin_name,
instance_id=instance_id,
- config=config,
+ config=typed_config,
logger=plugin_logger,
events=self.events,
runtime_capabilities=self.runtime,
@@ -675,7 +790,9 @@ async def load_instance(
if record.module is not None:
record.listener_ids.extend(
- self._register_decorated_handlers(record=record, target=record.module)
+ self._register_decorated_handlers(
+ record=record, target=record.module
+ )
)
record.plugin_instance = self._create_plugin_instance(
@@ -684,10 +801,14 @@ async def load_instance(
context=record.context,
)
record.listener_ids.extend(
- self._register_decorated_handlers(record=record, target=record.plugin_instance)
+ self._register_decorated_handlers(
+ record=record, target=record.plugin_instance
+ )
)
self._mark_lifecycle_phase(record, "on_load")
- await self._call_optional_lifecycle_method(record.plugin_instance, "on_load", record.context)
+ await self._call_optional_lifecycle_method(
+ record.plugin_instance, "on_load", record.context
+ )
self._mark_lifecycle_phase(record, "on_start")
await self._call_lifecycle_method(record.plugin_instance, "on_start")
@@ -701,7 +822,9 @@ async def load_instance(
except Exception as e:
self._unregister_record_listeners(record)
self._mark_error(record, f"{type(e).__name__}: {e}")
- logger.error(f"插件实例加载失败: {instance_id}, error={type(e).__name__}: {e}")
+ logger.error(
+ f"插件实例加载失败: {instance_id}, error={type(e).__name__}: {e}"
+ )
return record
@@ -775,9 +898,13 @@ async def unload_plugin(self, plugin_name: str) -> None:
try:
if record.plugin_instance is not None:
self._mark_lifecycle_phase(record, "on_stop")
- await self._call_lifecycle_method(record.plugin_instance, "on_stop", "stop")
+ await self._call_lifecycle_method(
+ record.plugin_instance, "on_stop", "stop"
+ )
self._mark_lifecycle_phase(record, "on_unload")
- await self._call_optional_lifecycle_method(record.plugin_instance, "on_unload")
+ await self._call_optional_lifecycle_method(
+ record.plugin_instance, "on_unload"
+ )
self._mark_status(record, "disposed")
self._mark_lifecycle_phase(record, "disposed")
except Exception as e:
@@ -839,9 +966,13 @@ async def reload_instance(
old_record.last_reload_reason = reason
old_record.last_reload_at = _utc8_now_iso()
self._mark_lifecycle_phase(old_record, "on_reload_prepare")
- await self._call_optional_lifecycle_method(old_record.plugin_instance, "on_reload_prepare")
+ await self._call_optional_lifecycle_method(
+ old_record.plugin_instance, "on_reload_prepare"
+ )
self._mark_lifecycle_phase(old_record, "on_stop")
- await self._call_lifecycle_method(old_record.plugin_instance, "on_stop", f"reload:{reason}")
+ await self._call_lifecycle_method(
+ old_record.plugin_instance, "on_stop", f"reload:{reason}"
+ )
await self.unload_instance(instance_id)
new_record = await self.load_instance(
@@ -863,13 +994,17 @@ async def reload_instance(
if old_record is not None:
previous_generation = int(getattr(old_record, "generation", 1) or 1)
new_record.generation = previous_generation + 1
- new_record.reload_count = (old_record.reload_count + 1) if old_record is not None else 1
+ new_record.reload_count = (
+ (old_record.reload_count + 1) if old_record is not None else 1
+ )
new_record.last_reload_reason = reason
new_record.last_reload_at = _utc8_now_iso()
if new_record.plugin_instance is not None:
self._mark_lifecycle_phase(new_record, "on_reload_commit")
- await self._call_optional_lifecycle_method(new_record.plugin_instance, "on_reload_commit")
+ await self._call_optional_lifecycle_method(
+ new_record.plugin_instance, "on_reload_commit"
+ )
self._mark_lifecycle_phase(new_record, "active")
return new_record
diff --git a/app/core/plugins/manager.py b/app/core/plugins/manager.py
index 1279b7d9..f3fc0500 100644
--- a/app/core/plugins/manager.py
+++ b/app/core/plugins/manager.py
@@ -8,7 +8,7 @@
import shutil
import importlib.metadata as importlib_metadata
from dataclasses import dataclass
-from typing import Any, Dict
+from typing import Any, Callable, Dict, cast
import uuid
from app.utils import get_logger
@@ -96,18 +96,24 @@ def _parse_local_plugin_project(self, pyproject_path: Path) -> _LocalPluginProje
OSError: 文件读取失败时抛出。
"""
with pyproject_path.open("rb") as f:
- data = tomllib.load(f)
-
- if not isinstance(data, dict):
- raise ValueError(f"pyproject 顶层必须是对象: {pyproject_path}")
-
- project_table = data.get("project", {})
- if not isinstance(project_table, dict):
+ data: dict[str, Any] = tomllib.load(f)
+
+ project_value = data.get("project")
+ project_table: dict[str, Any]
+ if project_value is None:
+ project_table = {}
+ elif isinstance(project_value, dict):
+ project_table = cast(dict[str, Any], project_value)
+ else:
raise ValueError(f"pyproject project 字段必须是对象: {pyproject_path}")
distribution_name = str(project_table.get("name") or pyproject_path.parent.name).strip()
- entry_points_table = project_table.get("entry-points", {})
- if not isinstance(entry_points_table, dict):
+ entry_points_value = project_table.get("entry-points")
+ if entry_points_value is None:
+ entry_points_table: dict[str, Any] = {}
+ elif isinstance(entry_points_value, dict):
+ entry_points_table = cast(dict[str, Any], entry_points_value)
+ else:
raise ValueError(f"pyproject project.entry-points 必须是对象: {pyproject_path}")
entry_point_names: set[str] = set()
@@ -115,7 +121,8 @@ def _parse_local_plugin_project(self, pyproject_path: Path) -> _LocalPluginProje
group_table = entry_points_table.get(group)
if not isinstance(group_table, dict):
continue
- for ep_name in group_table.keys():
+ group_table_dict = cast(dict[str, Any], group_table)
+ for ep_name in group_table_dict.keys():
name = str(ep_name or "").strip()
if name:
entry_point_names.add(name)
@@ -365,7 +372,7 @@ def _cleanup_package_from_target(self, package_name: str, target_dir: Path) -> b
for dist, modules in matched:
dist_files = list(getattr(dist, "files", []) or [])
for item in dist_files:
- candidate = Path(dist.locate_file(item))
+ candidate = Path(str(dist.locate_file(item)))
if candidate.is_file():
candidate.unlink(missing_ok=True)
elif candidate.is_dir():
@@ -534,7 +541,7 @@ async def _update_all_pypi_plugins(self, discovered: Dict[str, Any]) -> None:
def _list_scripts(self) -> list[Dict[str, Any]]:
try:
from app.core import Config
- scripts = []
+ scripts: list[dict[str, Any]] = []
for script_id, script in Config.ScriptConfig.items():
scripts.append(
{
@@ -553,9 +560,9 @@ def _get_script_log(self, script_id: str, limit: int = 200) -> str:
from app.core import Config
uid = uuid.UUID(script_id)
- script = Config.ScriptConfig.get(uid)
- if script is None:
+ if uid not in Config.ScriptConfig:
return ""
+ script = Config.ScriptConfig[uid]
log_value = getattr(script, "log", None)
if isinstance(log_value, str):
@@ -579,74 +586,32 @@ async def start(self) -> None:
logger.warning("插件系统已启动,忽略重复启动")
return
- discovered = await self.discover_plugins()
- instances = await self.config_store.load_instances(
- self.plugins_dir,
- discovered,
- auto_create_missing=False,
- )
+ await self.discover_plugins()
+ instances = await self.config_store.load_instances()
await self.loader.load_instances(instances)
- await self._repair_invalid_instances_after_start(discovered)
+ await self._repair_invalid_instances_after_start()
self.started = True
logger.info("插件系统启动完成")
- async def _repair_invalid_instances_after_start(self, discovered: Dict[str, Any]) -> None:
+ async def _repair_invalid_instances_after_start(self) -> None:
"""启动后修复失效插件实例配置。"""
failed = dict(getattr(self.loader, "startup_failed_instances", {}) or {})
if not failed:
return
- missing_ids = set(getattr(self.loader, "startup_missing_instances", set()) or set())
+ raw_missing_ids = getattr(self.loader, "startup_missing_instances", None)
+ missing_ids = {str(item) for item in raw_missing_ids or ()}
try:
- root = await self.config_store.get_root(
- self.plugins_dir,
- discovered,
- auto_create_missing=False,
+ removed_ids, disabled_ids = await self.config_store.repair_invalid_instances(
+ missing_instance_ids=missing_ids,
+ failed_instance_ids=set(failed.keys()),
)
except Exception as e:
- logger.error(f"读取插件配置失败,跳过失效实例修复: {type(e).__name__}: {e}")
+ logger.error(f"修复插件配置失败,已跳过失效实例处理: {type(e).__name__}: {e}")
return
- instances = root.get("instances", [])
- if not isinstance(instances, list):
- return
-
- changed = False
- removed_ids: list[str] = []
- disabled_ids: list[str] = []
- new_instances = []
-
- for item in instances:
- if not isinstance(item, dict):
- new_instances.append(item)
- continue
-
- instance_id = str(item.get("id") or "")
- if not instance_id:
- new_instances.append(item)
- continue
-
- if instance_id in missing_ids:
- removed_ids.append(instance_id)
- changed = True
- continue
-
- if instance_id in failed and bool(item.get("enabled", False)):
- item["enabled"] = False
- disabled_ids.append(instance_id)
- changed = True
-
- new_instances.append(item)
-
- if not changed:
- return
-
- root["instances"] = new_instances
- try:
- await self.config_store.save_root(self.plugins_dir, root)
- except Exception as e:
- logger.error(f"保存插件配置失败,失效实例修复未落盘: {type(e).__name__}: {e}")
+ if not removed_ids and not disabled_ids:
return
if removed_ids:
@@ -669,7 +634,12 @@ async def stop(self) -> None:
self.started = False
logger.info("插件系统已关闭")
- def on(self, event: str, handler, **kwargs: Any) -> str:
+ def on(
+ self,
+ event: str,
+ handler: Callable[[Any], Any],
+ **kwargs: Any,
+ ) -> str:
"""
注册插件系统事件监听器。
@@ -683,7 +653,13 @@ def on(self, event: str, handler, **kwargs: Any) -> str:
"""
return self.events.on(event, handler, **kwargs)
- def off(self, event: str, handler=None, *, listener_id: str | None = None) -> None:
+ def off(
+ self,
+ event: str,
+ handler: Callable[[Any], Any] | None = None,
+ *,
+ listener_id: str | None = None,
+ ) -> None:
"""
移除插件系统事件监听器。
@@ -781,11 +757,7 @@ async def reload_instance(self, instance_id: str) -> None:
RuntimeError: 目标实例对应 PyPI 插件更新失败时抛出。
"""
discovered = await self.discover_plugins()
- instances = await self.config_store.load_instances(
- self.plugins_dir,
- discovered,
- auto_create_missing=False,
- )
+ instances = await self.config_store.load_instances()
target = next((item for item in instances if item.id == instance_id), None)
if target is None:
raise ValueError(f"未找到插件实例: {instance_id}")
@@ -822,11 +794,7 @@ async def reload_plugin(self, plugin_name: str) -> None:
"""
discovered = await self.discover_plugins()
await self._update_pypi_plugin(plugin_name, discovered)
- instances = await self.config_store.load_instances(
- self.plugins_dir,
- discovered,
- auto_create_missing=False,
- )
+ instances = await self.config_store.load_instances()
matched = [item for item in instances if item.plugin == plugin_name]
if not matched:
raise ValueError(f"未找到插件实例: {plugin_name}")
diff --git a/app/core/plugins/plgType.py b/app/core/plugins/plgType.py
deleted file mode 100644
index f63d70bc..00000000
--- a/app/core/plugins/plgType.py
+++ /dev/null
@@ -1,163 +0,0 @@
-# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
-# Copyright © 2025-2026 AUTO-MAS Team
-
-"""DSL v2 统一类型导出模块(与 dsl.py / dsl_v2.py 同级)。"""
-
-from copy import deepcopy
-from typing import Annotated, Any, Generic, TypeVar
-
-from . import dsl_v2 as _dsl
-
-# 统一导出:插件侧推荐只 import 这个模块。
-ConfigModel = _dsl.PluginConfigModel
-SchemaModelAdapter = _dsl.SchemaModelAdapter
-
-# 描述元数据与辅助函数
-Desc = _dsl.Desc
-describe = _dsl.describe
-
-# 预定义类型(从 dsl_v2 迁移到 plgType)
-Switch = Annotated[
- bool,
- _dsl.TypePreset(schema_type="boolean", ui={"widget": "switch"}),
-]
-String = Annotated[
- str,
- _dsl.TypePreset(schema_type="string", ui={"widget": "input"}),
-]
-Password = Annotated[
- str,
- _dsl.TypePreset(schema_type="string", format="password", ui={"widget": "password"}),
-]
-Number = Annotated[
- float,
- _dsl.TypePreset(schema_type="number", ui={"widget": "number"}),
-]
-PathText = Annotated[
- str,
- _dsl.TypePreset(
- schema_type="string",
- format="path",
- codec="path",
- ui={"widget": "path"},
- ),
-]
-StringList = Annotated[
- list[str],
- _dsl.TypePreset(schema_type="list", item_type="string", ui={"widget": "list"}),
-]
-KeyValueStr = Annotated[
- dict[str, str],
- _dsl.TypePreset(
- schema_type="key_value",
- item_type="string",
- ui={"widget": "key_value"},
- ),
-]
-
-
-U = TypeVar("U")
-
-
-class TableOf(Generic[U]):
- """表格类型包装:TableOf[RowModel]。"""
-
- def __class_getitem__(cls, item: Any) -> Any:
- return _dsl.preset(
- list[item],
- schema_type="table",
- item_type="object",
- widget="table",
- )
-
-
-class D:
- """描述语法糖。
-
- - 稳定写法:Annotated[T, D("描述")]
- - 实验写法:D[T, "描述"](运行时可用,部分静态类型器会报无效类型表达式)
- """
-
- def __class_getitem__(cls, item: Any) -> Any:
- if not isinstance(item, tuple) or len(item) != 2:
- raise TypeError("D[...] 需要两个参数:D[Type, '描述文本']")
- py_type, text = item
- if not isinstance(text, str):
- raise TypeError("D[Type, text] 中 text 必须是字符串")
- return Annotated[py_type, Desc(text)]
-
-
-class A:
- """注解语法糖:A[Type, "描述文本"]。
-
- 说明:
- - 首个参数可为 plgType 预定义类型,也可为 Python 内置类型(如 str/int/list[str])
- - 返回 Annotated[Type, Desc("...")],供 dsl_v2 自动提取 description
- """
-
- def __class_getitem__(cls, item: Any) -> Any:
- if not isinstance(item, tuple) or len(item) != 2:
- raise TypeError("A[...] 需要两个参数:A[Type, '描述文本']")
- py_type, text = item
- if not isinstance(text, str):
- raise TypeError("A[Type, text] 中 text 必须是字符串")
- return Annotated[py_type, Desc(text)]
-
-
-def extra(field: str | None = None, /, **ui: Any):
- """类装饰器:为字段追加前端扩展元数据。
-
- 用法 1(推荐):
- @extra("enable", group="basic", order=1)
-
- 用法 2(批量):
- @extra(enable={"group": "basic"}, title={"placeholder": "请输入"})
- """
-
- if field is None:
- mapping: dict[str, dict[str, Any]] = {}
- for name, payload in ui.items():
- if isinstance(payload, dict):
- mapping[name] = deepcopy(payload)
- else:
- raise TypeError("@extra(enable=...) 的值必须是字典")
- else:
- if not isinstance(field, str) or not field.strip():
- raise TypeError("@extra(field, ...) 的 field 必须是非空字符串")
- mapping = {field: deepcopy(ui)}
-
- def _decorator(config_cls):
- attr_name = _dsl.FIELD_UI_EXTRA_ATTR
- existing = getattr(config_cls, attr_name, {})
- if not isinstance(existing, dict):
- existing = {}
- merged = deepcopy(existing)
- for field_name, payload in mapping.items():
- current = merged.get(field_name, {})
- if not isinstance(current, dict):
- current = {}
- current.update(deepcopy(payload))
- merged[field_name] = current
- setattr(config_cls, attr_name, merged)
- return config_cls
-
- return _decorator
-
-
-__all__ = [
- "ConfigModel",
- "SchemaModelAdapter",
- "Desc",
- "describe",
- "D",
- "A",
- "extra",
- "Switch",
- "String",
- "Password",
- "Number",
- "PathText",
- "StringList",
- "KeyValueStr",
- "TableOf",
-]
diff --git a/app/core/plugins/pypi_site.py b/app/core/plugins/pypi_site.py
index 915e86e3..227c3db2 100644
--- a/app/core/plugins/pypi_site.py
+++ b/app/core/plugins/pypi_site.py
@@ -7,7 +7,7 @@
import importlib.metadata as importlib_metadata
from dataclasses import dataclass
from pathlib import Path
-from typing import Dict, List, Optional
+from typing import Any, Dict, List, Optional, cast
from urllib.parse import urlparse, unquote
from urllib.request import url2pathname
@@ -119,7 +119,7 @@ def _resolve_dist_editable_project_path(dist: importlib_metadata.Distribution) -
Optional[Path]: 可解析时返回工程根目录路径;否则返回 None。
"""
try:
- direct_url_path = dist.locate_file("direct_url.json")
+ direct_url_path = Path(str(dist.locate_file("direct_url.json")))
if not direct_url_path.exists():
return _resolve_dist_editable_project_path_from_pth(dist)
data = json.loads(direct_url_path.read_text(encoding="utf-8"))
@@ -129,7 +129,8 @@ def _resolve_dist_editable_project_path(dist: importlib_metadata.Distribution) -
if not isinstance(data, dict):
return None
- url = str(data.get("url") or "").strip()
+ direct_url_data = cast(dict[str, Any], data)
+ url = str(direct_url_data.get("url") or "").strip()
parsed = urlparse(url)
if parsed.scheme != "file":
return None
@@ -166,7 +167,7 @@ def _resolve_dist_editable_project_path_from_pth(
return None
normalized_name = dist_name.replace("-", "_")
- pth_file = dist.locate_file(f"__editable__.{normalized_name}-{dist_version}.pth")
+ pth_file = Path(str(dist.locate_file(f"__editable__.{normalized_name}-{dist_version}.pth")))
if not pth_file.exists():
return None
@@ -192,7 +193,7 @@ def _is_broken_editable_distribution(dist: importlib_metadata.Distribution) -> b
return False
normalized_name = dist_name.replace("-", "_")
- pth_file = dist.locate_file(f"__editable__.{normalized_name}-{dist_version}.pth")
+ pth_file = Path(str(dist.locate_file(f"__editable__.{normalized_name}-{dist_version}.pth")))
if not pth_file.exists():
return False
diff --git a/app/core/plugins/runtime_api.py b/app/core/plugins/runtime_api.py
index 8bb493d4..68bad793 100644
--- a/app/core/plugins/runtime_api.py
+++ b/app/core/plugins/runtime_api.py
@@ -7,7 +7,10 @@
import sys
from contextlib import suppress
from pathlib import Path
-from typing import Any, Callable, Dict, Optional
+from typing import Any, Callable, cast
+
+from app.core.config import PluginConfigBase
+from app.utils.logger import LoggerLike
class RuntimeAPI:
@@ -18,42 +21,59 @@ def __init__(
*,
plugin_name: str,
instance_id: str,
- config: Dict[str, Any],
- logger,
- runtime_capabilities: Optional[Dict[str, Callable[..., Any]]] = None,
+ config: PluginConfigBase,
+ logger: LoggerLike,
+ runtime_capabilities: dict[str, Callable[..., Any]] | None = None,
) -> None:
self.plugin_name = plugin_name
self.instance_id = instance_id
- self.config = config or {}
+ self.config = config
self.logger = logger
self.runtime_capabilities = runtime_capabilities or {}
- self._cached_runtime_info: Optional[Dict[str, Any]] = None
+ self._cached_runtime_info: dict[str, Any] | None = None
+
+ def _root_get(self, key: str, default: Any = None) -> Any:
+ if hasattr(self.config, key):
+ return getattr(self.config, key)
+
+ extra = getattr(self.config, "__pydantic_extra__", None)
+ if isinstance(extra, dict):
+ extra_dict = cast(dict[str, Any], extra)
+ return extra_dict.get(key, default)
+
+ return default
+
+ def _root_set(self, key: str, value: Any) -> None:
+ setattr(self.config, key, value)
- def _runtime_options(self) -> Dict[str, Any]:
- runtime = self.config.get("runtime")
+ def _runtime_options(self) -> dict[str, Any]:
+ runtime = self._root_get("runtime")
if isinstance(runtime, dict):
- return runtime
+ return cast(dict[str, Any], runtime)
return {}
- def set_runtime_options(self, options: Dict[str, Any]) -> Dict[str, Any]:
+ def set_runtime_options(self, options: dict[str, Any]) -> dict[str, Any]:
"""更新 runtime 配置并返回最新配置。"""
- if not isinstance(options, dict):
- raise ValueError("runtime options 必须是字典")
-
- runtime = self.config.get("runtime")
+ runtime = self._root_get("runtime")
if not isinstance(runtime, dict):
runtime = {}
- self.config["runtime"] = runtime
+ self._root_set("runtime", runtime)
+ runtime_dict = cast(dict[str, Any], runtime)
for key, value in options.items():
- runtime[key] = value
+ runtime_dict[key] = value
# runtime 配置变更后清理缓存,确保 info 结果反映最新参数。
self._cached_runtime_info = None
self._audit("set_runtime_options", "ok", {"keys": list(options.keys())})
- return dict(runtime)
+ return dict(runtime_dict)
- def _audit(self, action: str, status: str, detail: Optional[Dict[str, Any]] = None) -> None:
+ def _audit(
+ self,
+ action: str,
+ status: str,
+ detail: dict[str, Any] | None = None,
+ ) -> None:
payload = {
"plugin": self.plugin_name,
"instance": self.instance_id,
@@ -62,15 +82,17 @@ def _audit(self, action: str, status: str, detail: Optional[Dict[str, Any]] = No
}
if detail:
payload.update(detail)
- self.logger.debug(f"[runtime] {json.dumps(payload, ensure_ascii=False, indent=2)}")
+ self.logger.debug(
+ f"[runtime] {json.dumps(payload, ensure_ascii=False, indent=2)}"
+ )
- def _resolve_interpreter(self, override: Optional[str] = None) -> str:
+ def _resolve_interpreter(self, override: str | None = None) -> str:
runtime_options = self._runtime_options()
candidates = [
override,
runtime_options.get("python_executable"),
- self.config.get("python_executable"),
+ self._root_get("python_executable"),
sys.executable,
]
@@ -80,14 +102,14 @@ def _resolve_interpreter(self, override: Optional[str] = None) -> str:
return sys.executable
- def _resolve_timeout(self, override: Optional[int] = None, default: int = 15) -> int:
+ def _resolve_timeout(self, override: int | None = None, default: int = 15) -> int:
runtime_options = self._runtime_options()
value = override
if value is None:
value = runtime_options.get("python_timeout_seconds")
if value is None:
- value = self.config.get("python_timeout_seconds")
+ value = self._root_get("python_timeout_seconds")
if value is None:
value = default
@@ -98,7 +120,7 @@ def _resolve_timeout(self, override: Optional[int] = None, default: int = 15) ->
return max(1, min(timeout, 300))
- def get_runtime_info(self, force_refresh: bool = False) -> Dict[str, Any]:
+ def get_runtime_info(self, force_refresh: bool = False) -> dict[str, Any]:
"""
获取当前插件实例的运行时环境信息。
@@ -124,7 +146,7 @@ def get_runtime_info(self, force_refresh: bool = False) -> Dict[str, Any]:
self._cached_runtime_info = info
return info
- def check_interpreter(self, python_executable: Optional[str] = None) -> Dict[str, Any]:
+ def check_interpreter(self, python_executable: str | None = None) -> dict[str, Any]:
"""
校验目标 Python 解释器是否可用。
@@ -175,7 +197,9 @@ def check_interpreter(self, python_executable: Optional[str] = None) -> Dict[str
result = {
"ok": False,
"python": target,
- "reason": (completed.stderr or completed.stdout or "解释器不可用").strip(),
+ "reason": (
+ completed.stderr or completed.stdout or "解释器不可用"
+ ).strip(),
}
self._audit("check_interpreter", "error", result)
return result
@@ -227,12 +251,16 @@ def get_script_log(self, script_id: str, limit: int = 200) -> Any:
"""
func = self.runtime_capabilities.get("get_script_log")
if not callable(func):
- self._audit("get_script_log", "ok", {"source": "empty", "script_id": script_id})
+ self._audit(
+ "get_script_log", "ok", {"source": "empty", "script_id": script_id}
+ )
return ""
try:
data = func(script_id, limit)
- self._audit("get_script_log", "ok", {"script_id": script_id, "limit": limit})
+ self._audit(
+ "get_script_log", "ok", {"script_id": script_id, "limit": limit}
+ )
return data
except Exception as e:
self._audit(
@@ -246,9 +274,9 @@ async def run_python_snippet(
self,
code: str,
*,
- python_executable: Optional[str] = None,
- timeout_seconds: Optional[int] = None,
- ) -> Dict[str, Any]:
+ python_executable: str | None = None,
+ timeout_seconds: int | None = None,
+ ) -> dict[str, Any]:
"""
使用指定解释器执行一段 Python 代码并返回执行结果。
@@ -263,6 +291,7 @@ async def run_python_snippet(
target = self._resolve_interpreter(python_executable)
timeout = self._resolve_timeout(timeout_seconds)
+ process: asyncio.subprocess.Process | None = None
try:
process = await asyncio.create_subprocess_exec(
target,
@@ -271,12 +300,15 @@ async def run_python_snippet(
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
- stdout_raw, stderr_raw = await asyncio.wait_for(process.communicate(), timeout=timeout)
+ stdout_raw, stderr_raw = await asyncio.wait_for(
+ process.communicate(), timeout=timeout
+ )
stdout = stdout_raw.decode("utf-8", errors="replace")
stderr = stderr_raw.decode("utf-8", errors="replace")
except asyncio.TimeoutError:
- with suppress(Exception):
- process.kill()
+ if process is not None:
+ with suppress(Exception):
+ process.kill()
result = {
"ok": False,
"returncode": -1,
@@ -284,7 +316,9 @@ async def run_python_snippet(
"stderr": "Python 代码执行超时",
"python": target,
}
- self._audit("run_python_snippet", "error", {"python": target, "timeout": timeout})
+ self._audit(
+ "run_python_snippet", "error", {"python": target, "timeout": timeout}
+ )
return result
except Exception as e:
result = {
@@ -294,7 +328,9 @@ async def run_python_snippet(
"stderr": f"Python 代码执行异常: {type(e).__name__}: {e}",
"python": target,
}
- self._audit("run_python_snippet", "error", {"python": target, "reason": str(e)})
+ self._audit(
+ "run_python_snippet", "error", {"python": target, "reason": str(e)}
+ )
return result
result = {
diff --git a/app/core/plugins/schema.py b/app/core/plugins/schema.py
index 48d33963..dcce8b94 100644
--- a/app/core/plugins/schema.py
+++ b/app/core/plugins/schema.py
@@ -11,9 +11,10 @@
from dataclasses import dataclass
from pathlib import Path
from types import NoneType, UnionType
-from typing import Annotated, Any, Dict, Mapping, Union, get_args, get_origin
+from typing import Annotated, Any, Dict, Mapping, Union, cast, get_args, get_origin
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
+from app.core.config import PluginConfigBase
from .pypi_site import iter_plugin_entry_points
@@ -83,7 +84,9 @@ def _canonical_plugin_name(self, plugin_name: str) -> str:
return ""
return normalized.split("@", 1)[0].strip() or normalized
- def _resolve_local_plugin_py_path(self, plugin_name: str, plugin_path: Path) -> Path | None:
+ def _resolve_local_plugin_py_path(
+ self, plugin_name: str, plugin_path: Path
+ ) -> Path | None:
"""解析本地插件入口文件路径。
兼容以下目录结构:
@@ -153,7 +156,9 @@ def _resolve_local_schema_path(
return None
- def load_schema(self, plugin_name: str, plugin_path: Path | None) -> Dict[str, Dict[str, Any]]:
+ def load_schema(
+ self, plugin_name: str, plugin_path: Path | None
+ ) -> Dict[str, Dict[str, Any]]:
"""
加载并校验插件 Schema 定义。
@@ -208,7 +213,9 @@ def load_schema(self, plugin_name: str, plugin_path: Path | None) -> Dict[str, D
self._validate_schema_definition(plugin_name, schema)
return schema
- def _extract_schema_from_module(self, plugin_name: str, module: Any) -> Dict[str, Dict[str, Any]]:
+ def _extract_schema_from_module(
+ self, plugin_name: str, module: Any
+ ) -> Dict[str, Dict[str, Any]]:
"""
从模块对象中提取 schema 定义。
@@ -240,7 +247,10 @@ def _extract_schema_from_module(self, plugin_name: str, module: Any) -> Dict[str
if not isinstance(schema, dict):
raise PluginSchemaError(f"插件 Schema 必须是对象: {plugin_name}")
- return self._normalize_schema_fields(plugin_name, schema)
+ return self._normalize_schema_fields(
+ plugin_name,
+ cast(Mapping[str, Any], schema),
+ )
def _import_module_from_file(self, module_name: str, file_path: Path) -> Any:
"""
@@ -295,7 +305,9 @@ def _load_schema_from_plugin_py(
module = self._import_module_from_file(module_name, plugin_py_path)
return self._extract_schema_from_module(plugin_name, module)
- def _load_schema_from_entry_point(self, plugin_name: str) -> Dict[str, Dict[str, Any]]:
+ def _load_schema_from_entry_point(
+ self, plugin_name: str
+ ) -> Dict[str, Dict[str, Any]]:
"""
从 PyPI Entry Point 对应模块加载 schema。
@@ -385,7 +397,10 @@ def _load_schema_from_py(
if not isinstance(schema, dict):
raise PluginSchemaError(f"插件 Schema 必须是对象: {plugin_name}")
- return self._normalize_schema_fields(plugin_name, schema)
+ return self._normalize_schema_fields(
+ plugin_name,
+ cast(Mapping[str, Any], schema),
+ )
def _build_schema_from_config_declaration(
self,
@@ -409,7 +424,7 @@ def _build_schema_from_config_declaration(
3) BaseModel 字段默认值工厂执行失败。
"""
target = declaration
- if callable(declaration) and not (inspect.isclass(declaration) and issubclass(declaration, BaseModel)):
+ if callable(declaration) and not inspect.isclass(declaration):
try:
target = declaration()
except Exception as e:
@@ -417,15 +432,25 @@ def _build_schema_from_config_declaration(
f"执行 Config 声明失败: {plugin_name}, error={type(e).__name__}: {e}"
) from e
- if inspect.isclass(target) and issubclass(target, BaseModel):
- return self._build_schema_from_model(plugin_name, target)
+ if inspect.isclass(target):
+ target_cls = target
+ if issubclass(target_cls, PluginConfigBase):
+ return self._build_schema_from_model(plugin_name, target_cls)
+ if issubclass(target_cls, BaseModel):
+ raise PluginSchemaError(
+ f"Config 必须继承 PluginConfigBase: {plugin_name}, actual={target_cls.__name__}"
+ )
+ raise PluginSchemaError(
+ f"Config 声明必须是 PluginConfigBase 子类: {plugin_name}, actual={target_cls.__name__}"
+ )
- if isinstance(target, BaseModel):
+ if isinstance(target, PluginConfigBase):
return self._build_schema_from_model(plugin_name, type(target))
- normalized = self._normalize_schema_object(target)
- if isinstance(normalized, dict):
- return self._normalize_schema_fields(plugin_name, normalized)
+ if isinstance(target, BaseModel):
+ raise PluginSchemaError(
+ f"Config 必须继承 PluginConfigBase: {plugin_name}, actual={type(target).__name__}"
+ )
raise PluginSchemaError(
f"Config 声明不支持的返回类型: {plugin_name}, type={type(target).__name__}"
@@ -467,7 +492,9 @@ def _build_schema_from_model(
if not field_info.is_required():
if field_info.default_factory is not None:
try:
- field_schema["default"] = field_info.default_factory()
+ field_schema["default"] = field_info.get_default(
+ call_default_factory=True,
+ )
except Exception as e:
raise PluginSchemaError(
f"Config 字段 default_factory 执行失败: {plugin_name}.{field_name}, "
@@ -482,7 +509,7 @@ def _build_schema_from_model(
raise PluginSchemaError(
f"Config 字段 json_schema_extra 必须是对象: {plugin_name}.{field_name}"
)
- field_schema.update(copy.deepcopy(extra))
+ field_schema.update(copy.deepcopy(cast(dict[str, Any], extra)))
result[field_name] = field_schema
@@ -551,11 +578,6 @@ def _normalize_schema_fields(
"""
normalized: Dict[str, Dict[str, Any]] = {}
for field_name, field_schema in schema.items():
- if not isinstance(field_name, str):
- raise PluginSchemaError(
- f"Schema 字段名必须是字符串: {plugin_name}.{field_name}"
- )
-
value = field_schema
if hasattr(value, "to_dict") and callable(value.to_dict):
value = value.to_dict()
@@ -594,12 +616,17 @@ def _load_schema_from_json(
with schema_path.open("r", encoding="utf-8") as f:
schema = json.load(f)
except Exception as e:
- raise PluginSchemaError(f"读取 Schema 失败: {plugin_name}, error={e}") from e
+ raise PluginSchemaError(
+ f"读取 Schema 失败: {plugin_name}, error={e}"
+ ) from e
if not isinstance(schema, dict):
raise PluginSchemaError(f"插件 Schema 必须是对象: {plugin_name}")
- return self._normalize_schema_fields(plugin_name, schema)
+ return self._normalize_schema_fields(
+ plugin_name,
+ cast(Mapping[str, Any], schema),
+ )
def _validate_schema_definition(
self,
@@ -649,9 +676,6 @@ def apply_defaults_and_validate(
3) 任一字段值不满足声明类型或约束;
4) 字段值为 None 且字段未声明 nullable。
"""
- if not isinstance(config, dict):
- raise PluginSchemaError(f"插件配置必须是对象: {plugin_name}")
-
compiled = self._compile_schema(plugin_name, schema)
merged = copy.deepcopy(config)
@@ -660,7 +684,9 @@ def apply_defaults_and_validate(
if item.has_default:
merged[item.name] = copy.deepcopy(item.default)
elif item.required:
- raise PluginSchemaError(f"缺少必填配置项: {plugin_name}.{item.name}")
+ raise PluginSchemaError(
+ f"缺少必填配置项: {plugin_name}.{item.name}"
+ )
else:
continue
@@ -697,7 +723,9 @@ def _compile_schema(
compiled: list[_CompiledField] = []
for field_name, raw_field in schema.items():
if "type" not in raw_field:
- raise PluginSchemaError(f"Schema 字段缺少 type: {plugin_name}.{field_name}")
+ raise PluginSchemaError(
+ f"Schema 字段缺少 type: {plugin_name}.{field_name}"
+ )
try:
spec = _FieldSpecModel.model_validate(raw_field)
@@ -720,7 +748,7 @@ def _compile_schema(
) from e
try:
- adapter = TypeAdapter(annotation)
+ adapter: TypeAdapter[Any] = TypeAdapter(annotation)
except Exception as e:
raise PluginSchemaError(
f"Schema 类型无法编译: {plugin_name}.{field_name}, type={spec.type}, error={type(e).__name__}: {e}"
@@ -870,7 +898,10 @@ def _parse_type_expr(self, expr: str) -> Any:
inner = normalized[6:-1].strip()
if not inner:
raise PluginSchemaError(f"非法 tuple 类型表达式: {expr}")
- tuple_args = [self._parse_type_expr(item.strip()) for item in self._split_top_level(inner, ",")]
+ tuple_args = [
+ self._parse_type_expr(item.strip())
+ for item in self._split_top_level(inner, ",")
+ ]
return tuple[tuple(tuple_args)]
if normalized.startswith("Optional[") and normalized.endswith("]"):
@@ -906,7 +937,9 @@ def _split_top_level(self, expr: str, separator: str) -> list[str]:
result.append(expr[start:])
return [item.strip() for item in result if item.strip()]
- def _format_schema_error(self, plugin_name: str, field_name: str, error: ValidationError) -> str:
+ def _format_schema_error(
+ self, plugin_name: str, field_name: str, error: ValidationError
+ ) -> str:
"""
格式化 schema 字段元信息错误。
diff --git a/app/core/task_manager.py b/app/core/task_manager.py
index efe4a12f..49cc71e0 100644
--- a/app/core/task_manager.py
+++ b/app/core/task_manager.py
@@ -20,25 +20,24 @@
# Contact: DLmaster_361@163.com
-import uuid
import asyncio
-from typing import Dict, Literal
+import uuid
+from typing import Dict, Literal, cast
-from .config import Config, MaaConfig, SrcConfig, GeneralConfig, MaaEndConfig
-from .config import Config, MaaConfig, SrcConfig, GeneralConfig
-from .plugins import PluginEventFactory, PluginEventNames
-from app.services import System
+from app.models import GeneralConfig, MaaConfig, MaaEndConfig, SrcConfig
from app.models.task import TaskItem, ScriptItem, UserItem, TaskExecuteBase
+from app.services import System
from app.utils import get_logger
-from app.task import MaaManager, SrcManager, GeneralManager, MaaEndManager
from app.utils.constants import POWER_SIGN_MAP
+from .config import Config
+from .plugins import PluginEventFactory, PluginEventNames
logger = get_logger("业务调度")
def _resolve_queue_name(queue_id: str | None) -> str | None:
- """根据 queue_id 解析队列名,解析失败时返回 None。"""
+ """根据 queue_id 解析队列名,失败时返回 None"""
if not queue_id:
return None
@@ -49,7 +48,7 @@ def _resolve_queue_name(queue_id: str | None) -> str | None:
def _build_script_summaries(script_list: list[ScriptItem]) -> list[dict[str, str]]:
- """构建脚本摘要数组,供任务事件复用。"""
+ """构建脚本摘要,供任务事件复用"""
return [
{
"script_id": item.script_id,
@@ -61,7 +60,7 @@ def _build_script_summaries(script_list: list[ScriptItem]) -> list[dict[str, str
def _resolve_final_script(task_info: TaskItem) -> ScriptItem | None:
- """获取任务结束时可代表当前任务状态的脚本项。"""
+ """获取任务结束时最能代表结果的脚本项"""
if 0 <= task_info.current_index < len(task_info.script_list):
return task_info.script_list[task_info.current_index]
if task_info.script_list:
@@ -70,24 +69,15 @@ def _resolve_final_script(task_info: TaskItem) -> ScriptItem | None:
class TaskInfo(TaskItem):
-
def _has_meaningful_current_log(self) -> bool:
- """判断当前脚本日志是否包含有效内容。"""
+ """判断当前脚本日志里是否有有效内容"""
if not (0 <= self.current_index < len(self.script_list)):
return False
- log_text = self.script_list[self.current_index].log
- if not isinstance(log_text, str):
- return False
-
- return bool(log_text.strip())
+ return bool(self.script_list[self.current_index].log.strip())
async def _emit_task_progress(self) -> None:
- """发送 task.progress 事件,避免重复发送相同快照。"""
- if 0 <= self.current_index < len(self.script_list):
- if not self._has_meaningful_current_log():
- return
-
+ """发送 task.progress 事件,避免重复广播相同快照"""
progress_data = PluginEventFactory.build_task_progress_data(
self,
queue_name=_resolve_queue_name(self.queue_id),
@@ -104,7 +94,7 @@ async def _emit_task_progress(self) -> None:
)
async def _emit_task_log(self) -> None:
- """发送 task.log 事件,提供当前脚本日志内容。"""
+ """发送 task.log 事件,带上当前脚本日志"""
if not (0 <= self.current_index < len(self.script_list)):
return
if not self._has_meaningful_current_log():
@@ -138,8 +128,17 @@ async def _emit_task_log(self) -> None:
},
)
+ async def emit_task_progress(self) -> None:
+ """公开的任务进度事件发送入口"""
+ await self._emit_task_progress()
+
+ async def emit_task_log(self) -> None:
+ """公开的任务日志事件发送入口"""
+ await self._emit_task_log()
+
async def on_change(self):
- """任务状态变更时,同步推送前端并广播插件事件。"""
+ """任务状态变化后推送 WebSocket 增量更新。"""
+
await Config.send_websocket_message(
id=self.task_id,
type="Update",
@@ -157,7 +156,6 @@ async def on_change(self):
class Task(TaskExecuteBase):
-
def __init__(self, task_info: TaskInfo):
super().__init__()
self.task_info = task_info
@@ -166,14 +164,14 @@ def __init__(self, task_info: TaskInfo):
self._exit_error: str | None = None
def _build_script_event_data(self) -> Dict[str, str | None]:
- """附加到 script.* 事件的任务上下文。"""
+ """附加到 script.* 事件里的任务上下文"""
return {
"queue_id": self.task_info.queue_id,
"queue_name": _resolve_queue_name(self.task_info.queue_id),
}
async def _emit_task_start(self) -> None:
- """发送 task.start 事件,提供插件所需的任务标识和可操作入口。"""
+ """发送 task.start 事件"""
scripts = _build_script_summaries(self.task_info.script_list)
primary_script = scripts[0] if len(scripts) == 1 else None
@@ -211,7 +209,7 @@ async def _emit_task_start(self) -> None:
)
async def _emit_task_exit(self) -> None:
- """发送 task.exit 事件,告知任务最终结果。"""
+ """发送 task.exit 事件"""
scripts = _build_script_summaries(self.task_info.script_list)
final_script = _resolve_final_script(self.task_info)
@@ -242,6 +240,7 @@ async def _emit_task_exit(self) -> None:
)
async def prepare(self):
+ """根据任务模式解析脚本清单并初始化运行项。"""
# 初始化任务列表
script_ids = (
@@ -273,15 +272,18 @@ async def prepare(self):
)
async def main_task(self):
+ """串行执行任务中每个脚本项。"""
await self.prepare()
await self._emit_task_start()
- await self.task_info._emit_task_progress()
+ await self.task_info.emit_task_progress()
logger.info(
f"开始运行任务: {self.task_info.task_id}, 模式: {self.task_info.mode}"
)
+ from app.task import MaaManager, SrcManager, GeneralManager, MaaEndManager
+
# 依次运行任务
for self.task_info.current_index, script_item in enumerate(
self.task_info.script_list
@@ -408,9 +410,10 @@ async def main_task(self):
)
raise
else:
+ current_status = cast(str, script_item.status)
result_event = (
PluginEventNames.SCRIPT_SUCCESS
- if script_item.status == "完成"
+ if current_status == "完成"
else PluginEventNames.SCRIPT_ERROR
)
result_error = None
@@ -445,6 +448,7 @@ async def main_task(self):
)
async def final_task(self) -> None:
+ """任务收尾:上报结果并处理电源动作信号。"""
logger.info(f"任务结束: {self.task_info.task_id}")
@@ -454,11 +458,10 @@ async def final_task(self) -> None:
data={"Accomplish": self.task_info.result},
)
- await self.task_info._emit_task_progress()
+ await self.task_info.emit_task_progress()
await self._emit_task_exit()
if self.task_info.mode == "AutoProxy" and self.task_info.queue_id is not None:
-
if Config.power_sign == "NoAction":
Config.power_sign = Config.QueueConfig[
uuid.UUID(self.task_info.queue_id)
@@ -468,7 +471,7 @@ async def final_task(self) -> None:
)
async def on_crash(self, e: Exception) -> None:
- """处理任务异常并记录退出状态。"""
+ """任务异常回调:记录日志并向前端推送错误信息。"""
if self._exit_result == "success":
self._exit_result = "error"
self._exit_error = f"{type(e).__name__}: {e}"
@@ -489,12 +492,35 @@ def __init__(self):
self.task_info: Dict[uuid.UUID, TaskInfo] = {}
self.task_handler: Dict[uuid.UUID, Task] = {}
+ self._mutex = asyncio.Lock()
+ self._background_tasks: set[asyncio.Task[None]] = set()
+
+ def _track_background_task(self, task: asyncio.Task[None]) -> None:
+ """跟踪后台任务,避免异常静默与任务泄漏。"""
+
+ self._background_tasks.add(task)
+
+ def _done(done_task: asyncio.Task[None]) -> None:
+ self._background_tasks.discard(done_task)
+ if done_task.cancelled():
+ return
+ exc = done_task.exception()
+ if exc is not None:
+ done_task.get_loop().call_exception_handler(
+ {
+ "message": "TaskManager 后台任务执行失败",
+ "exception": exc,
+ "task": done_task,
+ }
+ )
+
+ task.add_done_callback(_done)
async def add_task(
self,
mode: Literal["AutoProxy", "ManualReview", "ScriptConfig"],
id: str,
- new_task_info: dict | None = None,
+ new_task_info: dict[str, str] | None = None,
) -> uuid.UUID:
"""
添加任务, 根据 id 值搜索实际指向的任务配置
@@ -510,61 +536,61 @@ async def add_task(
uid = uuid.UUID(id)
- if mode == "ScriptConfig":
- if uid in Config.ScriptConfig:
+ async with self._mutex:
+ if mode == "ScriptConfig":
+ if uid in Config.ScriptConfig:
+ task_uid = uuid.uuid4()
+ queue_id = None
+ script_uid = uid
+ user_uid = "Default"
+ else:
+ for script_id, script in Config.ScriptConfig.items():
+ if uid in script.UserData:
+ task_uid = uuid.uuid4()
+ queue_id = None
+ script_uid = script_id
+ user_uid = uid
+ break
+ else:
+ raise ValueError(f"任务 {uid} 无法找到对应脚本配置")
+ elif uid in Config.QueueConfig:
+ task_uid = uuid.uuid4()
+ queue_id = uid
+ script_uid = None
+ user_uid = None
+ elif uid in Config.ScriptConfig:
task_uid = uuid.uuid4()
queue_id = None
script_uid = uid
- user_uid = "Default"
+ user_uid = None
else:
- for script_id, script in Config.ScriptConfig.items():
- if uid in script.UserData:
- task_uid = uuid.uuid4()
- queue_id = None
- script_uid = script_id
- user_uid = uid
- break
- else:
- raise ValueError(f"任务 {uid} 无法找到对应脚本配置")
- elif uid in Config.QueueConfig:
- task_uid = uuid.uuid4()
- queue_id = uid
- script_uid = None
- user_uid = None
- elif uid in Config.ScriptConfig:
- task_uid = uuid.uuid4()
- queue_id = None
- script_uid = uid
- user_uid = None
- else:
- raise ValueError(f"任务 {uid} 无法找到对应脚本配置")
+ raise ValueError(f"任务 {uid} 无法找到对应脚本配置")
- if script_uid is not None and Config.ScriptConfig[script_uid].is_locked:
- raise RuntimeError(
- f"任务 {Config.ScriptConfig[script_uid].get('Info', 'Name')} 已在运行"
- )
+ if script_uid is not None and Config.ScriptConfig[script_uid].is_locked:
+ raise RuntimeError(
+ f"任务 {Config.ScriptConfig[script_uid].get('Info', 'Name')} 已在运行"
+ )
- logger.info(f"创建任务: {task_uid}, 模式: {mode}")
- if new_task_info:
- new_task_info["newTask"] = str(task_uid)
- await Config.send_websocket_message(
- id="TaskManager", type="Signal", data=new_task_info
+ logger.info(f"创建任务: {task_uid}, 模式: {mode}")
+ if new_task_info:
+ new_task_info["newTask"] = str(task_uid)
+ await Config.send_websocket_message(
+ id="TaskManager", type="Signal", data=new_task_info
+ )
+ self.task_info[task_uid] = TaskInfo(
+ mode=mode,
+ task_id=str(task_uid),
+ queue_id=str(queue_id) if queue_id else None,
+ script_id=str(script_uid) if script_uid else None,
+ user_id=str(user_uid) if user_uid else None,
)
- self.task_info[task_uid] = TaskInfo(
- mode=mode,
- task_id=str(task_uid),
- queue_id=str(queue_id) if queue_id else None,
- script_id=str(script_uid) if script_uid else None,
- user_id=str(user_uid) if user_uid else None,
- )
- self.task_handler[task_uid] = Task(self.task_info[task_uid])
- self.task_handler[task_uid].execute()
- asyncio.create_task(self.clean_task(task_uid))
+ self.task_handler[task_uid] = Task(self.task_info[task_uid])
+ self.task_handler[task_uid].execute()
+ self._track_background_task(asyncio.create_task(self.clean_task(task_uid)))
- return task_uid
+ return task_uid
async def clean_task(self, task_uid: uuid.UUID) -> None:
-
await self.task_handler[task_uid].accomplish.wait()
power_enabled = bool(self.task_info[task_uid].mode != "ScriptConfig")
self.task_info.pop(task_uid, None)
@@ -622,7 +648,6 @@ async def start_startup_queue(self):
logger.info("开始运行启动时任务")
for uid, queue in Config.QueueConfig.items():
-
if queue.get("Info", "StartUpEnabled"):
logger.info(f"启动时需要运行的队列:{uid}")
await TaskManager.add_task(
diff --git a/app/core/timer.py b/app/core/timer.py
index 4ad7b422..2bc60322 100644
--- a/app/core/timer.py
+++ b/app/core/timer.py
@@ -23,7 +23,6 @@
from datetime import datetime
from app.services import Matomo
-from app.MaaFW import ArknightWin32Toolkit
from app.utils import get_logger
from .config import Config
from .task_manager import TaskManager
@@ -69,6 +68,7 @@ async def second_task(self):
await self.timed_start()
if Config.ToolsConfig.get("ArknightsPC", "Enabled"):
+ from app.MaaFW import ArknightWin32Toolkit
await ArknightWin32Toolkit.scheduled_task()
await asyncio.sleep(1)
diff --git a/app/models/ConfigBase.py b/app/models/ConfigBase.py
deleted file mode 100644
index 0084d3e4..00000000
--- a/app/models/ConfigBase.py
+++ /dev/null
@@ -1,1375 +0,0 @@
-# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
-# Copyright © 2024-2025 DLmaster361
-# Copyright © 2025 MoeSnowyFox
-# Copyright © 2025-2026 AUTO-MAS Team
-
-# This file is part of AUTO-MAS.
-
-# AUTO-MAS is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as
-# published by the Free Software Foundation, either version 3 of
-# the License, or (at your option) any later version.
-
-# AUTO-MAS is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty
-# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
-# the GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with AUTO-MAS. If not, see .
-
-# Contact: DLmaster_361@163.com
-
-
-from __future__ import annotations
-import os
-import json
-import uuid
-import shlex
-import inspect
-import asyncio
-import pyautogui
-import win32com.client
-from copy import deepcopy
-from urllib.parse import urlparse
-from datetime import datetime
-from contextlib import suppress
-from abc import ABC, abstractmethod
-from pathlib import Path
-from typing import Any, Type, TypeVar, Generic, Callable, Coroutine
-
-from app.utils import get_logger, dpapi_encrypt, dpapi_decrypt
-from app.utils.constants import (
- RESERVED_NAMES,
- ILLEGAL_CHARS,
- DEFAULT_DATETIME,
- EMULATOR_PATH_BOOK,
-)
-
-logger = get_logger("配置基类")
-
-
-class ValidatorBase(ABC):
- """基础配置验证器"""
-
- @abstractmethod
- def validate(self, value: Any) -> bool:
- """验证值是否合法"""
- pass
-
- @abstractmethod
- def correct(self, value: Any) -> Any:
- """修正非法值"""
- pass
-
-
-class StringValidator(ValidatorBase):
- """字符串验证器"""
-
- def validate(self, value):
- return isinstance(value, str)
-
- def correct(self, value):
- return value if self.validate(value) else ""
-
-
-class RangeValidator(ValidatorBase):
- """范围验证器"""
-
- def __init__(self, min: int | float, max: int | float):
- self.min = min
- self.max = max
- self.range = (min, max)
-
- def validate(self, value):
- if not isinstance(value, (int, float)):
- return False
- return self.min <= value <= self.max
-
- def correct(self, value):
- if not isinstance(value, (int, float)):
- try:
- value = float(value)
- except TypeError:
- return self.min
- return min(max(self.min, value), self.max)
-
-
-class OptionsValidator(ValidatorBase):
- """选项验证器"""
-
- def __init__(self, options: list):
- if not options:
- raise ValueError("可选项不能为空")
-
- self.options = options
-
- def validate(self, value):
- return value in self.options
-
- def correct(self, value):
- return value if self.validate(value) else self.options[0]
-
-
-class MultipleOptionsValidator(ValidatorBase):
- """多选选项验证器"""
-
- def __init__(self, options: list):
- if not options:
- raise ValueError("可选项不能为空")
-
- self.options = options
-
- def validate(self, value):
- if not isinstance(value, list):
- return False
-
- return all(item in self.options for item in value)
-
- def correct(self, value):
- return value if self.validate(value) else []
-
-
-class UUIDValidator(ValidatorBase):
- """UUID验证器"""
-
- def validate(self, value):
- try:
- uuid.UUID(value)
- return True
- except (TypeError, ValueError):
- return False
-
- def correct(self, value):
- return value if self.validate(value) else str(uuid.uuid4())
-
-
-class MultipleUIDValidator(ValidatorBase):
- """多配置管理类UID验证器"""
-
- def __init__(
- self, default: Any, related_config: dict[str, MultipleConfig], config_name: str
- ):
- self.default = default
- self.related_config = related_config
- self.config_name = config_name
-
- def validate(self, value):
- if value == self.default:
- return True
- if not isinstance(value, str):
- return False
- try:
- uid = uuid.UUID(value)
- except (TypeError, ValueError):
- return False
- if uid in self.related_config.get(self.config_name, {}):
- return True
- return False
-
- def correct(self, value):
- if self.validate(value):
- return value
- return self.default
-
-
-class DateTimeValidator(ValidatorBase):
- """日期时间验证器"""
-
- def __init__(self, date_format: str) -> None:
- if not date_format:
- raise ValueError("日期时间格式不能为空")
- self.date_format = date_format
-
- def validate(self, value):
- if not isinstance(value, str):
- return False
- try:
- datetime.strptime(value, self.date_format)
- return True
- except ValueError:
- return False
-
- def correct(self, value):
- if not isinstance(value, str):
- return DEFAULT_DATETIME.strftime(self.date_format)
- try:
- datetime.strptime(value, self.date_format)
- return value
- except ValueError:
- return DEFAULT_DATETIME.strftime(self.date_format)
-
-
-class JSONValidator(ValidatorBase):
- def __init__(self, tpye: type[dict] | type[list] = dict) -> None:
- self.type = tpye
-
- def validate(self, value):
- if not isinstance(value, str):
- return False
- try:
- data = json.loads(value)
- if isinstance(data, self.type):
- return True
- else:
- return False
- except json.JSONDecodeError:
- return False
-
- def correct(self, value):
- return (
- value if self.validate(value) else ("{ }" if self.type == dict else "[ ]")
- )
-
-
-class EncryptValidator(ValidatorBase):
- """加密数据验证器"""
-
- def validate(self, value):
- if not isinstance(value, str):
- return False
- try:
- dpapi_decrypt(value)
- return True
- except:
- return False
-
- def correct(self, value: Any) -> Any:
- return value if self.validate(value) else dpapi_encrypt("数据损坏, 请重新设置")
-
-
-class VirtualConfigValidator(ValidatorBase):
- """虚拟配置验证器"""
-
- def __init__(self, function: Callable[[], str]):
- self.function = function
- self.if_init = False
-
- def validate(self, value):
- if not self.if_init:
- self.if_init = True
- return True
- return False
-
- def correct(self, value):
- try:
- return self.function()
- except Exception as e:
- return str(e)
-
-
-class BoolValidator(ValidatorBase):
- """布尔值验证器"""
-
- def validate(self, value):
- if not isinstance(value, bool):
- return False
- return True
-
- def correct(self, value):
- return value if self.validate(value) else False
-
-
-class FileValidator(ValidatorBase):
- """文件路径验证器"""
-
- def validate(self, value):
- if not isinstance(value, str):
- return False
- # 允许空字符串(表示未设置路径)
- if value == "":
- return True
- if not Path(value).is_absolute():
- return False
- if Path(value).suffix == ".lnk":
- return False
- return True
-
- def correct(self, value):
- if not isinstance(value, str):
- value = str(Path.cwd())
- # 空字符串直接返回
- if value == "":
- return ""
- if "%APPDATA%" in value:
- value = value.replace("%APPDATA%", os.getenv("APPDATA") or "")
- if not Path(value).is_absolute():
- value = Path(value).resolve().as_posix()
- if Path(value).suffix == ".lnk":
- try:
- shell = win32com.client.Dispatch("WScript.Shell")
- shortcut = shell.CreateShortcut(value)
- value = shortcut.TargetPath
- except:
- pass
- return Path(value).resolve().as_posix()
-
-
-class FolderValidator(ValidatorBase):
- """文件夹路径验证器"""
-
- def validate(self, value):
- if not isinstance(value, str):
- return False
- if not Path(value).is_absolute():
- return False
- if not Path(value).is_dir():
- return False
- return True
-
- def correct(self, value):
- if not isinstance(value, str):
- value = str(Path.cwd())
- if "%APPDATA%" in value:
- value = value.replace("%APPDATA%", os.getenv("APPDATA") or "")
- if not Path(value).is_dir():
- value = Path(value).with_suffix("")
- return Path(value).resolve().as_posix()
-
-
-class EmulatorPathValidator(FileValidator):
- """模拟器管理器路径验证器"""
-
- def __init__(self, emulator_type: ConfigItem) -> None:
- super().__init__()
-
- self.emulator_type = emulator_type
-
- def validate(self, value):
- if not isinstance(value, str):
- return False
- # 允许空字符串(表示未设置路径)
- if value == "":
- return True
- if not Path(value).is_absolute():
- return False
- if Path(value).suffix == ".lnk":
- return False
-
- path = Path(value)
-
- if not path.is_file() or not os.access(path, os.X_OK):
- return False
-
- if (
- self.emulator_type.getValue() in EMULATOR_PATH_BOOK
- and path.name
- != EMULATOR_PATH_BOOK[self.emulator_type.getValue()]["executables"][0]
- ):
- return False
-
- return True
-
- def correct(self, value):
-
- if not isinstance(value, str):
- value = str(Path.cwd())
- # 空字符串直接返回
- if value == "":
- return ""
- if "%APPDATA%" in value:
- value = value.replace("%APPDATA%", os.getenv("APPDATA") or "")
- if not Path(value).is_absolute():
- value = Path(value).resolve().as_posix()
- if Path(value).suffix == ".lnk":
- try:
- shell = win32com.client.Dispatch("WScript.Shell")
- shortcut = shell.CreateShortcut(value)
- value = shortcut.TargetPath
- except:
- pass
-
- # 不支持矫正的模拟器类型直接返回路径字符串
- if self.emulator_type.getValue() not in EMULATOR_PATH_BOOK:
- return Path(value).resolve().as_posix()
-
- try:
- # 从给定路径向上或向下搜索模拟器主管理器程序的完整路径
- path = Path(value)
-
- # 获取模拟器配置信息
- config = EMULATOR_PATH_BOOK[self.emulator_type.getValue()]
- executables = config["executables"]
-
- # 第一个可执行文件是主管理器程序(优先级最高)
- primary_exe = executables[0]
-
- # 如果输入的是文件,先获取其父目录
- if path.is_file():
- path = path.parent
-
- # 1. 首先检查当前目录是否直接包含主管理器程序
- # 如果用户给的就是正确的主程序路径,直接返回
- primary_exe_path = path / primary_exe
- if primary_exe_path.exists():
- result = str(primary_exe_path)
- return result
-
- # 2. 向上搜索父目录,找到直接包含主管理器程序的目录(最多3层)
- candidates = []
- current = path
- for level in range(3):
- parent = current.parent
- if parent == current: # 已到达根目录
- break
-
- # 只接受直接包含主管理器程序的目录
- parent_exe_path = parent / primary_exe
- if parent_exe_path.exists():
- candidates.append(
- {
- "path": parent,
- "exe_path": parent_exe_path,
- "depth": len(parent.parts),
- "level": level + 1,
- }
- )
-
- current = parent
-
- # 如果找到了候选目录,选择最优的(深度最小的,即最接近根目录的)
- if candidates:
- # 排序策略:深度越小越好(越靠近根目录)
- candidates.sort(key=lambda x: x["depth"])
-
- best_candidate = candidates[0]
- result = str(best_candidate["exe_path"])
-
- return result
-
- # 3. 如果向上没找到,尝试向下搜索子目录(仅1层,且必须直接包含主管理器程序)
- try:
- for subdir in path.iterdir():
- if subdir.is_dir():
- subdir_exe_path = subdir / primary_exe
- # 只接受直接包含主管理器程序的子目录
- if subdir_exe_path.exists():
- result = str(subdir_exe_path)
- return result
- except PermissionError:
- pass
-
- # 4. 检查兄弟目录(必须直接包含主管理器程序)
- if path.parent != path:
- try:
- for sibling in path.parent.iterdir():
- if sibling.is_dir() and sibling != path:
- sibling_exe_path = sibling / primary_exe
- # 只接受直接包含主管理器程序的兄弟目录
- if sibling_exe_path.exists():
- result = str(sibling_exe_path)
- return result
- except PermissionError:
- pass
-
- return Path(value).resolve().as_posix()
- except Exception:
- return Path(value).resolve().as_posix()
-
-
-class UserNameValidator(ValidatorBase):
- """用户名验证器"""
-
- def validate(self, value):
- if not isinstance(value, str):
- return False
-
- if not value or not value.strip():
- return False
-
- if value != value.strip() or value != value.strip("."):
- return False
-
- if any(char in ILLEGAL_CHARS for char in value):
- return False
-
- if value.upper() in RESERVED_NAMES:
- return False
- if len(value) > 255:
- return False
-
- return True
-
- def correct(self, value):
- if not isinstance(value, str):
- value = "默认用户名"
-
- value = value.strip().strip(".")
-
- value = "".join(char for char in value if char not in ILLEGAL_CHARS)
-
- if value.upper() in RESERVED_NAMES or not value:
- value = "默认用户名"
-
- if len(value) > 255:
- value = value[:255]
-
- return value
-
-
-class KeyValidator(ValidatorBase):
- """键盘按键格式验证器"""
-
- def __init__(self, default: str = ""):
- self.default = default
-
- def validate(self, value: Any) -> bool:
- return value in pyautogui.KEYBOARD_KEYS
-
- def correct(self, value: Any) -> Any:
- return value if self.validate(value) else self.default
-
-
-class URLValidator(ValidatorBase):
- """URL格式验证器"""
-
- def __init__(
- self,
- schemes: list[str] | None = None,
- require_netloc: bool = True,
- default: str = "",
- ):
- """
- :param schemes: 允许的协议列表, 若为 None 则允许任意协议
- :param require_netloc: 是否要求必须包含网络位置, 如域名或IP
- """
- self.schemes = [s.lower() for s in schemes] if schemes else None
- self.require_netloc = require_netloc
- self.default = default
-
- def validate(self, value):
- if value == self.default:
- return True
-
- if not isinstance(value, str):
- return False
-
- try:
- parsed = urlparse(value)
- except Exception:
- return False
-
- # 检查协议
- if self.schemes is not None:
- if not parsed.scheme or parsed.scheme.lower() not in self.schemes:
- return False
- else:
- # 不限制协议仍要求有 scheme
- if not parsed.scheme:
- return False
-
- # 检查是否包含网络位置
- if self.require_netloc and not parsed.netloc:
- return False
-
- return True
-
- def correct(self, value):
- return value if self.validate(value) else self.default
-
-
-class ArgumentValidator(ValidatorBase):
-
- def validate(self, value):
- if not isinstance(value, str):
- return False
- try:
- shlex.split(value.strip())
- return True
- except ValueError:
- return False
-
- def correct(self, value):
-
- return value if self.validate(value) else ""
-
-
-class AdvancedArgumentValidator(ValidatorBase):
-
- def validate(self, value):
- if not isinstance(value, str):
- return False
- try:
- for segment in value.split("|"):
- segment = segment.strip()
- if not segment:
- continue
- param_str = segment.split("%", 1)[-1].strip()
- shlex.split(param_str)
- return True
- except ValueError:
- return False
-
- def correct(self, value):
-
- return value if self.validate(value) else ""
-
-
-class ConfigItem:
- """配置项"""
-
- def __init__(
- self,
- group: str,
- name: str,
- default: Any,
- validator: ValidatorBase = StringValidator(),
- legacy_group: str | None = None,
- legacy_name: str | None = None,
- ):
- """
- Parameters
- ----------
- group: str
- 配置项分组名称
-
- name: str
- 配置项字段名称
-
- default: Any
- 配置项默认值
-
- validator: ValidatorBase
- 配置项验证器, 默认为 None, 表示不进行验证
- """
- self.group = group
- self.name = name
- self.value: Any = default
- self.validator = validator
- self.legacy_group_name = (
- (legacy_group or group, legacy_name or name)
- if legacy_group or legacy_name
- else None
- )
- self.is_locked = False
- self._slots: list[Callable[[Any], Any]] = []
-
- if not self.validator.validate(self.value):
- raise ValueError(
- f"配置项 '{self.group}.{self.name}' 的默认值 '{self.value}' 不合法"
- )
-
- def setValue(self, value: Any):
- """
- 设置配置项值, 将自动进行验证和修正
-
- Parameters
- ----------
- value: Any
- 要设置的值, 可以是任何合法类型
- """
-
- if (
- dpapi_decrypt(self.value)
- if isinstance(self.validator, EncryptValidator)
- else self.value
- ) == value:
- return
-
- if self.is_locked:
- raise ValueError(f"配置项 '{self.group}.{self.name}' 已锁定, 无法修改")
-
- # deepcopy new value
- try:
- self.value = deepcopy(value)
- except:
- self.value = value
-
- if isinstance(self.validator, EncryptValidator):
- if self.validator.validate(self.value):
- self.value = self.value
- else:
- self.value = dpapi_encrypt(self.value)
-
- if not self.validator.validate(self.value):
- self.value = self.validator.correct(self.value)
-
- if len(self._slots) > 0:
- asyncio.create_task(self._emit_signal(self.value))
-
- def getValue(self, if_decrypt: bool = True) -> Any:
- """
- 获取配置项值
- """
-
- v = (
- self.value
- if self.validator.validate(self.value)
- else self.validator.correct(self.value)
- )
-
- if isinstance(self.validator, EncryptValidator) and if_decrypt:
- return dpapi_decrypt(v)
- return v
-
- def bind(self, slot: Callable[[Any], Any]):
- """
- 连接槽函数到配置项修改信号
-
- Parameters
- ----------
- slot: Callable[[Any], Any]
- 槽函数,接收新值作为参数,支持同步和异步函数
- """
- if not callable(slot):
- raise TypeError(f"槽函数必须是可调用对象")
-
- if slot not in self._slots:
- self._slots.append(slot)
-
- def unbind(self, slot: Callable[[Any], Any]):
- """
- 断开槽函数连接
-
- Parameters
- ----------
- slot: Callable[[Any], Any]
- 要断开的槽函数
- """
- if slot in self._slots:
- self._slots.remove(slot)
-
- def unbind_all(self):
- """断开所有槽函数连接"""
- self._slots.clear()
-
- @logger.catch
- async def _emit_signal(self, value: Any) -> None:
- """
- 执行所有连接的槽函数, 将新值作为参数传递
-
- Parameters
- ----------
- value: Any
- 新值, 已经过验证和修正
- """
-
- for slot in self._slots:
- if inspect.iscoroutinefunction(slot):
- await slot(value)
- else:
- slot(value)
-
- def lock(self):
- """
- 锁定配置项, 锁定后无法修改配置项值
- """
- self.is_locked = True
-
- def unlock(self):
- """
- 解锁配置项, 解锁后可以修改配置项值
- """
- self.is_locked = False
-
-
-class ConfigBase(ABC):
- """
- 配置基类
-
- 这个类提供了基本的配置项管理功能, 包括连接配置文件、加载配置数据、获取和设置配置项值等。
-
- 此类不支持直接实例化, 必须通过子类来实现具体的配置项,
- 请继承此类并在子类中定义具体的配置项, 并在定义完成后调用父类的 `__init__` 方法。
- 若将配置项设为类属性, 则所有实例都会共享同一份配置项数据。
- 若将配置项设为实例属性, 则每个实例都会有独立的配置项数据。
- 子配置项可以是 `MultipleConfig` 的实例。
- """
-
- def __init__(self):
- self.file: Path | None = None
- self.is_locked = False
- self._save_methods: list[Callable[[], Coroutine[Any, Any, None]]] = []
-
- # 配置项索引
- self._config_item_index: dict[str, dict[str, ConfigItem]] = {}
- self._multiple_config_index: dict[str, MultipleConfig] = {}
- for name in dir(self):
- item = getattr(self, name)
-
- if isinstance(item, ConfigItem):
- if not self._config_item_index.get(item.group):
- self._config_item_index[item.group] = {}
- self._config_item_index[item.group][item.name] = item
-
- elif isinstance(item, MultipleConfig):
- self._multiple_config_index[name] = item
-
- async def connect(self, path: Path):
- """
- 将配置数据绑定到指定配置文件
-
- Parameters
- ----------
- path: Path
- 配置文件路径, 必须为 JSON 文件, 如果不存在则会创建
- """
-
- if path.suffix != ".json":
- raise ValueError("配置文件必须是扩展名为 '.json' 的 JSON 文件")
-
- if self.is_locked:
- raise ValueError("配置已锁定, 无法修改")
-
- self.file = path
-
- if not self.file.exists():
- self.file.parent.mkdir(parents=True, exist_ok=True)
- self.file.touch()
-
- try:
- data = json.loads(self.file.read_text(encoding="utf-8"))
- except json.JSONDecodeError:
- data = {}
-
- await self.load(data)
-
- await self.add_save_method(self.save)
-
- async def add_save_method(
- self, save_method: Callable[[], Coroutine[Any, Any, None]]
- ):
- """
- 添加父配置项的保存方法
-
- Parameters
- ----------
- save_method: Callable[[], Coroutine[Any, Any, None]]
- 保存方法
- """
-
- if save_method != self.save:
- self._save_methods.append(save_method)
-
- for sub_config in self._multiple_config_index.values():
- await sub_config.add_save_method(save_method)
-
- async def load(self, data: dict):
- """
- 从字典加载配置数据
-
- 这个方法会遍历字典中的配置项, 并将其设置到对应的 ConfigItem 实例中。
- 如果字典中包含 "SubConfigsInfo" 键, 则会加载子配置项, 这些子配置项应该是 MultipleConfig 的实例。
-
- Parameters
- ----------
- data: dict
- 配置数据字典
- """
-
- if self.is_locked:
- raise ValueError("配置已锁定, 无法修改")
-
- # 加载多配置项类型数据
- sub_configs = data.pop("SubConfigsInfo", {})
- if not isinstance(sub_configs, dict):
- sub_configs = {}
- for name, sub_config in self._multiple_config_index.items():
- data_for_sub_config = sub_configs.get(name)
- if isinstance(data_for_sub_config, dict):
- await sub_config.load(data_for_sub_config)
-
- for group, info in self._config_item_index.items():
- for name, item in info.items():
- try:
- item.setValue(data[group][name])
- except:
- if item.legacy_group_name is not None:
- with suppress(Exception):
- item.setValue(
- data[item.legacy_group_name[0]][
- item.legacy_group_name[1]
- ]
- )
-
- if self.file:
- await self.save()
-
- await asyncio.gather(*(_() for _ in self._save_methods))
-
- async def toDict(
- self, if_decrypt: bool = True, regenerate_uuids: bool = False
- ) -> dict[str, Any]:
- """将配置项转换为字典"""
-
- data = {}
-
- for group, info in self._config_item_index.items():
- for name, item in info.items():
- data.setdefault(group, {})[name] = item.getValue(if_decrypt)
-
- for name, item in self._multiple_config_index.items():
- if not data.get("SubConfigsInfo"):
- data["SubConfigsInfo"] = {}
- data["SubConfigsInfo"][name] = await item.toDict(
- if_decrypt, regenerate_uuids
- )
-
- return data
-
- def get(self, group: str, name: str) -> Any:
- """获取配置项的值"""
-
- if not self._config_item_index.get(group, {}).get(name):
- raise AttributeError(f"配置项 '{group}.{name}' 不存在")
-
- return self._config_item_index[group][name].getValue()
-
- async def set(self, group: str, name: str, value: Any):
- """
- 设置配置项的值
-
- Parameters
- ----------
- group: str
- 配置项分组名称
- name: str
- 配置项名称
- value: Any
- 配置项新值
- """
-
- if not self._config_item_index.get(group, {}).get(name):
- raise AttributeError(f"配置项 '{group}.{name}' 不存在")
-
- if self.is_locked:
- raise ValueError("配置已锁定, 无法修改")
-
- self._config_item_index[group][name].setValue(value)
-
- if self.file:
- await self.save()
-
- await asyncio.gather(*(_() for _ in self._save_methods))
-
- def bind(self, group: str, name: str, slot: Callable[[Any], Any]):
- """
- 连接槽函数到配置项修改信号
-
- Parameters
- ----------
- group: str
- 配置项分组名称
- name: str
- 配置项名称
- slot: Callable[[Any], Any]
- 槽函数,接收新值作为参数,支持同步和异步函数
- """
-
- if not self._config_item_index.get(group, {}).get(name):
- raise AttributeError(f"配置项 '{group}.{name}' 不存在")
-
- if self.is_locked:
- raise ValueError("配置已锁定, 无法修改")
-
- self._config_item_index[group][name].bind(slot)
-
- def unbind(self, group: str, name: str, slot: Callable[[Any], Any]):
- """
- 断开槽函数连接
-
- Parameters
- ----------
- group: str
- 配置项分组名称
- name: str
- 配置项名称
- slot: Callable[[Any], Any]
- 要断开的槽函数
- """
-
- if not self._config_item_index.get(group, {}).get(name):
- raise AttributeError(f"配置项 '{group}.{name}' 不存在")
-
- if self.is_locked:
- raise ValueError("配置已锁定, 无法修改")
-
- self._config_item_index[group][name].unbind(slot)
-
- async def save(self) -> None:
- """保存配置"""
-
- if not self.file:
- raise ValueError("文件路径未设置, 请先调用 `connect` 方法连接配置文件")
-
- self.file.parent.mkdir(parents=True, exist_ok=True)
- self.file.write_text(
- json.dumps(
- await self.toDict(if_decrypt=False), ensure_ascii=False, indent=4
- ),
- encoding="utf-8",
- )
-
- async def lock(self):
- """
- 锁定配置项, 锁定后无法修改配置项值
- """
-
- self.is_locked = True
-
- for group in self._config_item_index.values():
- for item in group.values():
- item.lock()
- for config in self._multiple_config_index.values():
- await config.lock()
-
- async def unlock(self):
- """
- 解锁配置项, 解锁后可以修改配置项值
- """
-
- self.is_locked = False
-
- for group in self._config_item_index.values():
- for item in group.values():
- item.unlock()
- for config in self._multiple_config_index.values():
- await config.unlock()
-
-
-T = TypeVar("T", bound="ConfigBase")
-
-
-class MultipleConfig(Generic[T]):
- """
- 多配置项管理类
-
- 这个类允许管理多个配置项实例, 可以添加、删除、修改配置项, 并将其保存到 JSON 文件中。
- 允许通过 `config[uuid]` 访问配置项, 使用 `uuid in config` 检查是否存在配置项, 使用 `len(config)` 获取配置项数量。
-
- Parameters
- ----------
- sub_config_type: List[type]
- 子配置项的类型列表, 必须是 ConfigBase 的子类
- """
-
- def __init__(self, sub_config_type: list[Type[T]]):
- if not sub_config_type:
- raise ValueError("子配置项类型列表不能为空")
-
- for config_type in sub_config_type:
- if not issubclass(config_type, ConfigBase):
- raise TypeError(
- f"配置类型 {config_type.__name__} 必须是 ConfigBase 的子类"
- )
-
- self.sub_config_type: dict[str, Type[T]] = {
- _.__name__: _ for _ in sub_config_type
- }
- self.file: Path | None = None
- self.order: list[uuid.UUID] = []
- self.data: dict[uuid.UUID, T] = {}
- self.is_locked = False
- self._save_methods: list[Callable[[], Coroutine[Any, Any, None]]] = []
-
- def __getitem__(self, key: uuid.UUID) -> T:
- """允许通过 config[uuid] 访问配置项"""
- if key not in self.data:
- raise KeyError(f"配置项 '{key}' 不存在")
- return self.data[key]
-
- def __contains__(self, key: uuid.UUID) -> bool:
- """允许使用 uuid in config 检查是否存在"""
- return key in self.data
-
- def __len__(self) -> int:
- """允许使用 len(config) 获取配置项数量"""
- return len(self.data)
-
- def __repr__(self) -> str:
- """更好的字符串表示"""
- return f"MultipleConfig(items={len(self.data)}, types={list(self.sub_config_type.keys())})"
-
- def __str__(self) -> str:
- """用户友好的字符串表示"""
- return f"MultipleConfig with {len(self.data)} items"
-
- async def connect(self, path: Path):
- """
- 将配置文件连接到指定配置文件
-
- Parameters
- ----------
- path: Path
- 配置文件路径, 必须为 JSON 文件, 如果不存在则会创建
- """
-
- if path.suffix != ".json":
- raise ValueError("配置文件必须是带有 '.json' 扩展名的 JSON 文件。")
-
- if self.is_locked:
- raise ValueError("配置已锁定, 无法修改")
-
- self.file = path
-
- if not self.file.exists():
- self.file.parent.mkdir(parents=True, exist_ok=True)
- self.file.touch()
-
- try:
- data = json.loads(self.file.read_text(encoding="utf-8"))
- except json.JSONDecodeError:
- data = {}
-
- await self.load(data)
-
- await self.add_save_method(self.save)
-
- async def add_save_method(
- self, save_method: Callable[[], Coroutine[Any, Any, None]]
- ):
- """
- 添加父配置项的保存方法
-
- Parameters
- ----------
- save_method: Callable[[], Coroutine[Any, Any, None]]
- 保存方法, 必须是一个协程函数, 无参数, 无返回值
- """
-
- if save_method != self.save:
- self._save_methods.append(save_method)
-
- for sub_config in self.data.values():
- await sub_config.add_save_method(save_method)
-
- async def load(self, data: dict):
- """
- 从字典加载配置数据
-
- 这个方法会遍历字典中的配置项, 并将其设置到对应的 ConfigBase 实例中。
- 如果字典中包含 "instances" 键, 则会加载子配置项, 这些子配置项应该是 ConfigBase 子类的实例。
- 如果字典中没有 "instances" 键, 则清空当前配置项。
-
- Parameters
- ----------
- data: dict
- 配置数据字典
- """
-
- if self.is_locked:
- raise ValueError("配置已锁定, 无法修改")
-
- self.order = []
- self.data = {}
-
- if not data.get("instances"):
- return
-
- for instance in data["instances"]:
- if not isinstance(instance, dict) or not data.get(instance.get("uid")):
- continue
-
- type_name = instance.get("type")
-
- if type_name in self.sub_config_type:
- self.order.append(uuid.UUID(instance["uid"]))
- self.data[self.order[-1]] = self.sub_config_type[type_name]()
- await self.data[self.order[-1]].load(data[instance["uid"]])
-
- if self.file:
- await self.save()
-
- await asyncio.gather(*(_() for _ in self._save_methods))
-
- async def toDict(
- self, if_decrypt: bool = True, regenerate_uuids: bool = False
- ) -> dict[str, list | dict]:
- """
- 将配置项转换为字典
-
- Arguments
- ----------
- if_decrypt: bool
- 是否解密数据, 默认为 True
- regenerate_uuids: bool
- 是否重新生成 UUID, 默认为 False
-
- Returns
- -------
- Dict[str, Union[list, dict]]
- 配置项数据字典
- """
-
- uuid_book: dict[uuid.UUID, uuid.UUID] = {
- _: uuid.uuid4() if regenerate_uuids else _ for _ in self.order
- }
-
- data: dict[str, list | dict] = {
- "instances": [
- {"uid": str(uuid_book[_]), "type": type(self.data[_]).__name__}
- for _ in self.order
- ]
- }
- for uid, config in self.items():
- data[str(uuid_book[uid])] = await config.toDict(
- if_decrypt, regenerate_uuids
- )
-
- return data
-
- async def get(self, uid: uuid.UUID) -> dict[str, list | dict]:
- """
- 获取指定 UID 的配置项
-
- Parameters
- ----------
- uid: uuid.UUID
- 要获取的配置项的唯一标识符
- Returns
- -------
- Dict[str, Union[list, dict]]
- 对应的配置项数据字典
- """
-
- if uid not in self.data:
- raise ValueError(f"配置项 '{uid}' 不存在。")
-
- data: dict[str, list | dict] = {
- "instances": [
- {"uid": str(_), "type": type(self.data[_]).__name__}
- for _ in self.order
- if _ == uid
- ]
- }
- data[str(uid)] = await self.data[uid].toDict()
-
- return data
-
- async def save(self):
- """保存配置"""
-
- if not self.file:
- raise ValueError("文件路径未设置, 请先调用 `connect` 方法连接配置文件")
-
- self.file.parent.mkdir(parents=True, exist_ok=True)
- self.file.write_text(
- json.dumps(
- await self.toDict(if_decrypt=False), ensure_ascii=False, indent=4
- ),
- encoding="utf-8",
- )
-
- async def add(self, config_type: Type[T]) -> tuple[uuid.UUID, T]:
- """
- 添加一个新的配置项
-
- Parameters
- ----------
- config_type: type
- 配置项的类型, 必须是初始化时已声明的 ConfigBase 子类
-
- Returns
- -------
- tuple[uuid.UUID, ConfigBase]
- 新创建的配置项的唯一标识符和实例
- """
-
- if config_type not in self.sub_config_type.values():
- raise ValueError(f"配置类型 {config_type.__name__} 不被允许")
-
- if self.is_locked:
- raise ValueError("配置已锁定, 无法修改")
-
- uid = uuid.uuid4()
- self.order.append(uid)
- self.data[uid] = config_type()
-
- for save_method in self._save_methods:
- await self.data[uid].add_save_method(save_method)
-
- if self.file:
- await self.data[uid].add_save_method(self.save)
- await self.save()
-
- await asyncio.gather(*(_() for _ in self._save_methods))
-
- return uid, self.data[uid]
-
- async def remove(self, uid: uuid.UUID):
- """
- 移除配置项
-
- Parameters
- ----------
- uid: uuid.UUID
- 要移除的配置项的唯一标识符
- """
-
- if self.is_locked:
- raise ValueError("配置已锁定, 无法修改")
-
- if uid not in self.data:
- raise ValueError(f"配置项 '{uid}' 不存在")
-
- if self.data[uid].is_locked:
- raise ValueError(f"配置项 '{uid}' 已锁定, 无法移除")
-
- self.data.pop(uid)
- self.order.remove(uid)
-
- if self.file:
- await self.save()
-
- await asyncio.gather(*(_() for _ in self._save_methods))
-
- async def setOrder(self, order: list[uuid.UUID]):
- """
- 设置配置项的顺序
-
- Parameters
- ----------
- order: List[uuid.UUID]
- 新的配置项顺序
- """
-
- if set(order) != set(self.data.keys()):
- raise ValueError("顺序与当前配置项不匹配")
-
- if self.is_locked:
- raise ValueError("配置已锁定, 无法修改")
-
- self.order = order
-
- if self.file:
- await self.save()
-
- await asyncio.gather(*(_() for _ in self._save_methods))
-
- async def lock(self):
- """
- 锁定配置项, 锁定后无法修改配置项值
- """
-
- self.is_locked = True
-
- for item in self.values():
- await item.lock()
-
- async def unlock(self):
- """
- 解锁配置项, 解锁后可以修改配置项值
- """
-
- self.is_locked = False
-
- for item in self.values():
- await item.unlock()
-
- def keys(self):
- """返回配置项的所有唯一标识符"""
-
- return iter(self.order)
-
- def values(self):
- """返回配置项的所有实例"""
-
- if not self.data:
- return iter(())
-
- return (self.data[_] for _ in self.order)
-
- def items(self):
- """返回配置项的所有唯一标识符和实例的元组"""
-
- return zip(self.keys(), self.values())
diff --git a/app/models/__init__.py b/app/models/__init__.py
index 5bb4192a..e25b548f 100644
--- a/app/models/__init__.py
+++ b/app/models/__init__.py
@@ -1,30 +1,105 @@
-# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
-# Copyright © 2024-2025 DLmaster361
-# Copyright © 2025 MoeSnowyFox
-# Copyright © 2025-2026 AUTO-MAS Team
+from typing import TYPE_CHECKING
-# This file is part of AUTO-MAS.
+if TYPE_CHECKING:
+ from .common import EmulatorConfig, QueueConfig, QueueItem, TimeSet, Webhook
+ from .general import GeneralConfig, GeneralUserConfig
+ from .global_config import CLASS_BOOK, GlobalConfig, ToolsConfig
+ from .maa import MaaConfig, MaaPlanConfig, MaaUserConfig
+ from .maaend import MaaEndConfig, MaaEndUserConfig
+ from .plugin import PluginInstanceConfig
+ from .src import SrcConfig, SrcUserConfig
-# AUTO-MAS is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as
-# published by the Free Software Foundation, either version 3 of
-# the License, or (at your option) any later version.
-# AUTO-MAS is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty
-# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
-# the GNU Affero General Public License for more details.
+__all__ = [
+ "EmulatorConfig",
+ "Webhook",
+ "QueueItem",
+ "TimeSet",
+ "QueueConfig",
+ "MaaPlanConfig",
+ "MaaUserConfig",
+ "MaaConfig",
+ "MaaEndUserConfig",
+ "MaaEndConfig",
+ "PluginInstanceConfig",
+ "SrcUserConfig",
+ "SrcConfig",
+ "GeneralUserConfig",
+ "GeneralConfig",
+ "ToolsConfig",
+ "GlobalConfig",
+ "CLASS_BOOK",
+]
-# You should have received a copy of the GNU Affero General Public License
-# along with AUTO-MAS. If not, see .
-# Contact: DLmaster_361@163.com
+def __getattr__(name: str):
+ if name in {"emulator", "task"}:
+ import importlib
+ return importlib.import_module(f"{__name__}.{name}")
-from .ConfigBase import *
-from .config import *
-from .schema import *
-from .emulator import *
-from .task import *
+ if name in {
+ "EmulatorConfig",
+ "QueueConfig",
+ "QueueItem",
+ "TimeSet",
+ "Webhook",
+ }:
+ from .common import EmulatorConfig, QueueConfig, QueueItem, TimeSet, Webhook
-__all__ = ["ConfigBase", "config", "schema", "emulator", "task"]
+ return {
+ "EmulatorConfig": EmulatorConfig,
+ "QueueConfig": QueueConfig,
+ "QueueItem": QueueItem,
+ "TimeSet": TimeSet,
+ "Webhook": Webhook,
+ }[name]
+
+ if name in {"GeneralConfig", "GeneralUserConfig"}:
+ from .general import GeneralConfig, GeneralUserConfig
+
+ return {
+ "GeneralConfig": GeneralConfig,
+ "GeneralUserConfig": GeneralUserConfig,
+ }[name]
+
+ if name in {"CLASS_BOOK", "GlobalConfig", "ToolsConfig"}:
+ from .global_config import CLASS_BOOK, GlobalConfig, ToolsConfig
+
+ return {
+ "CLASS_BOOK": CLASS_BOOK,
+ "GlobalConfig": GlobalConfig,
+ "ToolsConfig": ToolsConfig,
+ }[name]
+
+ if name in {"MaaConfig", "MaaPlanConfig", "MaaUserConfig"}:
+ from .maa import MaaConfig, MaaPlanConfig, MaaUserConfig
+
+ return {
+ "MaaConfig": MaaConfig,
+ "MaaPlanConfig": MaaPlanConfig,
+ "MaaUserConfig": MaaUserConfig,
+ }[name]
+
+ if name in {"MaaEndConfig", "MaaEndUserConfig"}:
+ from .maaend import MaaEndConfig, MaaEndUserConfig
+
+ return {
+ "MaaEndConfig": MaaEndConfig,
+ "MaaEndUserConfig": MaaEndUserConfig,
+ }[name]
+
+ if name == "PluginInstanceConfig":
+ from .plugin import PluginInstanceConfig
+
+ return PluginInstanceConfig
+
+ if name in {"SrcConfig", "SrcUserConfig"}:
+ from .src import SrcConfig, SrcUserConfig
+
+ return {
+ "SrcConfig": SrcConfig,
+ "SrcUserConfig": SrcUserConfig,
+ }[name]
+
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git a/app/models/common.py b/app/models/common.py
new file mode 100644
index 00000000..a09048eb
--- /dev/null
+++ b/app/models/common.py
@@ -0,0 +1,143 @@
+from __future__ import annotations
+
+import calendar
+from typing import Annotated, Any, ClassVar, Literal, cast
+
+from pydantic import AliasChoices, AliasPath, BaseModel, Field, field_validator
+
+from app.core.config.base import MultipleConfig
+from app.core.config.pydantic import PydanticConfigBase
+from app.core.config.shortcuts import config, ref, sub_configs
+from app.core.config.types import (
+ HHMMString,
+ JsonDictString,
+ JsonListString,
+ PositiveInt,
+ UrlString,
+ YmdHmString,
+)
+
+
+DAY_NAMES: tuple[str, ...] = tuple(calendar.day_name)
+EMULATOR_TYPES = Literal["general", "mumu", "ldplayer"]
+AFTER_ACCOMPLISH_OPTIONS = Literal[
+ "NoAction",
+ "Shutdown",
+ "ShutdownForce",
+ "Reboot",
+ "Hibernate",
+ "Sleep",
+ "KillSelf",
+]
+HTTP_METHOD = Literal["POST", "GET"]
+
+
+@config
+class EmulatorConfig(PydanticConfigBase):
+ """模拟器配置"""
+
+ class InfoModel(BaseModel):
+ Name: str = "新模拟器"
+ Type: EMULATOR_TYPES = Field(
+ default="general",
+ validation_alias=AliasChoices("Type", AliasPath("Data", "Type")),
+ )
+ Path: str = ""
+ BossKey: JsonListString = Field(
+ default="[ ]",
+ validation_alias=AliasChoices("BossKey", AliasPath("Data", "BossKey")),
+ )
+ MaxWaitTime: PositiveInt = Field(
+ default=60,
+ le=9999,
+ validation_alias=AliasChoices(
+ "MaxWaitTime", AliasPath("Data", "MaxWaitTime")
+ ),
+ )
+
+ Info: InfoModel = Field(default_factory=InfoModel)
+
+
+@config
+class Webhook(PydanticConfigBase):
+ """Webhook 配置"""
+
+ class InfoModel(BaseModel):
+ Name: str = "新自定义 Webhook 通知"
+ Enabled: bool = True
+
+ class DataModel(BaseModel):
+ Url: UrlString = ""
+ Template: str = ""
+ Headers: JsonDictString = "{ }"
+ Method: HTTP_METHOD = "POST"
+
+ Info: InfoModel = Field(default_factory=InfoModel)
+ Data: DataModel = Field(default_factory=DataModel)
+
+
+@config
+class QueueItem(PydanticConfigBase):
+ """队列项配置"""
+
+ related_config: ClassVar[dict[str, MultipleConfig[Any]]] = {}
+
+ class InfoModel(BaseModel):
+ ScriptId: Annotated[
+ str,
+ ref(
+ "ScriptConfig",
+ default="-",
+ allow_values=("-",),
+ on_delete="cascade",
+ ),
+ ] = "-"
+
+ Info: InfoModel = Field(default_factory=InfoModel)
+
+
+@config
+class TimeSet(PydanticConfigBase):
+ """时间设置配置"""
+
+ class InfoModel(BaseModel):
+ Enabled: bool = True
+ Days: list[str] = Field(default_factory=lambda: list(DAY_NAMES))
+ Time: HHMMString = "00:00"
+
+ @field_validator("Days", mode="before")
+ @classmethod
+ def _validate_days(cls, value: Any) -> list[str]:
+ if not isinstance(value, list):
+ return []
+ raw_days = cast(list[object], value)
+ days: list[str] = [item for item in raw_days if isinstance(item, str)]
+ return (
+ days
+ if len(days) == len(raw_days)
+ and all(item in DAY_NAMES for item in days)
+ else []
+ )
+
+ Info: InfoModel = Field(default_factory=InfoModel)
+
+
+@config
+@sub_configs(TimeSet=[TimeSet], QueueItem=[QueueItem])
+class QueueConfig(PydanticConfigBase):
+ """队列配置"""
+
+ class InfoModel(BaseModel):
+ Name: str = "新队列"
+ TimeEnabled: bool = False
+ StartUpEnabled: bool = False
+ AfterAccomplish: AFTER_ACCOMPLISH_OPTIONS = "NoAction"
+
+ class DataModel(BaseModel):
+ LastTimedStart: YmdHmString = "2000-01-01 00:00"
+
+ Info: InfoModel = Field(default_factory=InfoModel)
+ Data: DataModel = Field(default_factory=DataModel)
+
+
+__all__ = ["EmulatorConfig", "Webhook", "QueueItem", "TimeSet", "QueueConfig"]
diff --git a/app/models/config.py b/app/models/config.py
deleted file mode 100644
index 43ea9412..00000000
--- a/app/models/config.py
+++ /dev/null
@@ -1,2101 +0,0 @@
-# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
-# Copyright © 2025-2026 AUTO-MAS Team
-
-# This file is part of AUTO-MAS.
-
-# AUTO-MAS is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as
-# published by the Free Software Foundation, either version 3 of
-# the License, or (at your option) any later version.
-
-# AUTO-MAS is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty
-# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
-# the GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with AUTO-MAS. If not, see .
-
-# Contact: DLmaster_361@163.com
-
-
-import uuid
-import json
-import calendar
-import re
-from pathlib import Path
-from datetime import datetime
-from typing import Callable
-
-from app.utils.constants import (
- UTC4,
- UTC8,
- MATERIALS_MAP,
- RESOURCE_STAGE_INFO,
- MAA_STAGE_KEY,
- MAAEND_STAGE_BOOK,
- MAAEND_STAGE_WITH_AB,
- STARRAIL_STAGE_BOOK,
-)
-from .ConfigBase import (
- ConfigBase,
- MultipleConfig,
- ConfigItem,
- MultipleUIDValidator,
- BoolValidator,
- OptionsValidator,
- MultipleOptionsValidator,
- RangeValidator,
- VirtualConfigValidator,
- FileValidator,
- FolderValidator,
- EmulatorPathValidator,
- EncryptValidator,
- UUIDValidator,
- DateTimeValidator,
- JSONValidator,
- StringValidator,
- URLValidator,
- UserNameValidator,
- KeyValidator,
- ArgumentValidator,
- AdvancedArgumentValidator,
-)
-from .schema import TagItem
-
-
-class EmulatorConfig(ConfigBase):
- """模拟器配置"""
-
- def __init__(self) -> None:
-
- ## Info ------------------------------------------------------------
- ## 模拟器名称
- self.Info_Name = ConfigItem("Info", "Name", "新模拟器")
- ## 模拟器类型
- self.Info_Type = ConfigItem(
- "Info",
- "Type",
- "general",
- OptionsValidator(
- [
- "general",
- "mumu",
- "ldplayer",
- # "nox", # 以下都是骗你的, 根本没有写~~
- # "memu",
- # "blueStacks",
- ]
- ),
- legacy_group="Data",
- )
- ## 模拟器路径
- self.Info_Path = ConfigItem(
- "Info", "Path", "", EmulatorPathValidator(self.Info_Type)
- )
- ## 老板键快捷键配置
- self.Info_BossKey = ConfigItem(
- "Info", "BossKey", "[ ]", JSONValidator(list), legacy_group="Data"
- )
- ## 最大等待时间(秒)
- self.Info_MaxWaitTime = ConfigItem(
- "Info", "MaxWaitTime", 60, RangeValidator(1, 9999), legacy_group="Data"
- )
-
- super().__init__()
-
-
-class Webhook(ConfigBase):
- """Webhook 配置"""
-
- def __init__(self) -> None:
-
- ## Info ------------------------------------------------------------
- ## Webhook 名称
- self.Info_Name = ConfigItem("Info", "Name", "新自定义 Webhook 通知")
- ## 是否启用
- self.Info_Enabled = ConfigItem("Info", "Enabled", True, BoolValidator())
-
- ## Data ------------------------------------------------------------
- ## Webhook URL 地址
- self.Data_Url = ConfigItem("Data", "Url", "", URLValidator())
- ## 消息模板
- self.Data_Template = ConfigItem("Data", "Template", "")
- ## 请求头
- self.Data_Headers = ConfigItem("Data", "Headers", "{ }", JSONValidator())
- ## 请求方法
- self.Data_Method = ConfigItem(
- "Data", "Method", "POST", OptionsValidator(["POST", "GET"])
- )
-
- super().__init__()
-
-
-class QueueItem(ConfigBase):
- """队列项配置"""
-
- related_config: dict[str, MultipleConfig] = {}
-
- def __init__(self) -> None:
-
- ## Info ------------------------------------------------------------
- ## 脚本 ID
- self.Info_ScriptId = ConfigItem(
- "Info",
- "ScriptId",
- "-",
- MultipleUIDValidator("-", self.related_config, "ScriptConfig"),
- )
-
- super().__init__()
-
-
-class TimeSet(ConfigBase):
- """时间设置配置"""
-
- def __init__(self) -> None:
-
- ## Info ------------------------------------------------------------
- ## 是否启用
- self.Info_Enabled = ConfigItem("Info", "Enabled", True, BoolValidator())
- ## 执行周期
- self.Info_Days = ConfigItem(
- "Info",
- "Days",
- list(calendar.day_name),
- MultipleOptionsValidator(list(calendar.day_name)),
- )
- ## 执行时间
- self.Info_Time = ConfigItem("Info", "Time", "00:00", DateTimeValidator("%H:%M"))
-
- super().__init__()
-
-
-class QueueConfig(ConfigBase):
- """队列配置"""
-
- def __init__(self) -> None:
-
- ## Info ------------------------------------------------------------
- ## 队列名称
- self.Info_Name = ConfigItem("Info", "Name", "新队列")
- ## 是否启用定时启动
- self.Info_TimeEnabled = ConfigItem(
- "Info", "TimeEnabled", False, BoolValidator()
- )
- ## 是否在启动时自动运行
- self.Info_StartUpEnabled = ConfigItem(
- "Info", "StartUpEnabled", False, BoolValidator()
- )
- ## 完成后操作
- self.Info_AfterAccomplish = ConfigItem(
- "Info",
- "AfterAccomplish",
- "NoAction",
- OptionsValidator(
- [
- "NoAction",
- "Shutdown",
- "ShutdownForce",
- "Reboot",
- "Hibernate",
- "Sleep",
- "KillSelf",
- ]
- ),
- )
-
- ## Data ------------------------------------------------------------
- ## 上次定时启动时间
- self.Data_LastTimedStart = ConfigItem(
- "Data",
- "LastTimedStart",
- "2000-01-01 00:00",
- DateTimeValidator("%Y-%m-%d %H:%M"),
- )
-
- self.TimeSet = MultipleConfig([TimeSet])
- self.QueueItem = MultipleConfig([QueueItem])
-
- super().__init__()
-
-
-class MaaUserConfig(ConfigBase):
- """MAA用户配置"""
-
- related_config: dict[str, MultipleConfig] = {}
-
- def __init__(self) -> None:
-
- ## Info ------------------------------------------------------------
- ## 用户名称
- self.Info_Name = ConfigItem("Info", "Name", "新用户", UserNameValidator())
- ## 用户 ID
- self.Info_Id = ConfigItem("Info", "Id", "")
- ## 密码
- self.Info_Password = ConfigItem("Info", "Password", "", EncryptValidator())
- ## 脚本模式
- self.Info_Mode = ConfigItem(
- "Info", "Mode", "简洁", OptionsValidator(["简洁", "详细"])
- )
- ## 关卡模式
- self.Info_StageMode = ConfigItem(
- "Info",
- "StageMode",
- "Fixed",
- MultipleUIDValidator("Fixed", self.related_config, "PlanConfig"),
- )
- ## 游戏服务器
- self.Info_Server = ConfigItem(
- "Info",
- "Server",
- "Official",
- OptionsValidator(
- ["Official", "Bilibili", "YoStarEN", "YoStarJP", "YoStarKR", "txwy"]
- ),
- )
- ## 是否启用
- self.Info_Status = ConfigItem("Info", "Status", True, BoolValidator())
- ## 剩余天数
- self.Info_RemainedDay = ConfigItem(
- "Info", "RemainedDay", -1, RangeValidator(-1, 9999)
- )
- ## 剿灭模式
- self.Info_Annihilation = ConfigItem(
- "Info",
- "Annihilation",
- "Annihilation",
- OptionsValidator(
- [
- "Close",
- "Annihilation",
- "Chernobog@Annihilation",
- "LungmenOutskirts@Annihilation",
- "LungmenDowntown@Annihilation",
- ]
- ),
- )
- ## 基建模式
- self.Info_InfrastMode = ConfigItem(
- "Info",
- "InfrastMode",
- "Normal",
- OptionsValidator(["Normal", "Rotation", "Custom"]),
- )
- ## 基建配置名称
- self.Info_InfrastName = ConfigItem(
- "Info", "InfrastName", "-", VirtualConfigValidator(self.getInfrastName)
- )
- ## 基建配置索引
- self.Info_InfrastIndex = ConfigItem(
- "Info", "InfrastIndex", "-", VirtualConfigValidator(self.getInfrastIndex)
- )
- ## 备注
- self.Info_Notes = ConfigItem("Info", "Notes", "无")
- ## 理智药数量
- self.Info_MedicineNumb = ConfigItem(
- "Info", "MedicineNumb", 0, RangeValidator(0, 9999)
- )
- ## 连战次数
- self.Info_SeriesNumb = ConfigItem(
- "Info",
- "SeriesNumb",
- "0",
- OptionsValidator(["0", "6", "5", "4", "3", "2", "1", "-1"]),
- )
- ## 关卡
- self.Info_Stage = ConfigItem("Info", "Stage", "-")
- ## 关卡 1
- self.Info_Stage_1 = ConfigItem("Info", "Stage_1", "-")
- ## 关卡 2
- self.Info_Stage_2 = ConfigItem("Info", "Stage_2", "-")
- ## 关卡 3
- self.Info_Stage_3 = ConfigItem("Info", "Stage_3", "-")
- ## 备用关卡
- self.Info_Stage_Remain = ConfigItem("Info", "Stage_Remain", "-")
- ## 是否启用森空岛签到
- self.Info_IfSkland = ConfigItem("Info", "IfSkland", False, BoolValidator())
- ## 森空岛 Token
- self.Info_SklandToken = ConfigItem(
- "Info", "SklandToken", "", EncryptValidator()
- )
- ## 用户标签信息(虚拟字段,供前端显示)
- self.Info_Tag = ConfigItem(
- "Info", "Tag", "[ ]", VirtualConfigValidator(self.getTags)
- )
-
- ## Data ------------------------------------------------------------
- ## 上次代理日期
- self.Data_LastProxyDate = ConfigItem(
- "Data", "LastProxyDate", "2000-01-01", DateTimeValidator("%Y-%m-%d")
- )
- ## 上次森空岛签到日期
- self.Data_LastSklandDate = ConfigItem(
- "Data", "LastSklandDate", "2000-01-01", DateTimeValidator("%Y-%m-%d")
- )
- ## 代理次数
- self.Data_ProxyTimes = ConfigItem(
- "Data", "ProxyTimes", 0, RangeValidator(0, 9999)
- )
- ## 是否通过检查
- self.Data_IfPassCheck = ConfigItem("Data", "IfPassCheck", True, BoolValidator())
- ## 自定义基建配置
- self.Data_CustomInfrast = ConfigItem(
- "Data", "CustomInfrast", "{ }", JSONValidator()
- )
- ## 基建配置索引数据
- self.Data_InfrastIndex = ConfigItem(
- "Data", "InfrastIndex", "0", legacy_group="Info"
- )
-
- ## Task ------------------------------------------------------------
- ## 是否自动唤醒
- self.Task_IfStartUp = ConfigItem("Task", "IfStartUp", True, BoolValidator())
- ## 是否理智作战
- self.Task_IfFight = ConfigItem("Task", "IfFight", True, BoolValidator())
- ## 是否基建换班
- self.Task_IfInfrast = ConfigItem("Task", "IfInfrast", True, BoolValidator())
- ## 是否公开招募
- self.Task_IfRecruit = ConfigItem("Task", "IfRecruit", True, BoolValidator())
- ## 是否信用收支
- self.Task_IfMall = ConfigItem("Task", "IfMall", True, BoolValidator())
- ## 是否领取奖励
- self.Task_IfAward = ConfigItem("Task", "IfAward", True, BoolValidator())
- ## 是否自动肉鸽
- self.Task_IfRoguelike = ConfigItem(
- "Task", "IfRoguelike", False, BoolValidator()
- )
- ## 是否生息演算
- self.Task_IfReclamation = ConfigItem(
- "Task", "IfReclamation", False, BoolValidator()
- )
-
- ## Notify ----------------------------------------------------------
- ## 是否启用通知
- self.Notify_Enabled = ConfigItem("Notify", "Enabled", False, BoolValidator())
- ## 是否发送统计信息
- self.Notify_IfSendStatistic = ConfigItem(
- "Notify", "IfSendStatistic", False, BoolValidator()
- )
- ## 是否发送六星通知
- self.Notify_IfSendSixStar = ConfigItem(
- "Notify", "IfSendSixStar", False, BoolValidator()
- )
- ## 是否发送邮件
- self.Notify_IfSendMail = ConfigItem(
- "Notify", "IfSendMail", False, BoolValidator()
- )
- ## 收件地址
- self.Notify_ToAddress = ConfigItem("Notify", "ToAddress", "")
- ## 是否启用 Server 酱
- self.Notify_IfServerChan = ConfigItem(
- "Notify", "IfServerChan", False, BoolValidator()
- )
- ## Server 酱密钥
- self.Notify_ServerChanKey = ConfigItem("Notify", "ServerChanKey", "")
- ## 自定义 Webhook 列表
- self.Notify_CustomWebhooks = MultipleConfig([Webhook])
-
- super().__init__()
-
- def getInfrastName(self) -> str:
-
- if self.get("Info", "InfrastMode") != "Custom":
- return "未使用自定义基建模式"
-
- infrast_data = json.loads(self.get("Data", "CustomInfrast"))
- if (
- infrast_data.get("title", "文件标题") != "文件标题"
- and infrast_data.get("description", "文件描述") != "文件描述"
- ):
- return f"{infrast_data['title']} - {infrast_data['description']}"
- elif infrast_data.get("title", "文件标题") != "文件标题":
- return str(infrast_data["title"])
- elif infrast_data.get("id", None):
- return str(infrast_data["id"])
- else:
- return "未命名自定义基建"
-
- def getInfrastIndex(self) -> str:
-
- if self.get("Info", "InfrastMode") != "Custom":
- return "-1"
-
- infrast_data = json.loads(self.get("Data", "CustomInfrast"))
-
- if len(infrast_data.get("plans", [])) == 0:
- return "-1"
-
- for i, plan in enumerate(infrast_data.get("plans", [])):
-
- for t in plan.get("period", []):
- if (
- datetime.strptime(t[0], "%H:%M").time()
- <= datetime.now().time()
- <= datetime.strptime(t[1], "%H:%M").time()
- ):
- return str(i)
-
- else:
- return self.get("Data", "InfrastIndex") or "0"
-
- def getTags(self) -> str:
- """生成用户标签列表,返回JSON字符串格式的TagItem列表"""
- tags = []
-
- # 人工排查状态标签
- if not self.get("Data", "IfPassCheck"):
- tags.append({"text": "人工排查未通过", "color": "red"})
-
- # 日常代理标签(使用东4区时间)
- if (
- datetime.strptime(self.get("Data", "LastProxyDate"), "%Y-%m-%d").date()
- == datetime.now(tz=UTC4).date()
- ):
- tags.append(
- {
- "text": f"日常:已代理{self.get('Data', 'ProxyTimes')}次",
- "color": "green",
- }
- )
- else:
- tags.append({"text": "日常:未代理", "color": "orange"})
-
- # 森空岛签到标签(使用东8区时间)
- if self.get("Info", "IfSkland"):
- if (
- datetime.strptime(self.get("Data", "LastSklandDate"), "%Y-%m-%d").date()
- == datetime.now(tz=UTC8).date()
- ):
- tags.append({"text": "森空岛:已签到", "color": "green"})
- else:
- tags.append({"text": "森空岛:未签到", "color": "orange"})
- else:
- tags.append({"text": "森空岛:禁用", "color": "red"})
-
- # 剩余天数标签
- remained_day = self.get("Info", "RemainedDay")
- if remained_day == -1:
- tag_color = "gold"
- elif remained_day == 0:
- tag_color = "red"
- elif remained_day <= 3:
- tag_color = "orange"
- elif remained_day <= 7:
- tag_color = "yellow"
- elif remained_day <= 30:
- tag_color = "blue"
- else:
- tag_color = "green"
- tags.append(
- {
- "text": (
- f"剩余天数:{remained_day}天"
- if remained_day >= 0
- else "剩余天数:无期限"
- ),
- "color": tag_color,
- }
- )
-
- # 基建模式标签
- infrast_mode = self.get("Info", "InfrastMode")
- if self.get("Task", "IfInfrast"):
- if infrast_mode == "Normal":
- infrast_text = "基建:常规"
- elif infrast_mode == "Rotation":
- infrast_text = "基建:轮换"
- elif infrast_mode == "Custom":
- infrast_text = f"基建:{self.getInfrastName() if len(self.getInfrastName()) < 10 else self.getInfrastName()[:10] + '...'}"
- else:
- infrast_text = "基建:开启"
- tags.append({"text": infrast_text, "color": "purple"})
- else:
- tags.append({"text": "基建:关闭", "color": "red"})
-
- # 关卡信息标签
- if self.get("Info", "StageMode") == "Fixed":
- plan_data = {
- stage_key: self.get_stage_zh(self.get("Info", stage_key))
- for stage_key in MAA_STAGE_KEY[2:]
- }
- tag_color = "blue"
- else:
- plan = self.related_config["PlanConfig"][
- uuid.UUID(self.get("Info", "StageMode"))
- ]
- if isinstance(plan, MaaPlanConfig):
- plan_data = {
- stage_key: self.get_stage_zh(
- plan.get_current_info(stage_key).getValue()
- )
- for stage_key in MAA_STAGE_KEY[2:]
- }
- tag_color = "green"
- # 主关卡
- tags.append({"text": f"主关卡:{plan_data['Stage']}", "color": tag_color})
- # 备选关卡(合并显示)
- backup_stages = [
- plan_data[f"Stage_{i}"]
- for i in range(1, 4)
- if plan_data[f"Stage_{i}"] != "禁用"
- ]
- if backup_stages:
- tags.append(
- {"text": f"备选:{', '.join(backup_stages)}", "color": tag_color}
- )
- # 剩余关卡
- if plan_data["Stage_Remain"] != "禁用":
- tags.append(
- {"text": f"剩余:{plan_data['Stage_Remain']}", "color": tag_color}
- )
-
- # 备注标签
- notes = self.get("Info", "Notes")
- tags.append(
- {
- "text": (
- f"备注:{notes}" if len(notes) <= 20 else f"备注:{notes[:20]}..."
- ),
- "color": "pink",
- }
- )
-
- return json.dumps(tags, ensure_ascii=False)
-
- @staticmethod
- def get_stage_zh(stage: str) -> str:
-
- for stage_info in RESOURCE_STAGE_INFO:
- if stage_info.get("value") == stage:
- return (
- stage_info.get("text", stage)
- .replace("经验-6/5", "经验")
- .replace("龙门币-6/5", "龙门币")
- .replace("红票-5", "红票")
- .replace("技能-5", "技能")
- .replace("碳-5", "碳")
- )
- else:
- return stage
-
-
-class MaaConfig(ConfigBase):
- """MAA配置"""
-
- related_config: dict[str, MultipleConfig] = {}
-
- def __init__(self) -> None:
-
- ## Info ------------------------------------------------------------
- ## MAA 脚本名称
- self.Info_Name = ConfigItem("Info", "Name", "新 MAA 脚本")
- ## MAA 路径
- self.Info_Path = ConfigItem("Info", "Path", str(Path.cwd()), FolderValidator())
-
- ## Emulator --------------------------------------------------------
- ## 模拟器 ID
- self.Emulator_Id = ConfigItem(
- "Emulator",
- "Id",
- "-",
- MultipleUIDValidator("-", self.related_config, "EmulatorConfig"),
- )
- ## 模拟器索引
- self.Emulator_Index = ConfigItem("Emulator", "Index", "-")
-
- ## Run -------------------------------------------------------------
- ## 任务切换方式
- self.Run_TaskTransitionMethod = ConfigItem(
- "Run",
- "TaskTransitionMethod",
- "ExitEmulator",
- OptionsValidator(["NoAction", "ExitGame", "ExitEmulator"]),
- )
- ## 代理次数限制
- self.Run_ProxyTimesLimit = ConfigItem(
- "Run", "ProxyTimesLimit", 0, RangeValidator(0, 9999)
- )
- ## 运行次数限制
- self.Run_RunTimesLimit = ConfigItem(
- "Run", "RunTimesLimit", 3, RangeValidator(1, 9999)
- )
- ## 剿灭时间限制(分钟)
- self.Run_AnnihilationTimeLimit = ConfigItem(
- "Run", "AnnihilationTimeLimit", 40, RangeValidator(1, 9999)
- )
- ## 日常时间限制(分钟)
- self.Run_RoutineTimeLimit = ConfigItem(
- "Run", "RoutineTimeLimit", 10, RangeValidator(1, 9999)
- )
- ## 剿灭避免无代理卡浪费理智
- self.Run_AnnihilationAvoidWaste = ConfigItem(
- "Run", "AnnihilationAvoidWaste", False, BoolValidator()
- )
-
- self.UserData = MultipleConfig([MaaUserConfig])
-
- super().__init__()
-
-
-class MaaEndUserConfig(ConfigBase):
- """MaaEnd用户配置"""
-
- def __init__(self) -> None:
-
- ## Info ------------------------------------------------------------
- ## 用户名称
- self.Info_Name = ConfigItem("Info", "Name", "新用户", UserNameValidator())
- ## 是否启用
- self.Info_Status = ConfigItem("Info", "Status", True, BoolValidator())
- ## 用户ID
- self.Info_Id = ConfigItem("Info", "Id", "")
- ## 密码
- self.Info_Password = ConfigItem("Info", "Password", "", EncryptValidator())
- ## 配置模式
- self.Info_Mode = ConfigItem(
- "Info", "Mode", "简洁", OptionsValidator(["简洁", "详细"])
- )
- ## 资源名称
- self.Info_Resource = ConfigItem(
- "Info", "Resource", "官服", OptionsValidator(["官服"])
- )
- ## 剩余天数
- self.Info_RemainedDay = ConfigItem(
- "Info", "RemainedDay", -1, RangeValidator(-1, 9999)
- )
- ## 备注
- self.Info_Notes = ConfigItem("Info", "Notes", "无")
- ## 是否启用森空岛签到
- self.Info_IfSkland = ConfigItem("Info", "IfSkland", False, BoolValidator())
- ## 森空岛 Token
- self.Info_SklandToken = ConfigItem(
- "Info", "SklandToken", "", EncryptValidator()
- )
- ## 用户标签信息
- self.Info_Tag = ConfigItem(
- "Info", "Tag", "[ ]", VirtualConfigValidator(self.getTags)
- )
-
- ## Task ------------------------------------------------------------
- ## 协议空间选项
- self.Task_ProtocolSpaceTab = ConfigItem(
- "Task",
- "ProtocolSpaceTab",
- "OperatorProgression",
- OptionsValidator(
- ["OperatorProgression", "WeaponProgression", "CrisisDrills"]
- ),
- )
- self.Task_OperatorProgression = ConfigItem(
- "Task",
- "OperatorProgression",
- "OperatorEXP",
- OptionsValidator(["OperatorEXP", "Promotions", "T-Creds", "SkillUp"]),
- )
- self.Task_WeaponProgression = ConfigItem(
- "Task",
- "WeaponProgression",
- "WeaponEXP",
- OptionsValidator(["WeaponEXP", "WeaponTune"]),
- )
- self.Task_CrisisDrills = ConfigItem(
- "Task",
- "CrisisDrills",
- "AdvancedProgression1",
- OptionsValidator(
- [
- "AdvancedProgression1",
- "AdvancedProgression2",
- "AdvancedProgression3",
- "AdvancedProgression4",
- "AdvancedProgression5",
- ]
- ),
- )
- self.Task_RewardsSetOption = ConfigItem(
- "Task",
- "RewardsSetOption",
- "RewardsSetA",
- OptionsValidator(["RewardsSetA", "RewardsSetB"]),
- )
-
- ## Data ------------------------------------------------------------
- ## 上次代理日期
- self.Data_LastProxyDate = ConfigItem(
- "Data", "LastProxyDate", "2000-01-01", DateTimeValidator("%Y-%m-%d")
- )
- ## 代理次数
- self.Data_ProxyTimes = ConfigItem(
- "Data", "ProxyTimes", 0, RangeValidator(0, 9999)
- )
- ## 上次代理状态
- self.Data_LastProxyStatus = ConfigItem(
- "Data",
- "LastProxyStatus",
- "未知",
- OptionsValidator(["未知", "成功", "失败"]),
- )
- ## 上次森空岛签到日期
- self.Data_LastSklandDate = ConfigItem(
- "Data", "LastSklandDate", "2000-01-01", DateTimeValidator("%Y-%m-%d")
- )
- ## 是否通过检查
- self.Data_IfPassCheck = ConfigItem("Data", "IfPassCheck", True, BoolValidator())
-
- ## Notify ----------------------------------------------------------
- ## 是否启用通知
- self.Notify_Enabled = ConfigItem("Notify", "Enabled", False, BoolValidator())
- ## 是否发送统计信息
- self.Notify_IfSendStatistic = ConfigItem(
- "Notify", "IfSendStatistic", False, BoolValidator()
- )
- ## 是否发送邮件
- self.Notify_IfSendMail = ConfigItem(
- "Notify", "IfSendMail", False, BoolValidator()
- )
- ## 收件地址
- self.Notify_ToAddress = ConfigItem("Notify", "ToAddress", "")
- ## 是否启用 Server 酱
- self.Notify_IfServerChan = ConfigItem(
- "Notify", "IfServerChan", False, BoolValidator()
- )
- ## Server 酱密钥
- self.Notify_ServerChanKey = ConfigItem("Notify", "ServerChanKey", "")
- ## 自定义 Webhook 列表
- self.Notify_CustomWebhooks = MultipleConfig([Webhook])
-
- super().__init__()
-
- def getTags(self) -> str:
- """生成用户标签列表,返回JSON字符串格式的TagItem列表"""
- tags = []
- # 人工排查状态标签
- if not self.get("Data", "IfPassCheck"):
- tags.append({"text": "人工排查未通过", "color": "red"})
-
- # 上次代理标签
- tags.append(
- {
- "text": f"上次:{self.get('Data', 'LastProxyStatus')}",
- "color": (
- "red" if self.get("Data", "LastProxyStatus") == "失败" else "green"
- ),
- }
- )
-
- # 日常代理标签(使用东4区时间)
- if (
- datetime.strptime(self.get("Data", "LastProxyDate"), "%Y-%m-%d").date()
- == datetime.now(tz=UTC4).date()
- ):
- tags.append(
- {
- "text": f"日常:已代理{self.get('Data', 'ProxyTimes')}次",
- "color": "green",
- }
- )
- else:
- tags.append({"text": "日常:未代理", "color": "orange"})
-
- # 森空岛签到标签(使用东8区时间)
- if self.get("Info", "IfSkland"):
- if (
- datetime.strptime(self.get("Data", "LastSklandDate"), "%Y-%m-%d").date()
- == datetime.now(tz=UTC8).date()
- ):
- tags.append({"text": "森空岛:已签到", "color": "green"})
- else:
- tags.append({"text": "森空岛:未签到", "color": "orange"})
- else:
- tags.append({"text": "森空岛:禁用", "color": "red"})
-
- # 剩余天数标签
- remained_day = self.get("Info", "RemainedDay")
- if remained_day == -1:
- tag_color = "gold"
- elif remained_day == 0:
- tag_color = "red"
- elif remained_day <= 3:
- tag_color = "orange"
- elif remained_day <= 7:
- tag_color = "yellow"
- elif remained_day <= 30:
- tag_color = "blue"
- else:
- tag_color = "green"
- tags.append(
- {
- "text": (
- f"剩余天数:{remained_day}天"
- if remained_day >= 0
- else "剩余天数:无期限"
- ),
- "color": tag_color,
- }
- )
-
- # 关卡信息标签
- stage = self.get("Task", self.get("Task", "ProtocolSpaceTab"))
- stage_ab = (
- f" - {self.get("Task", "RewardsSetOption")[-1]}"
- if stage in MAAEND_STAGE_WITH_AB
- else ""
- )
- tags.append({"text": MAAEND_STAGE_BOOK[stage] + stage_ab, "color": "blue"})
-
- # 备注标签
- notes = self.get("Info", "Notes")
- tags.append(
- {
- "text": (
- f"备注:{notes}" if len(notes) <= 20 else f"备注:{notes[:20]}..."
- ),
- "color": "pink",
- }
- )
-
- return json.dumps(tags, ensure_ascii=False)
-
-
-class MaaEndConfig(ConfigBase):
- """MaaEnd配置"""
-
- related_config: dict[str, MultipleConfig] = {}
-
- def __init__(self) -> None:
-
- ## Info ------------------------------------------------------------
- ## MaaEnd 脚本名称
- self.Info_Name = ConfigItem("Info", "Name", "新 MaaEnd 脚本")
- ## MaaEnd 路径
- self.Info_Path = ConfigItem("Info", "Path", str(Path.cwd()), FolderValidator())
-
- ## Run -------------------------------------------------------------
- ## 运行超时阈值
- self.Run_RunTimeLimit = ConfigItem(
- "Run", "RunTimeLimit", 10, RangeValidator(1, 9999)
- )
- ## 每日代理次数限制
- self.Run_ProxyTimesLimit = ConfigItem(
- "Run", "ProxyTimesLimit", 0, RangeValidator(0, 9999)
- )
- ## 运行次数限制
- self.Run_RunTimesLimit = ConfigItem(
- "Run", "RunTimesLimit", 3, RangeValidator(1, 9999)
- )
-
- ## Game ------------------------------------------------------------
- ## 控制器类型
- self.Game_ControllerType = ConfigItem(
- "Game",
- "ControllerType",
- "Win32-Window",
- OptionsValidator(
- [
- "Win32-Window",
- "Win32-Front",
- "Win32-Window-Background",
- "ADB",
- ]
- ),
- )
- ## 终末地游戏路径
- self.Game_Path = ConfigItem("Game", "Path", str(Path.cwd()), FileValidator())
- ## 终末地游戏启动参数
- self.Game_Arguments = ConfigItem("Game", "Arguments", "", ArgumentValidator())
- ## 等待时间(秒)
- self.Game_WaitTime = ConfigItem("Game", "WaitTime", 0, RangeValidator(0, 9999))
- ## 模拟器 ID
- self.Game_EmulatorId = ConfigItem(
- "Game",
- "EmulatorId",
- "-",
- MultipleUIDValidator("-", self.related_config, "EmulatorConfig"),
- )
- ## 模拟器索引
- self.Game_EmulatorIndex = ConfigItem("Game", "EmulatorIndex", "-")
- ## 结束后是否关闭游戏
- self.Game_CloseOnFinish = ConfigItem(
- "Game", "CloseOnFinish", True, BoolValidator()
- )
-
- self.UserData = MultipleConfig([MaaEndUserConfig])
-
- super().__init__()
-
-
-class SrcUserConfig(ConfigBase):
- """SRC用户配置"""
-
- related_config: dict[str, MultipleConfig] = {}
-
- def __init__(self) -> None:
-
- ## Info ------------------------------------------------------------
- ## 用户名称
- self.Info_Name = ConfigItem("Info", "Name", "新用户", UserNameValidator())
- ## 是否启用
- self.Info_Status = ConfigItem("Info", "Status", True, BoolValidator())
- ## 用户 ID
- self.Info_Id = ConfigItem("Info", "Id", "")
- ## 密码
- self.Info_Password = ConfigItem("Info", "Password", "", EncryptValidator())
- ## 脚本模式
- self.Info_Mode = ConfigItem(
- "Info", "Mode", "简洁", OptionsValidator(["简洁", "详细"])
- )
- ## 游戏服务器
- self.Info_Server = ConfigItem(
- "Info",
- "Server",
- "CN-Official",
- OptionsValidator(
- [
- "CN-Official",
- "CN-Bilibili",
- "VN-Official",
- "OVERSEA-America",
- "OVERSEA-Asia",
- "OVERSEA-Europe",
- "OVERSEA-TWHKMO",
- ]
- ),
- )
- ## 剩余天数
- self.Info_RemainedDay = ConfigItem(
- "Info", "RemainedDay", -1, RangeValidator(-1, 9999)
- )
- ## 备注
- self.Info_Notes = ConfigItem("Info", "Notes", "无")
- ## 用户标签信息
- self.Info_Tag = ConfigItem(
- "Info", "Tag", "[ ]", VirtualConfigValidator(self.getTags)
- )
-
- ## 关卡配置----------------------------------------------------------
- ## 关卡通道
- self.Stage_Channel = ConfigItem(
- "Stage",
- "Channel",
- "Relic",
- OptionsValidator(["Relic", "Materials", "Ornament"]),
- )
- ## 遗器关卡
- self.Stage_Relic = ConfigItem(
- "Stage",
- "Relic",
- "-",
- OptionsValidator(
- [
- "-",
- "Cavern_of_Corrosion_Path_of_Possession",
- "Cavern_of_Corrosion_Path_of_Hidden_Salvation",
- "Cavern_of_Corrosion_Path_of_Thundersurge",
- "Cavern_of_Corrosion_Path_of_Aria",
- "Cavern_of_Corrosion_Path_of_Uncertainty",
- "Cavern_of_Corrosion_Path_of_Cavalier",
- "Cavern_of_Corrosion_Path_of_Dreamdive"
- "Cavern_of_Corrosion_Path_of_Darkness",
- "Cavern_of_Corrosion_Path_of_Elixir_Seekers",
- "Cavern_of_Corrosion_Path_of_Conflagration",
- "Cavern_of_Corrosion_Path_of_Holy_Hymn",
- "Cavern_of_Corrosion_Path_of_Providence",
- "Cavern_of_Corrosion_Path_of_Drifting",
- "Cavern_of_Corrosion_Path_of_Jabbing_Punch",
- "Cavern_of_Corrosion_Path_of_Gelid_Wind",
- ]
- ),
- )
- ## 材料关卡
- self.Stage_Materials = ConfigItem(
- "Stage",
- "Materials",
- "-",
- OptionsValidator(
- [
- "-",
- "Calyx_Golden_Memories_Planarcadia",
- "Calyx_Golden_Aether_Planarcadia",
- "Calyx_Golden_Treasures_Planarcadia",
- "Calyx_Golden_Memories_Amphoreus",
- "Calyx_Golden_Aether_Amphoreus",
- "Calyx_Golden_Treasures_Amphoreus",
- "Calyx_Golden_Memories_Penacony",
- "Calyx_Golden_Aether_Penacony",
- "Calyx_Golden_Treasures_Penacony",
- "Calyx_Golden_Memories_The_Xianzhou_Luofu",
- "Calyx_Golden_Aether_The_Xianzhou_Luofu",
- "Calyx_Golden_Treasures_The_Xianzhou_Luofu",
- "Calyx_Golden_Memories_Jarilo_VI",
- "Calyx_Golden_Aether_Jarilo_VI",
- "Calyx_Golden_Treasures_Jarilo_VI",
- "Calyx_Crimson_Destruction_Herta_StorageZone",
- "Calyx_Crimson_Destruction_Luofu_ScalegorgeWaterscape",
- "Calyx_Crimson_Preservation_Herta_SupplyZone",
- "Calyx_Crimson_Preservation_Penacony_ClockStudiosThemePark",
- "Calyx_Crimson_The_Hunt_Jarilo_OutlyingSnowPlains",
- "Calyx_Crimson_The_Hunt_Penacony_SoulGladScorchsandAuditionVenue",
- "Calyx_Crimson_The_Hunt_Amphoreus_MemortisShoreRuinsofTime",
- "Calyx_Crimson_Abundance_Jarilo_BackwaterPass",
- "Calyx_Crimson_Abundance_Luofu_FyxestrollGarden",
- "Calyx_Crimson_Erudition_Jarilo_RivetTown",
- "Calyx_Crimson_Erudition_Penacony_PenaconyGrandTheater",
- "Calyx_Crimson_Harmony_Jarilo_RobotSettlement",
- "Calyx_Crimson_Harmony_Penacony_TheReverieDreamscape",
- "Calyx_Crimson_Nihility_Jarilo_GreatMine",
- "Calyx_Crimson_Nihility_Luofu_AlchemyCommission",
- "Calyx_Crimson_Remembrance_Amphoreus_StrifeRuinsCastrumKremnos",
- "Calyx_Crimson_Elation_Planarcadia_WorldEndTavern",
- "Stagnant_Shadow_Quanta",
- "Stagnant_Shadow_Gust",
- "Stagnant_Shadow_Fulmination",
- "Stagnant_Shadow_Blaze",
- "Stagnant_Shadow_Spike",
- "Stagnant_Shadow_Rime",
- "Stagnant_Shadow_Mirage",
- "Stagnant_Shadow_Icicle",
- "Stagnant_Shadow_Doom",
- "Stagnant_Shadow_Puppetry",
- "Stagnant_Shadow_Abomination",
- "Stagnant_Shadow_Scorch",
- "Stagnant_Shadow_Celestial",
- "Stagnant_Shadow_Perdition",
- "Stagnant_Shadow_Nectar",
- "Stagnant_Shadow_Roast",
- "Stagnant_Shadow_Ire",
- "Stagnant_Shadow_Duty",
- "Stagnant_Shadow_Timbre",
- "Stagnant_Shadow_Mechwolf",
- "Stagnant_Shadow_Gloam",
- "Stagnant_Shadow_Sloggyre",
- "Stagnant_Shadow_Gelidmoon",
- "Stagnant_Shadow_Deepsheaf",
- "Stagnant_Shadow_Cinders",
- "Stagnant_Shadow_Sirens",
- "Stagnant_Shadow_Ashes",
- "Stagnant_Shadow_Soundburst",
- ]
- ),
- )
- ## 饰品关卡
- self.Stage_Ornament = ConfigItem(
- "Stage",
- "Ornament",
- "-",
- OptionsValidator(
- [
- "-",
- "Divergent_Universe_Within_the_West_Wind",
- "Divergent_Universe_Moonlit_Blood",
- "Divergent_Universe_Unceasing_Strife",
- "Divergent_Universe_Famished_Worker",
- "Divergent_Universe_Eternal_Comedy",
- "Divergent_Universe_To_Sweet_Dreams",
- "Divergent_Universe_Pouring_Blades",
- "Divergent_Universe_Fruit_of_Evil",
- "Divergent_Universe_Permafrost",
- "Divergent_Universe_Gentle_Words",
- "Divergent_Universe_Smelted_Heart",
- "Divergent_Universe_Untoppled_Walls",
- ]
- ),
- )
- ## 使用储备开拓力
- self.Stage_ExtractReservedTrailblazePower = ConfigItem(
- "Stage", "ExtractReservedTrailblazePower", False, BoolValidator()
- )
- ## 使用燃料
- self.Stage_UseFuel = ConfigItem("Stage", "UseFuel", False, BoolValidator())
- ## 保留的燃料数量
- self.Stage_FuelReserve = ConfigItem(
- "Stage", "FuelReserve", 5, RangeValidator(0, 9999)
- )
- ## 历战余响关卡
- self.Stage_EchoOfWar = ConfigItem(
- "Stage",
- "EchoOfWar",
- "-",
- OptionsValidator(
- [
- "-",
- "Echo_of_War_Rusted_Crypt_of_the_Iron_Carcass",
- "Echo_of_War_Glance_of_Twilight",
- "Echo_of_War_Inner_Beast_Battlefield",
- "Echo_of_War_Salutations_of_Ashen_Dreams",
- "Echo_of_War_Borehole_Planet_Past_Nightmares",
- "Echo_of_War_Divine_Seed",
- "Echo_of_War_End_of_the_Eternal_Freeze",
- "Echo_of_War_Destruction_Beginning",
- ]
- ),
- )
- ## 模拟宇宙关卡
- self.Stage_SimulatedUniverseWorld = ConfigItem(
- "Stage",
- "SimulatedUniverseWorld",
- "-",
- OptionsValidator(
- [
- "-",
- "Simulated_Universe_World_3",
- "Simulated_Universe_World_4",
- "Simulated_Universe_World_5",
- "Simulated_Universe_World_6",
- "Simulated_Universe_World_8",
- ]
- ),
- )
-
- ## Data ------------------------------------------------------------
- ## 上次代理日期
- self.Data_LastProxyDate = ConfigItem(
- "Data", "LastProxyDate", "2000-01-01", DateTimeValidator("%Y-%m-%d")
- )
- ## 代理次数
- self.Data_ProxyTimes = ConfigItem(
- "Data", "ProxyTimes", 0, RangeValidator(0, 9999)
- )
- ## 是否通过检查
- self.Data_IfPassCheck = ConfigItem("Data", "IfPassCheck", True, BoolValidator())
-
- ## Notify ----------------------------------------------------------
- ## 是否启用通知
- self.Notify_Enabled = ConfigItem("Notify", "Enabled", False, BoolValidator())
- ## 是否发送统计信息
- self.Notify_IfSendStatistic = ConfigItem(
- "Notify", "IfSendStatistic", False, BoolValidator()
- )
- ## 是否发送邮件
- self.Notify_IfSendMail = ConfigItem(
- "Notify", "IfSendMail", False, BoolValidator()
- )
- ## 收件地址
- self.Notify_ToAddress = ConfigItem("Notify", "ToAddress", "")
- ## 是否启用 Server 酱
- self.Notify_IfServerChan = ConfigItem(
- "Notify", "IfServerChan", False, BoolValidator()
- )
- ## Server 酱密钥
- self.Notify_ServerChanKey = ConfigItem("Notify", "ServerChanKey", "")
- ## 自定义 Webhook 列表
- self.Notify_CustomWebhooks = MultipleConfig([Webhook])
-
- super().__init__()
-
- def getTags(self) -> str:
- """生成用户标签列表,返回JSON字符串格式的TagItem列表"""
- tags = []
-
- # 人工排查状态标签
- if not self.get("Data", "IfPassCheck"):
- tags.append({"text": "人工排查未通过", "color": "red"})
-
- # 日常代理标签(使用东4区时间)
- if (
- datetime.strptime(self.get("Data", "LastProxyDate"), "%Y-%m-%d").date()
- == datetime.now(tz=UTC4).date()
- ):
- tags.append(
- {
- "text": f"日常:已代理{self.get('Data', 'ProxyTimes')}次",
- "color": "green",
- }
- )
- else:
- tags.append({"text": "日常:未代理", "color": "orange"})
-
- # 剩余天数标签
- remained_day = self.get("Info", "RemainedDay")
- if remained_day == -1:
- tag_color = "gold"
- elif remained_day == 0:
- tag_color = "red"
- elif remained_day <= 3:
- tag_color = "orange"
- elif remained_day <= 7:
- tag_color = "yellow"
- elif remained_day <= 30:
- tag_color = "blue"
- else:
- tag_color = "green"
- tags.append(
- {
- "text": (
- f"剩余天数:{remained_day}天"
- if remained_day >= 0
- else "剩余天数:无期限"
- ),
- "color": tag_color,
- }
- )
-
- # 关卡信息标签
- tags.append(
- {
- "text": f"关卡:{STARRAIL_STAGE_BOOK.get(self.get('Stage', self.get('Stage', 'Channel')), '未知关卡')}",
- "color": "blue",
- }
- )
- tags.append(
- {
- "text": f"周本:{STARRAIL_STAGE_BOOK.get(self.get('Stage', 'EchoOfWar'), '未知关卡')}",
- "color": "blue",
- }
- )
- tags.append(
- {
- "text": f"模拟宇宙:{STARRAIL_STAGE_BOOK.get(self.get('Stage', 'SimulatedUniverseWorld'), '未知关卡')}",
- "color": "blue",
- }
- )
-
- # 备注标签
- notes = self.get("Info", "Notes")
- tags.append(
- {
- "text": (
- f"备注:{notes}" if len(notes) <= 20 else f"备注:{notes[:20]}..."
- ),
- "color": "pink",
- }
- )
-
- return json.dumps(tags, ensure_ascii=False)
-
-
-class SrcConfig(ConfigBase):
- """SRC配置"""
-
- related_config: dict[str, MultipleConfig] = {}
-
- def __init__(self) -> None:
-
- ## Info ------------------------------------------------------------
- ## SRC 脚本名称
- self.Info_Name = ConfigItem("Info", "Name", "新 SRC 脚本")
- ## SRC 路径
- self.Info_Path = ConfigItem("Info", "Path", str(Path.cwd()), FolderValidator())
-
- ## Emulator --------------------------------------------------------
- ## 模拟器 ID
- self.Emulator_Id = ConfigItem(
- "Emulator",
- "Id",
- "-",
- MultipleUIDValidator("-", self.related_config, "EmulatorConfig"),
- )
- ## 模拟器索引
- self.Emulator_Index = ConfigItem("Emulator", "Index", "-")
-
- ## Run -------------------------------------------------------------
- ## 任务切换方式
- self.Run_TaskTransitionMethod = ConfigItem(
- "Run",
- "TaskTransitionMethod",
- "ExitGame",
- OptionsValidator(["ExitGame", "ExitEmulator"]),
- )
- ## 代理次数限制
- self.Run_ProxyTimesLimit = ConfigItem(
- "Run", "ProxyTimesLimit", 0, RangeValidator(0, 9999)
- )
- ## 运行次数限制
- self.Run_RunTimesLimit = ConfigItem(
- "Run", "RunTimesLimit", 3, RangeValidator(1, 9999)
- )
- ## 运行时间限制(分钟)
- self.Run_RunTimeLimit = ConfigItem(
- "Run", "RunTimeLimit", 10, RangeValidator(1, 9999)
- )
-
- self.UserData = MultipleConfig([SrcUserConfig])
-
- super().__init__()
-
-
-class MaaPlanConfig(ConfigBase):
- """MAA计划表配置"""
-
- def __init__(self) -> None:
-
- ## Info ------------------------------------------------------------
- ## 计划表名称
- self.Info_Name = ConfigItem("Info", "Name", "新 MAA 计划表")
- ## 计划表模式
- self.Info_Mode = ConfigItem(
- "Info", "Mode", "ALL", OptionsValidator(["ALL", "Weekly"])
- )
-
- self.config_item_dict: dict[str, dict[str, ConfigItem]] = {}
-
- for group in ["ALL", *calendar.day_name]:
- self.config_item_dict[group] = {}
-
- ## 理智药数量
- self.config_item_dict[group]["MedicineNumb"] = ConfigItem(
- group, "MedicineNumb", 0, RangeValidator(0, 9999)
- )
- ## 连战次数
- self.config_item_dict[group]["SeriesNumb"] = ConfigItem(
- group,
- "SeriesNumb",
- "0",
- OptionsValidator(["0", "6", "5", "4", "3", "2", "1", "-1"]),
- )
-
- ## 理智关卡
- for name in MAA_STAGE_KEY[2:]:
- # Stage、Stage_1、Stage_2、Stage_3、Stage_Remain
- self.config_item_dict[group][name] = ConfigItem(group, name, "-")
-
- for name in MAA_STAGE_KEY:
- setattr(self, f"{group}_{name}", self.config_item_dict[group][name])
-
- super().__init__()
-
- def get_current_info(self, name: str) -> ConfigItem:
- """获取当前的计划表配置项"""
-
- if self.get("Info", "Mode") == "ALL":
- return self.config_item_dict["ALL"][name]
-
- elif self.get("Info", "Mode") == "Weekly":
-
- today = datetime.now(tz=UTC4).strftime("%A")
-
- if today in self.config_item_dict:
- return self.config_item_dict[today][name]
- else:
- return self.config_item_dict["ALL"][name]
-
- else:
- raise ValueError("非法的计划表模式")
-
-
-class GeneralUserConfig(ConfigBase):
- """通用脚本用户配置"""
-
- def __init__(self) -> None:
-
- ## Info ------------------------------------------------------------
- ## 用户名称
- self.Info_Name = ConfigItem("Info", "Name", "新用户", UserNameValidator())
- ## 是否启用
- self.Info_Status = ConfigItem("Info", "Status", True, BoolValidator())
- ## 剩余天数
- self.Info_RemainedDay = ConfigItem(
- "Info", "RemainedDay", -1, RangeValidator(-1, 9999)
- )
- ## 是否在任务前执行脚本
- self.Info_IfScriptBeforeTask = ConfigItem(
- "Info", "IfScriptBeforeTask", False, BoolValidator()
- )
- ## 任务前脚本路径
- self.Info_ScriptBeforeTask = ConfigItem(
- "Info", "ScriptBeforeTask", str(Path.cwd()), FileValidator()
- )
- ## 是否在任务后执行脚本
- self.Info_IfScriptAfterTask = ConfigItem(
- "Info", "IfScriptAfterTask", False, BoolValidator()
- )
- ## 任务后脚本路径
- self.Info_ScriptAfterTask = ConfigItem(
- "Info", "ScriptAfterTask", str(Path.cwd()), FileValidator()
- )
- ## 备注
- self.Info_Notes = ConfigItem("Info", "Notes", "无")
- ## 用户标签信息
- self.Info_Tag = ConfigItem(
- "Info", "Tag", "[ ]", VirtualConfigValidator(self.getTags)
- )
-
- ## Data ------------------------------------------------------------
- ## 上次代理日期
- self.Data_LastProxyDate = ConfigItem(
- "Data", "LastProxyDate", "2000-01-01", DateTimeValidator("%Y-%m-%d")
- )
- ## 代理次数
- self.Data_ProxyTimes = ConfigItem(
- "Data", "ProxyTimes", 0, RangeValidator(0, 9999)
- )
-
- ## Notify ----------------------------------------------------------
- ## 是否启用通知
- self.Notify_Enabled = ConfigItem("Notify", "Enabled", False, BoolValidator())
- ## 是否发送统计信息
- self.Notify_IfSendStatistic = ConfigItem(
- "Notify", "IfSendStatistic", False, BoolValidator()
- )
- ## 是否发送邮件
- self.Notify_IfSendMail = ConfigItem(
- "Notify", "IfSendMail", False, BoolValidator()
- )
- ## 收件地址
- self.Notify_ToAddress = ConfigItem("Notify", "ToAddress", "")
- ## 是否启用 Server 酱
- self.Notify_IfServerChan = ConfigItem(
- "Notify", "IfServerChan", False, BoolValidator()
- )
- ## Server 酱密钥
- self.Notify_ServerChanKey = ConfigItem("Notify", "ServerChanKey", "")
- ## 自定义 Webhook 列表
- self.Notify_CustomWebhooks = MultipleConfig([Webhook])
-
- super().__init__()
-
- def getTags(self) -> str:
- """生成通用用户标签列表"""
- tags = []
-
- # 任务代理标签(使用东4区时间)
- if (
- datetime.strptime(self.get("Data", "LastProxyDate"), "%Y-%m-%d").date()
- == datetime.now(tz=UTC4).date()
- ):
- tags.append(
- {
- "text": f"任务:已代理{self.get('Data', 'ProxyTimes')}次",
- "color": "green",
- }
- )
- else:
- tags.append({"text": "任务:未代理", "color": "orange"})
-
- # 剩余天数标签
- remained_day = self.get("Info", "RemainedDay")
- if remained_day == -1:
- tag_color = "gold"
- elif remained_day == 0:
- tag_color = "red"
- elif remained_day <= 3:
- tag_color = "orange"
- elif remained_day <= 7:
- tag_color = "yellow"
- elif remained_day <= 30:
- tag_color = "blue"
- else:
- tag_color = "green"
- tags.append(
- {
- "text": (
- f"剩余天数:{remained_day}天"
- if remained_day >= 0
- else "剩余天数:无期限"
- ),
- "color": tag_color,
- }
- )
-
- # 备注标签
- notes = self.get("Info", "Notes")
- tags.append(
- {
- "text": (
- f"备注:{notes}" if len(notes) <= 20 else f"备注:{notes[:20]}..."
- ),
- "color": "pink",
- }
- )
-
- return json.dumps(tags, ensure_ascii=False)
-
-
-class GeneralConfig(ConfigBase):
- """通用配置"""
-
- related_config: dict[str, MultipleConfig] = {}
-
- def __init__(self) -> None:
-
- ## Info ------------------------------------------------------------
- ## 脚本名称
- self.Info_Name = ConfigItem("Info", "Name", "新通用脚本")
- ## 根目录路径
- self.Info_RootPath = ConfigItem(
- "Info", "RootPath", str(Path.cwd()), FileValidator()
- )
-
- ## Script ----------------------------------------------------------
- ## 脚本路径
- self.Script_ScriptPath = ConfigItem(
- "Script", "ScriptPath", str(Path.cwd()), FileValidator()
- )
- ## 脚本参数
- self.Script_Arguments = ConfigItem(
- "Script", "Arguments", "", AdvancedArgumentValidator()
- )
- ## 是否追踪进程
- self.Script_IfTrackProcess = ConfigItem(
- "Script", "IfTrackProcess", False, BoolValidator()
- )
- ## 追踪进程的名称
- self.Script_TrackProcessName = ConfigItem("Script", "TrackProcessName", "")
- ## 追踪进程的文件路径
- self.Script_TrackProcessExe = ConfigItem("Script", "TrackProcessExe", "")
- ## 追踪进程的启动命令行参数
- self.Script_TrackProcessCmdline = ConfigItem(
- "Script", "TrackProcessCmdline", "", ArgumentValidator()
- )
- self.Script_ConfigPath = ConfigItem(
- "Script", "ConfigPath", str(Path.cwd()), FileValidator()
- )
- ## 配置路径模式
- self.Script_ConfigPathMode = ConfigItem(
- "Script", "ConfigPathMode", "File", OptionsValidator(["File", "Folder"])
- )
- ## 更新配置模式
- self.Script_UpdateConfigMode = ConfigItem(
- "Script",
- "UpdateConfigMode",
- "Never",
- OptionsValidator(["Never", "Success", "Failure", "Always"]),
- )
- ## 日志路径
- self.Script_LogPath = ConfigItem(
- "Script", "LogPath", str(Path.cwd()), FileValidator()
- )
- ## 日志路径格式
- self.Script_LogPathFormat = ConfigItem("Script", "LogPathFormat", "%Y-%m-%d")
- ## 日志时间戳开始位置
- self.Script_LogTimeStart = ConfigItem(
- "Script", "LogTimeStart", 1, RangeValidator(1, 9999)
- )
- ## 日志时间戳结束位置
- self.Script_LogTimeEnd = ConfigItem(
- "Script", "LogTimeEnd", 1, RangeValidator(1, 9999)
- )
- ## 日志时间格式
- self.Script_LogTimeFormat = ConfigItem(
- "Script", "LogTimeFormat", "%Y-%m-%d %H:%M:%S"
- )
- ## 成功日志匹配
- self.Script_SuccessLog = ConfigItem("Script", "SuccessLog", "")
- ## 错误日志匹配
- self.Script_ErrorLog = ConfigItem("Script", "ErrorLog", "")
-
- ## Game ------------------------------------------------------------
- ## 是否启用游戏
- self.Game_Enabled = ConfigItem("Game", "Enabled", False, BoolValidator())
- ## 游戏类型
- self.Game_Type = ConfigItem(
- "Game", "Type", "Emulator", OptionsValidator(["Emulator", "Client", "URL"])
- )
- ## 游戏路径
- self.Game_Path = ConfigItem("Game", "Path", str(Path.cwd()), FileValidator())
- ## 自定义协议URL
- self.Game_URL = ConfigItem("Game", "URL", "")
- ## 游戏进程名称
- self.Game_ProcessName = ConfigItem("Game", "ProcessName", "")
- ## 游戏启动参数
- self.Game_Arguments = ConfigItem("Game", "Arguments", "", ArgumentValidator())
- ## 等待时间(秒)
- self.Game_WaitTime = ConfigItem("Game", "WaitTime", 0, RangeValidator(0, 9999))
- ## 是否强制关闭
- self.Game_IfForceClose = ConfigItem(
- "Game", "IfForceClose", False, BoolValidator()
- )
- ## 模拟器 ID
- self.Game_EmulatorId = ConfigItem(
- "Game",
- "EmulatorId",
- "-",
- MultipleUIDValidator("-", self.related_config, "EmulatorConfig"),
- )
- ## 模拟器索引
- self.Game_EmulatorIndex = ConfigItem("Game", "EmulatorIndex", "-")
-
- ## Run -------------------------------------------------------------
- ## 代理次数限制
- self.Run_ProxyTimesLimit = ConfigItem(
- "Run", "ProxyTimesLimit", 0, RangeValidator(0, 9999)
- )
- ## 运行次数限制
- self.Run_RunTimesLimit = ConfigItem(
- "Run", "RunTimesLimit", 3, RangeValidator(1, 9999)
- )
- ## 运行时间限制(分钟)
- self.Run_RunTimeLimit = ConfigItem(
- "Run", "RunTimeLimit", 10, RangeValidator(1, 9999)
- )
-
- self.UserData = MultipleConfig([GeneralUserConfig])
-
- super().__init__()
-
-
-class ToolsConfig(ConfigBase):
- """工具配置"""
-
- def __init__(self) -> None:
-
- self.ArknightsPC_Enabled = ConfigItem(
- "ArknightsPC", "Enabled", False, BoolValidator()
- )
- self.ArknightsPC_PauseKey = ConfigItem(
- "ArknightsPC", "PauseKey", "f10", KeyValidator("f10")
- )
- self.ArknightsPC_SelectDeployedKey = ConfigItem(
- "ArknightsPC", "SelectDeployedKey", "w", KeyValidator("w")
- )
- self.ArknightsPC_UseSkillKey = ConfigItem(
- "ArknightsPC", "UseSkillKey", "r", KeyValidator("r")
- )
- self.ArknightsPC_RetreatKey = ConfigItem(
- "ArknightsPC", "RetreatKey", "t", KeyValidator("t")
- )
- self.ArknightsPC_NextFrameKey = ConfigItem(
- "ArknightsPC", "NextFrameKey", "f", KeyValidator("f")
- )
- self.ArknightsPC_AnotherQuitKey = ConfigItem(
- "ArknightsPC", "AnotherQuitKey", "space", KeyValidator("space")
- )
- self.ArknightsPC_Status = ConfigItem(
- "ArknightsPC",
- "Status",
- "-",
- VirtualConfigValidator(self.arknights_pc_status),
- )
-
- self.arknights_pc_running = False
- self.arknights_pc_get_connected: Callable[[], bool] = lambda: False
-
- super().__init__()
-
- @property
- def arknights_pc_connected(self) -> bool:
-
- return self.arknights_pc_get_connected()
-
- def arknights_pc_status(self) -> str:
-
- if not self.get("ArknightsPC", "Enabled"):
- return TagItem(text="未启用", color="gray").model_dump_json()
- else:
- if self.arknights_pc_running:
- if self.arknights_pc_connected:
- return TagItem(text="运行中", color="green").model_dump_json()
- else:
- return TagItem(text="未连接", color="red").model_dump_json()
- else:
- return TagItem(text="已暂停", color="yellow").model_dump_json()
-
- @property
- def arknights_pc_keys(self) -> list[str]:
- """获取明日方舟 PC 按键配置"""
-
- return [
- self.get("ArknightsPC", _)
- for _ in (
- "SelectDeployedKey",
- "UseSkillKey",
- "RetreatKey",
- "NextFrameKey",
- "AnotherQuitKey",
- )
- ]
-
-
-class PluginConfig(ConfigBase):
- """插件系统独立配置。"""
-
- class PluginNameValidator(StringValidator):
- """插件名称验证器。"""
-
- _pattern = re.compile(r"^[a-zA-Z0-9_][a-zA-Z0-9_@-]*$")
-
- def validate(self, value):
- """校验插件名称是否合法。"""
- return isinstance(value, str) and bool(self._pattern.fullmatch(value))
-
- def correct(self, value):
- """修正非法插件名称。"""
- if isinstance(value, str):
- fixed = re.sub(r"[^a-zA-Z0-9_@-]", "", value.strip())
- if fixed and fixed[0].isalnum() or (fixed and fixed[0] == "_"):
- return fixed
- return "unknown_plugin"
-
- class PluginInstanceIdValidator(StringValidator):
- """插件实例号验证器。"""
-
- _pattern = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
-
- def validate(self, value):
- """校验插件实例号是否合法。"""
- return isinstance(value, str) and bool(self._pattern.fullmatch(value))
-
- def correct(self, value):
- """修正非法插件实例号。"""
- if isinstance(value, str):
- fixed = re.sub(r"[^a-zA-Z0-9_-]", "", value.strip())
- if fixed:
- return fixed[:64]
- return uuid.uuid4().hex[:5]
-
- class PluginInstanceConfig(ConfigBase):
- """插件实例配置,固定元数据使用独立字段,业务配置使用虚拟校验字段。"""
-
- def __init__(self) -> None:
- ## Info --------------------------------------------------------
- ## 插件名称
- self.Info_Plugin = ConfigItem(
- "Info",
- "Plugin",
- "unknown_plugin",
- PluginConfig.PluginNameValidator(),
- )
- ## 插件实例号(不含插件名前缀)
- self.Info_Id = ConfigItem(
- "Info",
- "Id",
- uuid.uuid4().hex[:5],
- PluginConfig.PluginInstanceIdValidator(),
- )
- ## 是否启用
- self.Info_Enabled = ConfigItem("Info", "Enabled", True, BoolValidator())
- ## 实例名称
- self.Info_Name = ConfigItem("Info", "Name", "插件实例", StringValidator())
-
- ## Data --------------------------------------------------------
- ## 原始配置(JSON 字符串)
- self.Data_ConfigRaw = ConfigItem(
- "Data",
- "ConfigRaw",
- "{ }",
- JSONValidator(),
- legacy_name="Config",
- )
- ## 虚拟配置字段(按 schema 校验后的配置)
- self.Data_Config = ConfigItem(
- "Data",
- "Config",
- "{ }",
- VirtualConfigValidator(self.get_validated_config),
- )
-
- super().__init__()
-
- def get_validated_config(self) -> str:
- """获取经过插件 Schema 校验并补默认值后的配置 JSON。"""
- try:
- raw = self.get("Data", "ConfigRaw")
- raw_config = json.loads(raw) if isinstance(raw, str) else {}
- if not isinstance(raw_config, dict):
- raw_config = {}
- except Exception:
- raw_config = {}
-
- plugin_name = self.get("Info", "Plugin")
- if not isinstance(plugin_name, str) or not plugin_name:
- return json.dumps(raw_config, ensure_ascii=False)
-
- try:
- from app.core.plugins.schema import PluginSchemaManager
-
- plugin_path = Path.cwd() / "plugins" / plugin_name
- schema_manager = PluginSchemaManager()
- schema = schema_manager.load_schema(plugin_name, plugin_path)
- if not schema:
- return json.dumps(raw_config, ensure_ascii=False)
-
- validated = schema_manager.apply_defaults_and_validate(
- plugin_name,
- schema,
- raw_config,
- )
- return json.dumps(validated, ensure_ascii=False)
- except Exception:
- return json.dumps(raw_config, ensure_ascii=False)
-
- def __init__(self) -> None:
- ## Data -------------------------------------------------------------
- ## 插件配置版本
- self.Data_Version = ConfigItem("Data", "Version", 1, RangeValidator(1, 9999))
- ## 插件实例集合
- self.PluginInstances = MultipleConfig([PluginConfig.PluginInstanceConfig])
-
- super().__init__()
-
-
-class GlobalConfig(ConfigBase):
- """全局配置"""
-
- def __init__(self):
-
- ## Function ---------------------------------------------------------
- ## 历史记录保留时间(天)
- self.Function_HistoryRetentionTime = ConfigItem(
- "Function",
- "HistoryRetentionTime",
- 0,
- OptionsValidator([7, 15, 30, 60, 90, 180, 365, 0]),
- )
- ## 是否允许睡眠
- self.Function_IfAllowSleep = ConfigItem(
- "Function", "IfAllowSleep", False, BoolValidator()
- )
- ## 是否启用静默模式
- self.Function_IfSilence = ConfigItem(
- "Function", "IfSilence", False, BoolValidator()
- )
- ## 是否同意 Bilibili 协议
- self.Function_IfAgreeBilibili = ConfigItem(
- "Function", "IfAgreeBilibili", False, BoolValidator()
- )
- ## 是否屏蔽模拟器广告
- self.Function_IfBlockAd = ConfigItem(
- "Function", "IfBlockAd", False, BoolValidator()
- )
-
- ## Voice ------------------------------------------------------------
- ## 是否启用语音
- self.Voice_Enabled = ConfigItem("Voice", "Enabled", False, BoolValidator())
- ## 语音类型
- self.Voice_Type = ConfigItem(
- "Voice", "Type", "simple", OptionsValidator(["simple", "noisy"])
- )
-
- ## Start ------------------------------------------------------------
- ## 是否自动启动
- self.Start_IfSelfStart = ConfigItem(
- "Start", "IfSelfStart", False, BoolValidator()
- )
- ## 是否启动时直接最小化
- self.Start_IfMinimizeDirectly = ConfigItem(
- "Start", "IfMinimizeDirectly", False, BoolValidator()
- )
-
- ## UI ---------------------------------------------------------------
- ## 是否显示托盘图标
- self.UI_IfShowTray = ConfigItem("UI", "IfShowTray", False, BoolValidator())
- ## 是否关闭到托盘
- self.UI_IfToTray = ConfigItem("UI", "IfToTray", False, BoolValidator())
-
- ## Notify -----------------------------------------------------------
- ## 任务结果推送时间
- self.Notify_SendTaskResultTime = ConfigItem(
- "Notify",
- "SendTaskResultTime",
- "不推送",
- OptionsValidator(["不推送", "任何时刻", "仅失败时"]),
- )
- ## 是否发送统计信息
- self.Notify_IfSendStatistic = ConfigItem(
- "Notify", "IfSendStatistic", False, BoolValidator()
- )
- ## 是否发送六星通知
- self.Notify_IfSendSixStar = ConfigItem(
- "Notify", "IfSendSixStar", False, BoolValidator()
- )
- ## 是否推送系统通知
- self.Notify_IfPushPlyer = ConfigItem(
- "Notify", "IfPushPlyer", False, BoolValidator()
- )
- ## 是否发送邮件
- self.Notify_IfSendMail = ConfigItem(
- "Notify", "IfSendMail", False, BoolValidator()
- )
- ## 是否发送Koishi通知
- self.Notify_IfKoishiSupport = ConfigItem(
- "Notify", "IfKoishiSupport", False, BoolValidator()
- )
- ## Koishi WebSocket 服务器地址
- self.Notify_KoishiServerAddress = ConfigItem(
- "Notify",
- "KoishiServerAddress",
- "ws://localhost:5140/AUTO_MAS",
- URLValidator(),
- )
- ## Koishi Token
- self.Notify_KoishiToken = ConfigItem("Notify", "KoishiToken", "")
- ## SMTP 服务器地址
- self.Notify_SMTPServerAddress = ConfigItem("Notify", "SMTPServerAddress", "")
- ## 邮箱授权码
- self.Notify_AuthorizationCode = ConfigItem(
- "Notify", "AuthorizationCode", "", EncryptValidator()
- )
- ## 发件地址
- self.Notify_FromAddress = ConfigItem("Notify", "FromAddress", "")
- ## 收件地址
- self.Notify_ToAddress = ConfigItem("Notify", "ToAddress", "")
- ## 是否启用 Server 酱
- self.Notify_IfServerChan = ConfigItem(
- "Notify", "IfServerChan", False, BoolValidator()
- )
- ## Server 酱密钥
- self.Notify_ServerChanKey = ConfigItem("Notify", "ServerChanKey", "")
- ## 自定义 Webhook 列表
- self.Notify_CustomWebhooks = MultipleConfig([Webhook])
-
- ## Update -----------------------------------------------------------
- ## 是否自动更新
- self.Update_IfAutoUpdate = ConfigItem(
- "Update", "IfAutoUpdate", False, BoolValidator()
- )
- ## 更新源
- self.Update_Source = ConfigItem(
- "Update",
- "Source",
- "GitHub",
- OptionsValidator(["GitHub", "MirrorChyan", "AutoSite", "CNB"]),
- )
- ## 更新频道
- self.Update_Channel = ConfigItem(
- "Update", "Channel", "stable", OptionsValidator(["stable", "beta"])
- )
- ## 代理地址
- self.Update_ProxyAddress = ConfigItem("Update", "ProxyAddress", "")
- ## 镜像站 CDK
- self.Update_MirrorChyanCDK = ConfigItem(
- "Update", "MirrorChyanCDK", "", EncryptValidator()
- )
-
- ## Data -------------------------------------------------------------
- ## 唯一标识符
- self.Data_UID = ConfigItem("Data", "UID", str(uuid.uuid4()), UUIDValidator())
- ## 上次统计上传时间
- self.Data_LastStatisticsUpload = ConfigItem(
- "Data",
- "LastStatisticsUpload",
- "2000-01-01 00:00:00",
- DateTimeValidator("%Y-%m-%d %H:%M:%S"),
- )
- ## 上次关卡更新时间
- self.Data_LastStageUpdated = ConfigItem(
- "Data",
- "LastStageUpdated",
- "2000-01-01 00:00:00",
- DateTimeValidator("%Y-%m-%d %H:%M:%S"),
- )
- ## 关卡数据的版本标识符
- self.Data_StageETag = ConfigItem("Data", "StageETag", "")
- ## 关卡信息数据
- self.Data_StageData = ConfigItem(
- "Data", "StageData", "{ }", JSONValidator(), legacy_name="Stage"
- )
- ## 关卡信息
- self.Data_Stage = ConfigItem(
- "Data", "Stage", "-", VirtualConfigValidator(self.getStage)
- )
- ## 上次公告更新时间
- self.Data_LastNoticeUpdated = ConfigItem(
- "Data",
- "LastNoticeUpdated",
- "2000-01-01 00:00:00",
- DateTimeValidator("%Y-%m-%d %H:%M:%S"),
- )
- ## 公告的版本标识符
- self.Data_NoticeETag = ConfigItem("Data", "NoticeETag", "")
- ## 是否显示公告
- self.Data_IfShowNotice = ConfigItem(
- "Data", "IfShowNotice", True, BoolValidator()
- )
- ## 公告内容
- self.Data_Notice = ConfigItem("Data", "Notice", "{ }", JSONValidator())
- ## 上次 Web 配置更新时间
- self.Data_LastWebConfigUpdated = ConfigItem(
- "Data",
- "LastWebConfigUpdated",
- "2000-01-01 00:00:00",
- DateTimeValidator("%Y-%m-%d %H:%M:%S"),
- )
- ## Web 配置
- self.Data_WebConfig = ConfigItem(
- "Data", "WebConfig", "[ ]", JSONValidator(list)
- )
- super().__init__()
-
- ## 模拟器配置列表
- self.EmulatorConfig = MultipleConfig([EmulatorConfig])
- ## 计划表配置列表
- self.PlanConfig = MultipleConfig([MaaPlanConfig])
- ## 脚本配置列表
- self.ScriptConfig = MultipleConfig(
- [MaaConfig, MaaEndConfig, SrcConfig, GeneralConfig]
- )
- ## 队列配置列表
- self.QueueConfig = MultipleConfig([QueueConfig])
- ## 工具箱配置
- self.ToolsConfig = ToolsConfig()
- ## 插件系统独立配置
- self.PluginConfig = PluginConfig()
-
- MaaConfig.related_config["EmulatorConfig"] = self.EmulatorConfig
- MaaEndConfig.related_config["EmulatorConfig"] = self.EmulatorConfig
- SrcConfig.related_config["EmulatorConfig"] = self.EmulatorConfig
- GeneralConfig.related_config["EmulatorConfig"] = self.EmulatorConfig
- MaaUserConfig.related_config["PlanConfig"] = self.PlanConfig
- QueueItem.related_config["ScriptConfig"] = self.ScriptConfig
-
- def getStage(self) -> str:
- """获取关卡信息"""
-
- try:
- raw_stage_data = json.loads(self.get("Data", "StageData"))
-
- activity_stage_drop_info = []
- activity_stage_combox = []
-
- for side_story in raw_stage_data.values():
- if (
- datetime.strptime(
- side_story["Activity"]["UtcStartTime"], "%Y/%m/%d %H:%M:%S"
- ).replace(tzinfo=UTC8)
- < datetime.now(tz=UTC8)
- < datetime.strptime(
- side_story["Activity"]["UtcExpireTime"], "%Y/%m/%d %H:%M:%S"
- ).replace(tzinfo=UTC8)
- ):
- for stage in side_story["Stages"]:
- activity_stage_combox.append(
- {"label": stage["Display"], "value": stage["Value"]}
- )
-
- if "SSReopen" not in stage["Display"]:
-
- if stage["Drop"] in MATERIALS_MAP:
- drop_id = stage["Drop"]
- elif "玉" in stage["Drop"]:
- drop_id = "30012"
- else:
- drop_id = "NotFound"
-
- activity_stage_drop_info.append(
- {
- "Display": stage["Display"],
- "Value": stage["Value"],
- "Drop": drop_id,
- "DropName": MATERIALS_MAP.get(
- stage["Drop"], stage["Drop"]
- ),
- "Activity": side_story["Activity"],
- }
- )
- except:
- return "{ }"
-
- stage_data = {"Info": activity_stage_drop_info}
-
- for day in range(0, 8):
- res_stage = []
-
- for stage in RESOURCE_STAGE_INFO:
- if day in stage["days"] or day == 0:
- res_stage.append({"label": stage["text"], "value": stage["value"]})
-
- stage_data[calendar.day_name[day - 1] if day > 0 else "ALL"] = (
- res_stage[0:1] + activity_stage_combox + res_stage[1:]
- )
-
- return json.dumps(stage_data, ensure_ascii=False)
-
-
-CLASS_BOOK = {
- "MAA": MaaConfig,
- "MaaPlan": MaaPlanConfig,
- "SRC": SrcConfig,
- "MaaEnd": MaaEndConfig,
- "General": GeneralConfig,
-}
-"""配置类映射表"""
diff --git a/app/models/general.py b/app/models/general.py
new file mode 100644
index 00000000..44b36295
--- /dev/null
+++ b/app/models/general.py
@@ -0,0 +1,142 @@
+from __future__ import annotations
+
+import json
+from datetime import datetime
+from pathlib import Path
+from typing import Annotated, Any, ClassVar, Literal
+
+from pydantic import BaseModel, Field, field_validator
+
+from app.utils.constants import UTC4
+from app.core.config.base import MultipleConfig
+from app.core.config.pydantic import PydanticConfigBase
+from app.core.config.shortcuts import config, ref, sub_configs, virtual
+from app.core.config.types import DayCount, NonNegativeInt, PositiveInt, UrlString
+from .common import Webhook
+from .tag_helpers import notes_tag, proxy_date_tag, remained_day_tag
+
+
+@config
+@sub_configs(Notify_CustomWebhooks=[Webhook])
+class GeneralUserConfig(PydanticConfigBase):
+ """通用脚本用户配置"""
+
+ class InfoModel(BaseModel):
+ Name: str = "新用户"
+ Status: bool = True
+ RemainedDay: DayCount = -1
+ IfScriptBeforeTask: bool = False
+ ScriptBeforeTask: str = str(Path.cwd())
+ IfScriptAfterTask: bool = False
+ ScriptAfterTask: str = str(Path.cwd())
+ Notes: str = "无"
+ Tag: Annotated[
+ str,
+ virtual("getTags"),
+ ] = "[ ]"
+
+ class DataModel(BaseModel):
+ LastProxyDate: str = "2000-01-01"
+ ProxyTimes: NonNegativeInt = Field(default=0, le=9999)
+
+ @field_validator("LastProxyDate", mode="before")
+ @classmethod
+ def _normalize_ymd(cls, value: Any) -> str:
+ text = value if isinstance(value, str) else str(value)
+ try:
+ datetime.strptime(text, "%Y-%m-%d")
+ return text
+ except ValueError:
+ return "2000-01-01"
+
+ class NotifyModel(BaseModel):
+ Enabled: bool = False
+ IfSendStatistic: bool = False
+ IfSendMail: bool = False
+ ToAddress: str = ""
+ IfServerChan: bool = False
+ ServerChanKey: str = ""
+
+ Info: InfoModel = Field(default_factory=InfoModel)
+ Data: DataModel = Field(default_factory=DataModel)
+ Notify: NotifyModel = Field(default_factory=NotifyModel)
+
+ def getTags(self) -> str: # noqa: N802
+ """生成通用用户标签列表"""
+ tags: list[dict[str, str]] = []
+
+ tags.append(
+ proxy_date_tag(
+ self.get("Data", "LastProxyDate"),
+ self.get("Data", "ProxyTimes"),
+ UTC4,
+ label="任务",
+ )
+ )
+ tags.append(remained_day_tag(self.get("Info", "RemainedDay")))
+ tags.append(notes_tag(self.get("Info", "Notes")))
+
+ return json.dumps(tags, ensure_ascii=False)
+
+
+@config
+@sub_configs(UserData=[GeneralUserConfig])
+class GeneralConfig(PydanticConfigBase):
+ """通用配置"""
+
+ related_config: ClassVar[dict[str, MultipleConfig[Any]]] = {}
+
+ class InfoModel(BaseModel):
+ Name: str = "新通用脚本"
+ RootPath: str = str(Path.cwd())
+
+ class ScriptModel(BaseModel):
+ ScriptPath: str = str(Path.cwd())
+ Arguments: str = ""
+ IfTrackProcess: bool = False
+ TrackProcessName: str = ""
+ TrackProcessExe: str = ""
+ TrackProcessCmdline: str = ""
+ ConfigPath: str = str(Path.cwd())
+ ConfigPathMode: Literal["File", "Folder"] = "File"
+ UpdateConfigMode: Literal["Never", "Success", "Failure", "Always"] = "Never"
+ LogPath: str = str(Path.cwd())
+ LogPathFormat: str = "%Y-%m-%d"
+ LogTimeStart: PositiveInt = Field(default=1, le=9999)
+ LogTimeEnd: PositiveInt = Field(default=1, le=9999)
+ LogTimeFormat: str = "%Y-%m-%d %H:%M:%S"
+ SuccessLog: str = ""
+ ErrorLog: str = ""
+
+ class GameModel(BaseModel):
+ Enabled: bool = False
+ Type: Literal["Emulator", "Client", "URL"] = "Emulator"
+ Path: str = str(Path.cwd())
+ URL: UrlString = ""
+ ProcessName: str = ""
+ Arguments: str = ""
+ WaitTime: NonNegativeInt = Field(default=0, le=9999)
+ IfForceClose: bool = False
+ EmulatorId: Annotated[
+ str,
+ ref(
+ "EmulatorConfig",
+ default="-",
+ allow_values=("-",),
+ on_delete="set_default",
+ ),
+ ] = "-"
+ EmulatorIndex: str = "-"
+
+ class RunModel(BaseModel):
+ ProxyTimesLimit: NonNegativeInt = Field(default=0, le=9999)
+ RunTimesLimit: PositiveInt = Field(default=3, le=9999)
+ RunTimeLimit: PositiveInt = Field(default=10, le=9999)
+
+ Info: InfoModel = Field(default_factory=InfoModel)
+ Script: ScriptModel = Field(default_factory=ScriptModel)
+ Game: GameModel = Field(default_factory=GameModel)
+ Run: RunModel = Field(default_factory=RunModel)
+
+
+__all__ = ["GeneralUserConfig", "GeneralConfig"]
diff --git a/app/models/global_config.py b/app/models/global_config.py
new file mode 100644
index 00000000..01e89332
--- /dev/null
+++ b/app/models/global_config.py
@@ -0,0 +1,265 @@
+from __future__ import annotations
+
+import calendar
+import json
+import uuid
+from datetime import datetime
+from typing import Annotated, Any, Literal
+
+from pydantic import AliasChoices, AliasPath, BaseModel, Field, field_validator
+
+from app.core.config.pydantic import PydanticConfigBase
+from app.core.config.shortcuts import config, singleton, sub_configs, virtual
+from app.core.config.types import (
+ EncryptedString,
+ JsonDictString,
+ JsonListString,
+ KeyboardKeyString,
+ UrlString,
+ YmdHmsString,
+)
+from app.utils.constants import MATERIALS_MAP, RESOURCE_STAGE_INFO, UTC8
+from app.models.shared import TagItem
+from .common import EmulatorConfig, QueueConfig, QueueItem, Webhook
+from .general import GeneralConfig
+from .maa import MaaConfig, MaaPlanConfig, MaaUserConfig
+from .maaend import MaaEndConfig
+from .plugin import PluginInstanceConfig
+from .src import SrcConfig
+
+
+class ToolsConfig(PydanticConfigBase):
+ """工具配置"""
+
+ class ArknightsPCModel(BaseModel):
+ Enabled: bool = False
+ PauseKey: KeyboardKeyString = "f10"
+ SelectDeployedKey: KeyboardKeyString = "w"
+ UseSkillKey: KeyboardKeyString = "r"
+ RetreatKey: KeyboardKeyString = "t"
+ NextFrameKey: KeyboardKeyString = "f"
+ AnotherQuitKey: KeyboardKeyString = "space"
+ Status: Annotated[
+ str,
+ virtual("arknights_pc_status"),
+ ] = "-"
+
+ ArknightsPC: ArknightsPCModel = Field(default_factory=ArknightsPCModel)
+
+ def __init__(self, **data: Any):
+ super().__init__(**data)
+ object.__setattr__(self, "arknights_pc_running", False)
+ object.__setattr__(self, "arknights_pc_get_connected", lambda: False)
+
+ @property
+ def arknights_pc_connected(self) -> bool:
+ return self.arknights_pc_get_connected()
+
+ def arknights_pc_status(self) -> str:
+ if not self.get("ArknightsPC", "Enabled"):
+ return TagItem(text="未启用", color="gray").model_dump_json()
+
+ if self.arknights_pc_running:
+ if self.arknights_pc_connected:
+ return TagItem(text="运行中", color="green").model_dump_json()
+ return TagItem(text="未连接", color="red").model_dump_json()
+
+ return TagItem(text="已暂停", color="yellow").model_dump_json()
+
+ @property
+ def arknights_pc_keys(self) -> list[str]:
+ """获取明日方舟 PC 按键配置"""
+
+ return [
+ self.get("ArknightsPC", _)
+ for _ in (
+ "SelectDeployedKey",
+ "UseSkillKey",
+ "RetreatKey",
+ "NextFrameKey",
+ "AnotherQuitKey",
+ )
+ ]
+
+
+@config
+@sub_configs(
+ Notify_CustomWebhooks=[Webhook],
+ EmulatorConfig=[EmulatorConfig],
+ PlanConfig=[MaaPlanConfig],
+ ScriptConfig=[MaaConfig, MaaEndConfig, SrcConfig, GeneralConfig],
+ PluginConfig=[PluginInstanceConfig],
+ QueueConfig=[QueueConfig],
+ ToolsConfig=singleton(ToolsConfig),
+)
+class GlobalConfig(PydanticConfigBase):
+ """全局配置"""
+
+ class FunctionModel(BaseModel):
+ HistoryRetentionTime: Literal[7, 15, 30, 60, 90, 180, 365, 0] = 0
+ IfAllowSleep: bool = False
+ IfSilence: bool = False
+ IfAgreeBilibili: bool = False
+ IfBlockAd: bool = False
+
+ class VoiceModel(BaseModel):
+ Enabled: bool = False
+ Type: Literal["simple", "noisy"] = "simple"
+
+ class StartModel(BaseModel):
+ IfSelfStart: bool = False
+ IfMinimizeDirectly: bool = False
+
+ class UIModel(BaseModel):
+ IfShowTray: bool = False
+ IfToTray: bool = False
+
+ class NotifyModel(BaseModel):
+ SendTaskResultTime: Literal["不推送", "任何时刻", "仅失败时"] = "不推送"
+ IfSendStatistic: bool = False
+ IfSendSixStar: bool = False
+ IfPushPlyer: bool = False
+ IfSendMail: bool = False
+ IfKoishiSupport: bool = False
+ KoishiServerAddress: UrlString = "ws://localhost:5140/AUTO_MAS"
+ KoishiToken: str = ""
+ SMTPServerAddress: str = ""
+ AuthorizationCode: EncryptedString = ""
+ FromAddress: str = ""
+ ToAddress: str = ""
+ IfServerChan: bool = False
+ ServerChanKey: str = ""
+
+ class UpdateModel(BaseModel):
+ IfAutoUpdate: bool = False
+ Source: Literal["GitHub", "MirrorChyan", "AutoSite"] = "GitHub"
+ Channel: Literal["stable", "beta"] = "stable"
+ ProxyAddress: str = ""
+ MirrorChyanCDK: EncryptedString = ""
+
+ class DataModel(BaseModel):
+ UID: str = str(uuid.uuid4())
+ LastStatisticsUpload: YmdHmsString = "2000-01-01 00:00:00"
+ LastStageUpdated: YmdHmsString = "2000-01-01 00:00:00"
+ StageETag: str = ""
+ StageData: JsonDictString = Field(
+ default="{ }",
+ validation_alias=AliasChoices("StageData", AliasPath("Data", "Stage")),
+ )
+ LastNoticeUpdated: YmdHmsString = "2000-01-01 00:00:00"
+ NoticeETag: str = ""
+ IfShowNotice: bool = True
+ Notice: JsonDictString = "{ }"
+ LastWebConfigUpdated: YmdHmsString = "2000-01-01 00:00:00"
+ WebConfig: JsonListString = "[ ]"
+ Stage: Annotated[
+ str,
+ virtual("getStage"),
+ ] = "-"
+
+ @field_validator("UID", mode="before")
+ @classmethod
+ def _normalize_uid(cls, value: Any) -> str:
+ text = value if isinstance(value, str) else str(value)
+ try:
+ return str(uuid.UUID(text))
+ except (TypeError, ValueError):
+ return str(uuid.uuid4())
+
+ Function: FunctionModel = Field(default_factory=FunctionModel)
+ Voice: VoiceModel = Field(default_factory=VoiceModel)
+ Start: StartModel = Field(default_factory=StartModel)
+ UI: UIModel = Field(default_factory=UIModel)
+ Notify: NotifyModel = Field(default_factory=NotifyModel)
+ Update: UpdateModel = Field(default_factory=UpdateModel)
+ Data: DataModel = Field(default_factory=DataModel)
+
+ def model_post_init(self, __context: Any) -> None:
+ MaaConfig.related_config["EmulatorConfig"] = self.EmulatorConfig
+ MaaEndConfig.related_config["EmulatorConfig"] = self.EmulatorConfig
+ SrcConfig.related_config["EmulatorConfig"] = self.EmulatorConfig
+ GeneralConfig.related_config["EmulatorConfig"] = self.EmulatorConfig
+ MaaUserConfig.related_config["PlanConfig"] = self.PlanConfig
+ QueueItem.related_config["ScriptConfig"] = self.ScriptConfig
+
+ def getStage(self) -> str: # noqa: N802
+ """获取关卡信息"""
+
+ try:
+ raw_stage_data = json.loads(self.get("Data", "StageData"))
+
+ activity_stage_drop_info: list[dict[str, Any]] = []
+ activity_stage_combox: list[dict[str, str]] = []
+
+ for side_story in raw_stage_data.values():
+ if (
+ datetime.strptime(
+ side_story["Activity"]["UtcStartTime"], "%Y/%m/%d %H:%M:%S"
+ ).replace(tzinfo=UTC8)
+ < datetime.now(tz=UTC8)
+ < datetime.strptime(
+ side_story["Activity"]["UtcExpireTime"], "%Y/%m/%d %H:%M:%S"
+ ).replace(tzinfo=UTC8)
+ ):
+ for stage in side_story["Stages"]:
+ activity_stage_combox.append(
+ {"label": stage["Display"], "value": stage["Value"]}
+ )
+
+ if "SSReopen" not in stage["Display"]:
+ if stage["Drop"] in MATERIALS_MAP:
+ drop_id = stage["Drop"]
+ elif "玉" in stage["Drop"]:
+ drop_id = "30012"
+ else:
+ drop_id = "NotFound"
+
+ activity_stage_drop_info.append(
+ {
+ "Display": stage["Display"],
+ "Value": stage["Value"],
+ "Drop": drop_id,
+ "DropName": MATERIALS_MAP.get(
+ stage["Drop"], stage["Drop"]
+ ),
+ "Activity": side_story["Activity"],
+ }
+ )
+ except (json.JSONDecodeError, KeyError, TypeError, ValueError):
+ return "{ }"
+
+ stage_data: dict[str, Any] = {"Info": activity_stage_drop_info}
+
+ for day in range(0, 8):
+ res_stage: list[dict[str, str]] = []
+
+ for stage in RESOURCE_STAGE_INFO:
+ stage_days = stage.get("days")
+ stage_text = stage.get("text")
+ stage_value = stage.get("value")
+ if (
+ isinstance(stage_days, list)
+ and isinstance(stage_text, str)
+ and isinstance(stage_value, str)
+ and (day in stage_days or day == 0)
+ ):
+ res_stage.append({"label": stage_text, "value": stage_value})
+
+ stage_data[calendar.day_name[day - 1] if day > 0 else "ALL"] = (
+ res_stage[0:1] + activity_stage_combox + res_stage[1:]
+ )
+
+ return json.dumps(stage_data, ensure_ascii=False)
+
+
+CLASS_BOOK = {
+ "MAA": MaaConfig,
+ "MaaPlan": MaaPlanConfig,
+ "SRC": SrcConfig,
+ "MaaEnd": MaaEndConfig,
+ "General": GeneralConfig,
+}
+"""配置类映射表"""
+
+
+__all__ = ["ToolsConfig", "GlobalConfig", "CLASS_BOOK"]
diff --git a/app/models/maa.py b/app/models/maa.py
new file mode 100644
index 00000000..9ace7ffe
--- /dev/null
+++ b/app/models/maa.py
@@ -0,0 +1,340 @@
+from __future__ import annotations
+
+import json
+import uuid
+from datetime import datetime
+from pathlib import Path
+from typing import Annotated, Any, ClassVar, Literal
+
+from pydantic import AliasChoices, AliasPath, BaseModel, Field, field_validator
+
+from app.core.config.base import MultipleConfig
+from app.core.config.pydantic import PydanticConfigBase
+from app.core.config.shortcuts import config, ref, sub_configs, virtual
+from app.core.config.types import (
+ DayCount,
+ EncryptedString,
+ JsonDictString,
+ NonNegativeInt,
+ PositiveInt,
+)
+from app.utils.constants import MAA_STAGE_KEY, RESOURCE_STAGE_INFO, UTC4, UTC8
+from .common import Webhook
+from .tag_helpers import notes_tag, proxy_date_tag, remained_day_tag
+
+
+@config
+@sub_configs(Notify_CustomWebhooks=[Webhook])
+class MaaUserConfig(PydanticConfigBase):
+ """MAA用户配置"""
+
+ related_config: ClassVar[dict[str, MultipleConfig[Any]]] = {}
+
+ class InfoModel(BaseModel):
+ Name: str = "新用户"
+ Id: str = ""
+ Password: EncryptedString = ""
+ Mode: Literal["简洁", "详细"] = "简洁"
+ StageMode: Annotated[
+ str,
+ ref(
+ "PlanConfig",
+ default="Fixed",
+ allow_values=("Fixed",),
+ on_delete="set_default",
+ ),
+ ] = "Fixed"
+ Server: Literal[
+ "Official", "Bilibili", "YoStarEN", "YoStarJP", "YoStarKR", "txwy"
+ ] = "Official"
+ Status: bool = True
+ RemainedDay: DayCount = -1
+ Annihilation: Literal[
+ "Close",
+ "Annihilation",
+ "Chernobog@Annihilation",
+ "LungmenOutskirts@Annihilation",
+ "LungmenDowntown@Annihilation",
+ ] = "Annihilation"
+ InfrastMode: Literal["Normal", "Rotation", "Custom"] = "Normal"
+ InfrastName: Annotated[
+ str,
+ virtual("getInfrastName"),
+ ] = "-"
+ InfrastIndex: Annotated[
+ str,
+ virtual("getInfrastIndex"),
+ ] = "-"
+ Notes: str = "无"
+ MedicineNumb: NonNegativeInt = 0
+ SeriesNumb: Literal["0", "6", "5", "4", "3", "2", "1", "-1"] = "0"
+ Stage: str = "-"
+ Stage_1: str = "-"
+ Stage_2: str = "-"
+ Stage_3: str = "-"
+ Stage_Remain: str = "-"
+ IfSkland: bool = False
+ SklandToken: EncryptedString = ""
+ Tag: Annotated[
+ str,
+ virtual("getTags"),
+ ] = "[ ]"
+
+ class DataModel(BaseModel):
+ LastProxyDate: str = "2000-01-01"
+ LastSklandDate: str = "2000-01-01"
+ ProxyTimes: NonNegativeInt = 0
+ IfPassCheck: bool = True
+ CustomInfrast: JsonDictString = "{ }"
+ InfrastIndex: str = Field(
+ default="0",
+ validation_alias=AliasChoices(
+ "InfrastIndex", AliasPath("Info", "InfrastIndex")
+ ),
+ )
+
+ @field_validator("LastProxyDate", "LastSklandDate", mode="before")
+ @classmethod
+ def _normalize_ymd(cls, value: Any) -> str:
+ text = value if isinstance(value, str) else str(value)
+ try:
+ datetime.strptime(text, "%Y-%m-%d")
+ return text
+ except ValueError:
+ return "2000-01-01"
+
+ class TaskModel(BaseModel):
+ IfStartUp: bool = True
+ IfFight: bool = True
+ IfInfrast: bool = True
+ IfRecruit: bool = True
+ IfMall: bool = True
+ IfAward: bool = True
+ IfRoguelike: bool = False
+ IfReclamation: bool = False
+
+ class NotifyModel(BaseModel):
+ Enabled: bool = False
+ IfSendStatistic: bool = False
+ IfSendSixStar: bool = False
+ IfSendMail: bool = False
+ ToAddress: str = ""
+ IfServerChan: bool = False
+ ServerChanKey: str = ""
+
+ Info: InfoModel = Field(default_factory=InfoModel)
+ Data: DataModel = Field(default_factory=DataModel)
+ Task: TaskModel = Field(default_factory=TaskModel)
+ Notify: NotifyModel = Field(default_factory=NotifyModel)
+
+ def getInfrastName(self) -> str: # noqa: N802
+ if self.get("Info", "InfrastMode") != "Custom":
+ return "未使用自定义基建模式"
+
+ infrast_data = json.loads(self.get("Data", "CustomInfrast"))
+ if (
+ infrast_data.get("title", "文件标题") != "文件标题"
+ and infrast_data.get("description", "文件描述") != "文件描述"
+ ):
+ return f"{infrast_data['title']} - {infrast_data['description']}"
+ if infrast_data.get("title", "文件标题") != "文件标题":
+ return str(infrast_data["title"])
+ if infrast_data.get("id", None):
+ return str(infrast_data["id"])
+ return "未命名自定义基建"
+
+ def getInfrastIndex(self) -> str: # noqa: N802
+ if self.get("Info", "InfrastMode") != "Custom":
+ return "-1"
+
+ infrast_data = json.loads(self.get("Data", "CustomInfrast"))
+
+ if len(infrast_data.get("plans", [])) == 0:
+ return "-1"
+
+ for i, plan in enumerate(infrast_data.get("plans", [])):
+ for t in plan.get("period", []):
+ if (
+ datetime.strptime(t[0], "%H:%M").time()
+ <= datetime.now().time()
+ <= datetime.strptime(t[1], "%H:%M").time()
+ ):
+ return str(i)
+
+ return self.get("Data", "InfrastIndex") or "0"
+
+ def getTags(self) -> str: # noqa: N802
+ """生成用户标签列表,返回JSON字符串格式的TagItem列表"""
+ tags: list[dict[str, str]] = []
+
+ if not self.get("Data", "IfPassCheck"):
+ tags.append({"text": "人工排查未通过", "color": "red"})
+
+ tags.append(
+ proxy_date_tag(
+ self.get("Data", "LastProxyDate"),
+ self.get("Data", "ProxyTimes"),
+ UTC4,
+ )
+ )
+
+ if self.get("Info", "IfSkland"):
+ if (
+ datetime.strptime(self.get("Data", "LastSklandDate"), "%Y-%m-%d").date()
+ == datetime.now(tz=UTC8).date()
+ ):
+ tags.append({"text": "森空岛:已签到", "color": "green"})
+ else:
+ tags.append({"text": "森空岛:未签到", "color": "orange"})
+ else:
+ tags.append({"text": "森空岛:禁用", "color": "red"})
+
+ tags.append(remained_day_tag(self.get("Info", "RemainedDay")))
+
+ infrast_mode = self.get("Info", "InfrastMode")
+ if self.get("Task", "IfInfrast"):
+ if infrast_mode == "Normal":
+ infrast_text = "基建:常规"
+ elif infrast_mode == "Rotation":
+ infrast_text = "基建:轮换"
+ elif infrast_mode == "Custom":
+ name = self.getInfrastName()
+ infrast_text = f"基建:{name if len(name) < 10 else name[:10] + '...'}"
+ else:
+ infrast_text = "基建:开启"
+ tags.append({"text": infrast_text, "color": "purple"})
+ else:
+ tags.append({"text": "基建:关闭", "color": "red"})
+
+ plan_data: dict[str, str] = {
+ stage_key: self.get_stage_zh(self.get("Info", stage_key))
+ for stage_key in MAA_STAGE_KEY[2:]
+ }
+ tag_color = "blue"
+ if self.get("Info", "StageMode") != "Fixed":
+ plan = self.related_config["PlanConfig"][
+ uuid.UUID(self.get("Info", "StageMode"))
+ ]
+ if isinstance(plan, MaaPlanConfig):
+ plan_data = {
+ stage_key: self.get_stage_zh(
+ plan.get_current_info(stage_key)
+ )
+ for stage_key in MAA_STAGE_KEY[2:]
+ }
+ tag_color = "green"
+
+ tags.append({"text": f"主关卡:{plan_data['Stage']}", "color": tag_color})
+ backup_stages = [
+ plan_data[f"Stage_{i}"]
+ for i in range(1, 4)
+ if plan_data[f"Stage_{i}"] != "禁用"
+ ]
+ if backup_stages:
+ tags.append(
+ {"text": f"备选:{', '.join(backup_stages)}", "color": tag_color}
+ )
+ if plan_data["Stage_Remain"] != "禁用":
+ tags.append(
+ {"text": f"剩余:{plan_data['Stage_Remain']}", "color": tag_color}
+ )
+
+ tags.append(notes_tag(self.get("Info", "Notes")))
+
+ return json.dumps(tags, ensure_ascii=False)
+
+ @staticmethod
+ def get_stage_zh(stage: str) -> str:
+ for stage_info in RESOURCE_STAGE_INFO:
+ if stage_info.get("value") == stage:
+ text_value = stage_info.get("text", stage)
+ text = text_value if isinstance(text_value, str) else stage
+ return (
+ text.replace("经验-6/5", "经验")
+ .replace("龙门币-6/5", "龙门币")
+ .replace("红票-5", "红票")
+ .replace("技能-5", "技能")
+ .replace("碳-5", "碳")
+ )
+ return stage
+
+
+@config
+@sub_configs(UserData=[MaaUserConfig])
+class MaaConfig(PydanticConfigBase):
+ """MAA配置"""
+
+ related_config: ClassVar[dict[str, MultipleConfig[Any]]] = {}
+
+ class InfoModel(BaseModel):
+ Name: str = "新 MAA 脚本"
+ Path: str = str(Path.cwd())
+
+ class EmulatorModel(BaseModel):
+ Id: Annotated[
+ str,
+ ref(
+ "EmulatorConfig",
+ default="-",
+ allow_values=("-",),
+ on_delete="set_default",
+ ),
+ ] = "-"
+ Index: str = "-"
+
+ class RunModel(BaseModel):
+ TaskTransitionMethod: Literal["NoAction", "ExitGame", "ExitEmulator"] = (
+ "ExitEmulator"
+ )
+ ProxyTimesLimit: NonNegativeInt = Field(default=0, le=9999)
+ RunTimesLimit: PositiveInt = Field(default=3, le=9999)
+ AnnihilationTimeLimit: PositiveInt = Field(default=40, le=9999)
+ RoutineTimeLimit: PositiveInt = Field(default=10, le=9999)
+ AnnihilationAvoidWaste: bool = False
+
+ Info: InfoModel = Field(default_factory=InfoModel)
+ Emulator: EmulatorModel = Field(default_factory=EmulatorModel)
+ Run: RunModel = Field(default_factory=RunModel)
+
+
+class MaaPlanConfig(PydanticConfigBase):
+ """MAA计划表配置"""
+
+ class InfoModel(BaseModel):
+ Name: str = "新 MAA 计划表"
+ Mode: Literal["ALL", "Weekly"] = "ALL"
+
+ class DayPlanModel(BaseModel):
+ MedicineNumb: NonNegativeInt = Field(default=0, le=9999)
+ SeriesNumb: Literal["0", "6", "5", "4", "3", "2", "1", "-1"] = "0"
+ Stage: str = "-"
+ Stage_1: str = "-"
+ Stage_2: str = "-"
+ Stage_3: str = "-"
+ Stage_Remain: str = "-"
+
+ Info: InfoModel = Field(default_factory=InfoModel)
+ ALL: DayPlanModel = Field(default_factory=DayPlanModel)
+ Monday: DayPlanModel = Field(default_factory=DayPlanModel)
+ Tuesday: DayPlanModel = Field(default_factory=DayPlanModel)
+ Wednesday: DayPlanModel = Field(default_factory=DayPlanModel)
+ Thursday: DayPlanModel = Field(default_factory=DayPlanModel)
+ Friday: DayPlanModel = Field(default_factory=DayPlanModel)
+ Saturday: DayPlanModel = Field(default_factory=DayPlanModel)
+ Sunday: DayPlanModel = Field(default_factory=DayPlanModel)
+
+ def get_current_info(self, name: str) -> Any:
+ """获取当前的计划表配置项"""
+
+ if self.get("Info", "Mode") == "ALL":
+ return getattr(self.ALL, name, "-")
+
+ if self.get("Info", "Mode") == "Weekly":
+ today = datetime.now(tz=UTC4).strftime("%A")
+ plan = getattr(self, today, self.ALL)
+ return getattr(plan, name, "-")
+
+ raise ValueError("非法的计划表模式")
+
+
+__all__ = ["MaaPlanConfig", "MaaUserConfig", "MaaConfig"]
diff --git a/app/models/maaend.py b/app/models/maaend.py
new file mode 100644
index 00000000..02c55fb9
--- /dev/null
+++ b/app/models/maaend.py
@@ -0,0 +1,177 @@
+from __future__ import annotations
+
+import json
+from datetime import datetime
+from pathlib import Path
+from typing import Annotated, Any, ClassVar, Literal
+
+from pydantic import BaseModel, Field, field_validator
+
+from app.core.config.base import MultipleConfig
+from app.core.config.pydantic import PydanticConfigBase
+from app.core.config.shortcuts import config, ref, sub_configs, virtual
+from app.core.config.types import DayCount, EncryptedString, NonNegativeInt, PositiveInt
+from app.utils.constants import MAAEND_STAGE_BOOK, MAAEND_STAGE_WITH_AB, UTC4, UTC8
+from .common import Webhook
+from .tag_helpers import notes_tag, proxy_date_tag, remained_day_tag
+
+
+@config
+@sub_configs(Notify_CustomWebhooks=[Webhook])
+class MaaEndUserConfig(PydanticConfigBase):
+ """MaaEnd用户配置"""
+
+ class InfoModel(BaseModel):
+ Name: str = "新用户"
+ Status: bool = True
+ Id: str = ""
+ Password: EncryptedString = ""
+ Mode: Literal["简洁", "详细"] = "简洁"
+ Resource: Literal["官服"] = "官服"
+ RemainedDay: DayCount = -1
+ Notes: str = "无"
+ IfSkland: bool = False
+ SklandToken: EncryptedString = ""
+ Tag: Annotated[
+ str,
+ virtual("getTags"),
+ ] = "[ ]"
+
+ class TaskModel(BaseModel):
+ ProtocolSpaceTab: Literal[
+ "OperatorProgression", "WeaponProgression", "CrisisDrills"
+ ] = "OperatorProgression"
+ OperatorProgression: Literal[
+ "OperatorEXP", "Promotions", "T-Creds", "SkillUp"
+ ] = "OperatorEXP"
+ WeaponProgression: Literal["WeaponEXP", "WeaponTune"] = "WeaponEXP"
+ CrisisDrills: Literal[
+ "AdvancedProgression1",
+ "AdvancedProgression2",
+ "AdvancedProgression3",
+ "AdvancedProgression4",
+ "AdvancedProgression5",
+ ] = "AdvancedProgression1"
+ RewardsSetOption: Literal["RewardsSetA", "RewardsSetB"] = "RewardsSetA"
+
+ class DataModel(BaseModel):
+ LastProxyDate: str = "2000-01-01"
+ ProxyTimes: NonNegativeInt = Field(default=0, le=9999)
+ LastProxyStatus: Literal["未知", "成功", "失败"] = "未知"
+ LastSklandDate: str = "2000-01-01"
+ IfPassCheck: bool = True
+
+ @field_validator("LastProxyDate", "LastSklandDate", mode="before")
+ @classmethod
+ def _normalize_ymd(cls, value: Any) -> str:
+ text = value if isinstance(value, str) else str(value)
+ try:
+ datetime.strptime(text, "%Y-%m-%d")
+ return text
+ except ValueError:
+ return "2000-01-01"
+
+ class NotifyModel(BaseModel):
+ Enabled: bool = False
+ IfSendStatistic: bool = False
+ IfSendMail: bool = False
+ ToAddress: str = ""
+ IfServerChan: bool = False
+ ServerChanKey: str = ""
+
+ Info: InfoModel = Field(default_factory=InfoModel)
+ Task: TaskModel = Field(default_factory=TaskModel)
+ Data: DataModel = Field(default_factory=DataModel)
+ Notify: NotifyModel = Field(default_factory=NotifyModel)
+
+ def getTags(self) -> str: # noqa: N802
+ """生成用户标签列表,返回JSON字符串格式的TagItem列表"""
+ tags: list[dict[str, str]] = []
+
+ if not self.get("Data", "IfPassCheck"):
+ tags.append({"text": "人工排查未通过", "color": "red"})
+
+ tags.append(
+ {
+ "text": f"上次:{self.get('Data', 'LastProxyStatus')}",
+ "color": "red"
+ if self.get("Data", "LastProxyStatus") == "失败"
+ else "green",
+ }
+ )
+
+ tags.append(
+ proxy_date_tag(
+ self.get("Data", "LastProxyDate"),
+ self.get("Data", "ProxyTimes"),
+ UTC4,
+ )
+ )
+
+ if self.get("Info", "IfSkland"):
+ if (
+ datetime.strptime(self.get("Data", "LastSklandDate"), "%Y-%m-%d").date()
+ == datetime.now(tz=UTC8).date()
+ ):
+ tags.append({"text": "森空岛:已签到", "color": "green"})
+ else:
+ tags.append({"text": "森空岛:未签到", "color": "orange"})
+ else:
+ tags.append({"text": "森空岛:禁用", "color": "red"})
+
+ tags.append(remained_day_tag(self.get("Info", "RemainedDay")))
+
+ stage = self.get("Task", self.get("Task", "ProtocolSpaceTab"))
+ stage_ab = (
+ f" - {self.get('Task', 'RewardsSetOption')[-1]}"
+ if stage in MAAEND_STAGE_WITH_AB
+ else ""
+ )
+ tags.append({"text": MAAEND_STAGE_BOOK[stage] + stage_ab, "color": "blue"})
+
+ tags.append(notes_tag(self.get("Info", "Notes")))
+
+ return json.dumps(tags, ensure_ascii=False)
+
+
+@config
+@sub_configs(UserData=[MaaEndUserConfig])
+class MaaEndConfig(PydanticConfigBase):
+ """MaaEnd配置"""
+
+ related_config: ClassVar[dict[str, MultipleConfig[Any]]] = {}
+
+ class InfoModel(BaseModel):
+ Name: str = "新 MaaEnd 脚本"
+ Path: str = str(Path.cwd())
+
+ class RunModel(BaseModel):
+ RunTimeLimit: PositiveInt = Field(default=10, le=9999)
+ ProxyTimesLimit: NonNegativeInt = Field(default=0, le=9999)
+ RunTimesLimit: PositiveInt = Field(default=3, le=9999)
+
+ class GameModel(BaseModel):
+ ControllerType: Literal[
+ "Win32-Window", "Win32-Front", "Win32-Window-Background", "ADB"
+ ] = "Win32-Window"
+ Path: str = str(Path.cwd())
+ Arguments: str = ""
+ WaitTime: NonNegativeInt = Field(default=0, le=9999)
+ EmulatorId: Annotated[
+ str,
+ ref(
+ "EmulatorConfig",
+ default="-",
+ allow_values=("-",),
+ on_delete="set_default",
+ ),
+ ] = "-"
+ EmulatorIndex: str = "-"
+ CloseOnFinish: bool = True
+
+ Info: InfoModel = Field(default_factory=InfoModel)
+ Run: RunModel = Field(default_factory=RunModel)
+ Game: GameModel = Field(default_factory=GameModel)
+
+
+__all__ = ["MaaEndUserConfig", "MaaEndConfig"]
diff --git a/app/models/plugin.py b/app/models/plugin.py
new file mode 100644
index 00000000..996b1f97
--- /dev/null
+++ b/app/models/plugin.py
@@ -0,0 +1,33 @@
+from __future__ import annotations
+
+import json
+from typing import Any, cast
+
+from pydantic import BaseModel, Field
+
+from app.core.config.pydantic import PydanticConfigBase
+from app.core.config.types import JsonDictString
+
+
+class PluginInstanceConfig(PydanticConfigBase):
+ """插件实例配置"""
+
+ class InfoModel(BaseModel):
+ Plugin: str = ""
+ Id: str = ""
+ Name: str = "新插件实例"
+ Enabled: bool = True
+
+ class DataModel(BaseModel):
+ Config: JsonDictString = Field(default="{ }")
+
+ def config_dict(self) -> dict[str, Any]:
+ try:
+ parsed = json.loads(self.Config)
+ except json.JSONDecodeError:
+ return {}
+
+ return cast(dict[str, Any], parsed) if isinstance(parsed, dict) else {}
+
+ Info: InfoModel = Field(default_factory=InfoModel)
+ Data: DataModel = Field(default_factory=DataModel)
diff --git a/app/models/schema.py b/app/models/schema.py
deleted file mode 100644
index 682de19c..00000000
--- a/app/models/schema.py
+++ /dev/null
@@ -1,1461 +0,0 @@
-# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
-# Copyright © 2024-2025 DLmaster361
-# Copyright © 2025 MoeSnowyFox
-# Copyright © 2025-2026 AUTO-MAS Team
-
-# This file is part of AUTO-MAS.
-
-# AUTO-MAS is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as
-# published by the Free Software Foundation, either version 3 of
-# the License, or (at your option) any later version.
-
-# AUTO-MAS is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty
-# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
-# the GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with AUTO-MAS. If not, see .
-
-# Contact: DLmaster_361@163.com
-
-
-from pydantic import BaseModel, Field
-from typing import Any, Dict, List, Union, Optional, Literal
-
-
-class OutBase(BaseModel):
- code: int = Field(default=200, description="状态码")
- status: str = Field(default="success", description="操作状态")
- message: str = Field(default="操作成功", description="操作消息")
-
-
-class InfoOut(OutBase):
- data: Dict[str, Any] = Field(..., description="收到的服务器数据")
-
-
-class VersionOut(OutBase):
- if_need_update: bool = Field(..., description="后端代码是否需要更新")
- current_time: str = Field(..., description="后端代码当前时间戳")
- current_hash: str = Field(..., description="后端代码当前哈希值")
-
-
-class NoticeOut(OutBase):
- if_need_show: bool = Field(..., description="是否需要显示公告")
- data: Dict[str, str] = Field(
- ..., description="公告信息, key为公告标题, value为公告内容"
- )
-
-
-class TagItem(BaseModel):
- text: str = Field(..., description="标签文本")
- color: Literal[
- "red",
- "blue",
- "green",
- "yellow",
- "orange",
- "purple",
- "pink",
- "brown",
- "black",
- "white",
- "gray",
- "silver",
- "gold",
- ] = Field(..., description="标签颜色")
-
-
-class ComboBoxItem(BaseModel):
- label: str = Field(..., description="展示值")
- value: Optional[str] = Field(..., description="实际值")
-
-
-class ComboBoxOut(OutBase):
- data: List[ComboBoxItem] = Field(..., description="下拉框选项")
-
-
-class GetStageIn(BaseModel):
- type: Literal[
- "User",
- "Today",
- "ALL",
- "Monday",
- "Tuesday",
- "Wednesday",
- "Thursday",
- "Friday",
- "Saturday",
- "Sunday",
- ] = Field(
- ...,
- description="选择的日期类型, Today为当天, ALL为包含当天未开放关卡在内的所有项",
- )
-
-
-class EmulatorConfigIndexItem(BaseModel):
- uid: str = Field(..., description="唯一标识符")
- type: Literal["EmulatorConfig"] = Field(..., description="配置类型")
-
-
-class EmulatorConfig_Info(BaseModel):
- Name: Optional[str] = Field(default=None, description="模拟器名称")
- Type: Optional[Literal["general", "mumu", "ldplayer"]] = Field(
- default=None, description="模拟器类型"
- )
- Path: Optional[str] = Field(default=None, description="模拟器路径")
- BossKey: Optional[str] = Field(default=None, description="老板键快捷键配置")
- MaxWaitTime: Optional[int] = Field(default=None, description="最大等待时间(秒)")
-
-
-class EmulatorConfig(BaseModel):
- Info: Optional[EmulatorConfig_Info] = Field(
- default=None, description="模拟器基础信息"
- )
-
-
-class ToolsConfig_ArknightsPC(BaseModel):
- Enabled: bool | None = Field(default=None, description="是否启用 ArknightsPC 工具")
- PauseKey: str | None = Field(default=None, description="暂停键位")
- SelectDeployedKey: str | None = Field(
- default=None, description="选中已部署干员键位"
- )
- UseSkillKey: str | None = Field(default=None, description="释放技能键位")
- RetreatKey: str | None = Field(default=None, description="撤退键位")
- NextFrameKey: str | None = Field(default=None, description="下一帧键位")
- AnotherQuitKey: str | None = Field(default=None, description="自定义退出、暂停键位")
- Status: str | None = Field(default=None, description="工具状态 Tag")
-
-
-class ToolsConfig(BaseModel):
- ArknightsPC: ToolsConfig_ArknightsPC | None = Field(
- default=None, description="明日方舟PC工具配置"
- )
-
-
-class WebhookIndexItem(BaseModel):
- uid: str = Field(..., description="唯一标识符")
- type: Literal["Webhook"] = Field(..., description="配置类型")
-
-
-class Webhook_Info(BaseModel):
- Name: Optional[str] = Field(default=None, description="Webhook名称")
- Enabled: Optional[bool] = Field(default=None, description="是否启用")
-
-
-class Webhook_Data(BaseModel):
- Url: Optional[str] = Field(default=None, description="Webhook URL")
- Template: Optional[str] = Field(default=None, description="消息模板")
- Headers: Optional[str] = Field(default=None, description="自定义请求头")
- Method: Optional[Literal["POST", "GET"]] = Field(
- default=None, description="请求方法"
- )
-
-
-class Webhook(BaseModel):
- Info: Optional[Webhook_Info] = Field(default=None, description="Webhook基础信息")
- Data: Optional[Webhook_Data] = Field(default=None, description="Webhook配置数据")
-
-
-class GlobalConfig_Function(BaseModel):
- HistoryRetentionTime: Optional[Literal[7, 15, 30, 60, 90, 180, 365, 0]] = Field(
- None, description="历史记录保留时间, 0表示永久保存"
- )
- IfAllowSleep: Optional[bool] = Field(default=None, description="允许休眠")
- IfSilence: Optional[bool] = Field(default=None, description="静默模式")
- IfAgreeBilibili: Optional[bool] = Field(
- default=None, description="同意哔哩哔哩用户协议"
- )
- IfBlockAd: Optional[bool] = Field(default=None, description="屏蔽模拟器广告")
-
-
-class GlobalConfig_Voice(BaseModel):
- Enabled: Optional[bool] = Field(default=None, description="语音功能是否启用")
- Type: Optional[Literal["simple", "noisy"]] = Field(
- default=None, description="语音类型, simple为简洁, noisy为聒噪"
- )
-
-
-class GlobalConfig_Start(BaseModel):
- IfSelfStart: Optional[bool] = Field(
- default=None, description="是否在系统启动时自动运行"
- )
- IfMinimizeDirectly: Optional[bool] = Field(
- default=None, description="启动时是否直接最小化到托盘而不显示主窗口"
- )
-
-
-class GlobalConfig_UI(BaseModel):
- IfShowTray: Optional[bool] = Field(default=None, description="是否常态显示托盘图标")
- IfToTray: Optional[bool] = Field(default=None, description="是否最小化到托盘")
-
-
-class GlobalConfig_Notify(BaseModel):
- SendTaskResultTime: Optional[Literal["不推送", "任何时刻", "仅失败时"]] = Field(
- default=None, description="任务结果推送时机"
- )
- IfSendStatistic: Optional[bool] = Field(
- default=None, description="是否发送统计信息"
- )
- IfSendSixStar: Optional[bool] = Field(
- default=None, description="是否发送公招六星通知"
- )
- IfPushPlyer: Optional[bool] = Field(default=None, description="是否推送系统通知")
- IfSendMail: Optional[bool] = Field(default=None, description="是否发送邮件通知")
- IfKoishiSupport: Optional[bool] = Field(
- default=None, description="是否启用Koishi支持"
- )
- KoishiServerAddress: Optional[str] = Field(
- default=None, description="Koishi服务器地址"
- )
- KoishiToken: Optional[str] = Field(default=None, description="Koishi Token")
- SMTPServerAddress: Optional[str] = Field(default=None, description="SMTP服务器地址")
- AuthorizationCode: Optional[str] = Field(default=None, description="SMTP授权码")
- FromAddress: Optional[str] = Field(default=None, description="邮件发送地址")
- ToAddress: Optional[str] = Field(default=None, description="邮件接收地址")
- IfServerChan: Optional[bool] = Field(
- default=None, description="是否使用ServerChan推送"
- )
- ServerChanKey: Optional[str] = Field(default=None, description="ServerChan推送密钥")
-
-
-class GlobalConfig_Update(BaseModel):
- IfAutoUpdate: Optional[bool] = Field(default=None, description="是否自动更新")
- Source: Optional[Literal["GitHub", "MirrorChyan", "AutoSite", "CNB"]] = Field(
- default=None, description="更新源: GitHub源, Mirror酱源, 自建源, CNB 镜像源"
- )
- Channel: Optional[Literal["stable", "beta"]] = Field(
- default=None, description="更新渠道: 稳定版, 测试版"
- )
- ProxyAddress: Optional[str] = Field(default=None, description="网络代理地址")
- MirrorChyanCDK: Optional[str] = Field(default=None, description="Mirror酱CDK")
-
-
-class GlobalConfig(BaseModel):
- Function: Optional[GlobalConfig_Function] = Field(
- default=None, description="功能相关配置"
- )
- Voice: Optional[GlobalConfig_Voice] = Field(
- default=None, description="语音相关配置"
- )
- Start: Optional[GlobalConfig_Start] = Field(
- default=None, description="启动相关配置"
- )
- UI: Optional[GlobalConfig_UI] = Field(default=None, description="界面相关配置")
- Notify: Optional[GlobalConfig_Notify] = Field(
- default=None, description="通知相关配置"
- )
- Update: Optional[GlobalConfig_Update] = Field(
- default=None, description="更新相关配置"
- )
-
-
-class QueueIndexItem(BaseModel):
- uid: str = Field(..., description="唯一标识符")
- type: Literal["QueueConfig"] = Field(..., description="配置类型")
-
-
-class QueueItemIndexItem(BaseModel):
- uid: str = Field(..., description="唯一标识符")
- type: Literal["QueueItem"] = Field(..., description="配置类型")
-
-
-class TimeSetIndexItem(BaseModel):
- uid: str = Field(..., description="唯一标识符")
- type: Literal["TimeSet"] = Field(..., description="配置类型")
-
-
-class QueueItem_Info(BaseModel):
- ScriptId: Optional[str] = Field(
- default=None, description="任务所对应的脚本ID, 为None时表示未选择"
- )
-
-
-class QueueItem(BaseModel):
- Info: Optional[QueueItem_Info] = Field(default=None, description="队列项")
-
-
-class TimeSet_Info(BaseModel):
- Enabled: Optional[bool] = Field(default=None, description="是否启用")
- Days: Optional[
- List[
- Literal[
- "Monday",
- "Tuesday",
- "Wednesday",
- "Thursday",
- "Friday",
- "Saturday",
- "Sunday",
- ]
- ]
- ] = Field(default=None, description="执行周期, 可多选")
- Time: Optional[str] = Field(default=None, description="时间设置, 格式为HH:MM")
-
-
-class TimeSet(BaseModel):
- Info: Optional[TimeSet_Info] = Field(default=None, description="时间项")
-
-
-class QueueConfig_Info(BaseModel):
- Name: Optional[str] = Field(default=None, description="队列名称")
- TimeEnabled: Optional[bool] = Field(default=None, description="是否启用定时")
- StartUpEnabled: Optional[bool] = Field(default=None, description="是否启动时运行")
- AfterAccomplish: Optional[
- Literal[
- "NoAction",
- "Shutdown",
- "ShutdownForce",
- "Reboot",
- "Hibernate",
- "Sleep",
- "KillSelf",
- ]
- ] = Field(default=None, description="完成后操作")
-
-
-class QueueConfig(BaseModel):
- Info: Optional[QueueConfig_Info] = Field(default=None, description="队列信息")
-
-
-class ScriptIndexItem(BaseModel):
- uid: str = Field(..., description="唯一标识符")
- type: Literal["MaaConfig", "GeneralConfig", "SrcConfig", "MaaEndConfig"] = Field(
- ..., description="配置类型"
- )
-
-
-class UserIndexItem(BaseModel):
- uid: str = Field(..., description="唯一标识符")
- type: Literal[
- "MaaUserConfig", "GeneralUserConfig", "SrcUserConfig", "MaaEndUserConfig"
- ] = Field(..., description="配置类型")
-
-
-class MaaUserConfig_Info(BaseModel):
- Name: Optional[str] = Field(default=None, description="用户名")
- Id: Optional[str] = Field(default=None, description="用户ID")
- Mode: Optional[Literal["简洁", "详细"]] = Field(
- default=None, description="用户配置模式"
- )
- StageMode: Optional[str] = Field(default=None, description="关卡配置模式")
- Server: Optional[
- Literal["Official", "Bilibili", "YoStarEN", "YoStarJP", "YoStarKR", "txwy"]
- ] = Field(default=None, description="服务器")
- Status: Optional[bool] = Field(default=None, description="用户状态")
- RemainedDay: Optional[int] = Field(default=None, description="剩余天数")
- Annihilation: Optional[
- Literal[
- "Close",
- "Annihilation",
- "Chernobog@Annihilation",
- "LungmenOutskirts@Annihilation",
- "LungmenDowntown@Annihilation",
- ]
- ] = Field(default=None, description="剿灭模式")
- InfrastMode: Optional[Literal["Normal", "Rotation", "Custom"]] = Field(
- default=None, description="基建模式"
- )
- InfrastName: Optional[str] = Field(default=None, description="基建方案名称")
- InfrastIndex: Optional[str] = Field(default=None, description="基建方案索引")
- Password: Optional[str] = Field(default=None, description="密码")
- Notes: Optional[str] = Field(default=None, description="备注")
- MedicineNumb: Optional[int] = Field(default=None, description="吃理智药数量")
- SeriesNumb: Optional[Literal["0", "6", "5", "4", "3", "2", "1", "-1"]] = Field(
- default=None, description="连战次数"
- )
- Stage: Optional[str] = Field(default=None, description="关卡选择")
- Stage_1: Optional[str] = Field(default=None, description="备选关卡 - 1")
- Stage_2: Optional[str] = Field(default=None, description="备选关卡 - 2")
- Stage_3: Optional[str] = Field(default=None, description="备选关卡 - 3")
- Stage_Remain: Optional[str] = Field(default=None, description="剩余理智关卡")
- IfSkland: Optional[bool] = Field(default=None, description="是否启用森空岛签到")
- SklandToken: Optional[str] = Field(default=None, description="SklandToken")
- Tag: Optional[str] = Field(default=None, description="状态标签列表")
-
-
-class MaaUserConfig_Data(BaseModel):
- IfPassCheck: Optional[bool] = Field(default=None, description="是否通过人工排查")
-
-
-class MaaUserConfig_Task(BaseModel):
- IfStartUp: Optional[bool] = Field(default=None, description="开始唤醒")
- IfRecruit: Optional[bool] = Field(default=None, description="自动公招")
- IfInfrast: Optional[bool] = Field(default=None, description="基建换班")
- IfFight: Optional[bool] = Field(default=None, description="理智作战")
- IfMall: Optional[bool] = Field(default=None, description="信用收支")
- IfAward: Optional[bool] = Field(default=None, description="领取奖励")
- IfRoguelike: Optional[bool] = Field(default=None, description="自动肉鸽")
- IfReclamation: Optional[bool] = Field(default=None, description="生息演算")
-
-
-class MaaUserConfig_Notify(BaseModel):
- Enabled: Optional[bool] = Field(default=None, description="是否启用通知")
- IfSendStatistic: Optional[bool] = Field(
- default=None, description="是否发送统计信息"
- )
- IfSendSixStar: Optional[bool] = Field(default=None, description="是否发送高资喜报")
- IfSendMail: Optional[bool] = Field(default=None, description="是否发送邮件通知")
- ToAddress: Optional[str] = Field(default=None, description="邮件接收地址")
- IfServerChan: Optional[bool] = Field(
- default=None, description="是否使用Server酱推送"
- )
- ServerChanKey: Optional[str] = Field(default=None, description="ServerChanKey")
-
-
-class GeneralUserConfig_Notify(BaseModel):
- Enabled: Optional[bool] = Field(default=None, description="是否启用通知")
- IfSendStatistic: Optional[bool] = Field(
- default=None, description="是否发送统计信息"
- )
- IfSendMail: Optional[bool] = Field(default=None, description="是否发送邮件通知")
- ToAddress: Optional[str] = Field(default=None, description="邮件接收地址")
- IfServerChan: Optional[bool] = Field(
- default=None, description="是否使用Server酱推送"
- )
- ServerChanKey: Optional[str] = Field(default=None, description="ServerChanKey")
-
-
-class MaaUserConfig(BaseModel):
- Info: Optional[MaaUserConfig_Info] = Field(default=None, description="基础信息")
- Data: Optional[MaaUserConfig_Data] = Field(default=None, description="用户数据")
- Task: Optional[MaaUserConfig_Task] = Field(default=None, description="任务列表")
- Notify: Optional[MaaUserConfig_Notify] = Field(default=None, description="单独通知")
-
-
-class MaaConfig_Info(BaseModel):
- Name: Optional[str] = Field(default=None, description="脚本名称")
- Path: Optional[str] = Field(default=None, description="脚本路径")
-
-
-class MaaConfig_Emulator(BaseModel):
- Id: Optional[str] = Field(default=None, description="模拟器ID")
- Index: Optional[str] = Field(default=None, description="模拟器多开实例索引")
-
-
-class MaaConfig_Run(BaseModel):
- TaskTransitionMethod: Optional[Literal["NoAction", "ExitGame", "ExitEmulator"]] = (
- Field(default=None, description="简洁任务间切换方式")
- )
- ProxyTimesLimit: Optional[int] = Field(default=None, description="每日代理次数限制")
- RunTimesLimit: Optional[int] = Field(default=None, description="重试次数限制")
- AnnihilationTimeLimit: Optional[int] = Field(
- default=None, description="剿灭超时限制"
- )
- RoutineTimeLimit: Optional[int] = Field(default=None, description="日常超时限制")
- AnnihilationAvoidWaste: Optional[bool] = Field(
- default=None, description="剿灭避免无代理卡浪费理智"
- )
-
-
-class MaaConfig(BaseModel):
- Info: Optional[MaaConfig_Info] = Field(default=None, description="脚本基础信息")
- Emulator: Optional[MaaConfig_Emulator] = Field(
- default=None, description="模拟器配置"
- )
- Run: Optional[MaaConfig_Run] = Field(default=None, description="脚本运行配置")
-
-
-class GeneralUserConfig_Info(BaseModel):
- Name: Optional[str] = Field(default=None, description="用户名")
- Status: Optional[bool] = Field(default=None, description="用户状态")
- RemainedDay: Optional[int] = Field(default=None, description="剩余天数")
- IfScriptBeforeTask: Optional[bool] = Field(
- default=None, description="是否在任务前执行脚本"
- )
- ScriptBeforeTask: Optional[str] = Field(default=None, description="任务前脚本路径")
- IfScriptAfterTask: Optional[bool] = Field(
- default=None, description="是否在任务后执行脚本"
- )
- ScriptAfterTask: Optional[str] = Field(default=None, description="任务后脚本路径")
- Notes: Optional[str] = Field(default=None, description="备注")
- Tag: Optional[str] = Field(
- default=None, description="用户标签列表(JSON字符串,TagItem的dict列表)"
- )
-
-
-class GeneralUserConfig_Data(BaseModel):
- LastProxyDate: Optional[str] = Field(default=None, description="上次代理日期")
- ProxyTimes: Optional[int] = Field(default=None, description="代理次数")
-
-
-class GeneralUserConfig(BaseModel):
- Info: Optional[GeneralUserConfig_Info] = Field(default=None, description="用户信息")
- Data: Optional[GeneralUserConfig_Data] = Field(default=None, description="用户数据")
- Notify: Optional[GeneralUserConfig_Notify] = Field(
- default=None, description="单独通知"
- )
-
-
-class GeneralConfig_Info(BaseModel):
- Name: Optional[str] = Field(default=None, description="脚本名称")
- RootPath: Optional[str] = Field(default=None, description="脚本根目录")
-
-
-class GeneralConfig_Script(BaseModel):
- ScriptPath: Optional[str] = Field(default=None, description="脚本可执行文件路径")
- Arguments: Optional[str] = Field(default=None, description="脚本启动附加命令参数")
- IfTrackProcess: Optional[bool] = Field(
- default=None, description="是否追踪脚本子进程"
- )
- TrackProcessName: Optional[str] = Field(default=None, description="追踪进程名称")
- TrackProcessExe: Optional[str] = Field(default=None, description="追踪进程文件路径")
- TrackProcessCmdline: Optional[str] = Field(
- default=None, description="追踪进程启动命令行参数"
- )
- ConfigPath: Optional[str] = Field(default=None, description="配置文件路径")
- ConfigPathMode: Optional[Literal["File", "Folder"]] = Field(
- default=None, description="配置文件类型: 单个文件, 文件夹"
- )
- UpdateConfigMode: Optional[Literal["Never", "Success", "Failure", "Always"]] = (
- Field(
- default=None,
- description="更新配置时机, 从不, 仅成功时, 仅失败时, 任务结束时",
- )
- )
- LogPath: Optional[str] = Field(default=None, description="日志文件路径")
- LogPathFormat: Optional[str] = Field(default=None, description="日志文件名格式")
- LogTimeStart: Optional[int] = Field(default=None, description="日志时间戳开始位置")
- LogTimeEnd: Optional[int] = Field(default=None, description="日志时间戳结束位置")
- LogTimeFormat: Optional[str] = Field(default=None, description="日志时间戳格式")
- SuccessLog: Optional[str] = Field(default=None, description="成功时日志")
- ErrorLog: Optional[str] = Field(default=None, description="错误时日志")
-
-
-class GeneralConfig_Game(BaseModel):
- Enabled: Optional[bool] = Field(
- default=None, description="游戏/模拟器相关功能是否启用"
- )
- Type: Optional[Literal["Emulator", "Client", "URL"]] = Field(
- default=None, description="类型: 模拟器, PC端, URL协议"
- )
- Path: Optional[str] = Field(default=None, description="游戏/模拟器程序路径")
- URL: Optional[str] = Field(default=None, description="自定义协议URL")
- ProcessName: Optional[str] = Field(default=None, description="游戏进程名称")
- Arguments: Optional[str] = Field(default=None, description="游戏/模拟器启动参数")
- WaitTime: Optional[int] = Field(default=None, description="游戏/模拟器等待启动时间")
- IfForceClose: Optional[bool] = Field(
- default=None, description="是否强制关闭游戏/模拟器进程"
- )
- EmulatorId: Optional[str] = Field(default=None, description="模拟器ID")
- EmulatorIndex: Optional[str] = Field(default=None, description="模拟器多开实例索引")
-
-
-class GeneralConfig_Run(BaseModel):
- ProxyTimesLimit: Optional[int] = Field(default=None, description="每日代理次数限制")
- RunTimesLimit: Optional[int] = Field(default=None, description="重试次数限制")
- RunTimeLimit: Optional[int] = Field(default=None, description="日志超时限制")
-
-
-class GeneralConfig(BaseModel):
- Info: Optional[GeneralConfig_Info] = Field(default=None, description="脚本基础信息")
- Script: Optional[GeneralConfig_Script] = Field(default=None, description="脚本配置")
- Game: Optional[GeneralConfig_Game] = Field(default=None, description="游戏配置")
- Run: Optional[GeneralConfig_Run] = Field(default=None, description="运行配置")
-
-
-class MaaEndUserConfig_Info(BaseModel):
- Name: Optional[str] = Field(default=None, description="用户名")
- Status: Optional[bool] = Field(default=None, description="用户状态")
- Id: Optional[str] = Field(default=None, description="用户ID")
- Password: Optional[str] = Field(default=None, description="密码")
- Mode: Optional[Literal["简洁", "详细"]] = Field(
- default=None, description="配置模式"
- )
- Resource: Optional[Literal["官服"]] = Field(default=None, description="资源名称")
- RemainedDay: Optional[int] = Field(default=None, description="剩余天数")
- Notes: Optional[str] = Field(default=None, description="备注")
- IfSkland: Optional[bool] = Field(default=None, description="是否启用森空岛签到")
- SklandToken: Optional[str] = Field(default=None, description="SklandToken")
- Tag: Optional[str] = Field(default=None, description="用户标签信息")
-
-
-class MaaEndUserConfig_Task(BaseModel):
- ProtocolSpaceTab: Optional[
- Literal["OperatorProgression", "WeaponProgression", "CrisisDrills"]
- ] = Field(default=None, description="协议空间选项卡")
- OperatorProgression: Optional[
- Literal["OperatorEXP", "Promotions", "T-Creds", "SkillUp"]
- ] = Field(default=None, description="干员养成任务")
- WeaponProgression: Optional[Literal["WeaponEXP", "WeaponTune"]] = Field(
- default=None, description="武器养成任务"
- )
- CrisisDrills: Optional[
- Literal[
- "AdvancedProgression1",
- "AdvancedProgression2",
- "AdvancedProgression3",
- "AdvancedProgression4",
- "AdvancedProgression5",
- ]
- ] = Field(default=None, description="危境预演任务")
- RewardsSetOption: Optional[Literal["RewardsSetA", "RewardsSetB"]] = Field(
- default=None, description="奖励套组选项"
- )
-
-
-class MaaEndUserConfig_Notify(BaseModel):
- Enabled: Optional[bool] = Field(default=None, description="是否启用通知")
- IfSendStatistic: Optional[bool] = Field(
- default=None, description="是否发送统计信息"
- )
- IfSendMail: Optional[bool] = Field(default=None, description="是否发送邮件")
- ToAddress: Optional[str] = Field(default=None, description="收件地址")
- IfServerChan: Optional[bool] = Field(default=None, description="是否启用Server酱")
- ServerChanKey: Optional[str] = Field(default=None, description="Server酱密钥")
-
-
-class MaaEndUserConfig(BaseModel):
- Info: Optional[MaaEndUserConfig_Info] = Field(default=None, description="用户信息")
- Task: Optional[MaaEndUserConfig_Task] = Field(default=None, description="任务配置")
- Notify: Optional[MaaEndUserConfig_Notify] = Field(
- default=None, description="通知配置"
- )
-
-
-class MaaEndConfig_Info(BaseModel):
- Name: Optional[str] = Field(default=None, description="脚本名称")
- Path: Optional[str] = Field(default=None, description="脚本路径")
-
-
-class MaaEndConfig_Run(BaseModel):
- RunTimeLimit: Optional[int] = Field(
- default=None, description="运行时间限制(分钟)"
- )
- ProxyTimesLimit: Optional[int] = Field(default=None, description="每日代理次数限制")
- RunTimesLimit: Optional[int] = Field(default=None, description="重试次数限制")
-
-
-class MaaEndConfig_Game(BaseModel):
- ControllerType: Optional[
- Literal["Win32-Window", "Win32-Window-Background", "Win32-Front", "ADB"]
- ] = Field(default=None, description="控制器类型")
- Path: Optional[str] = Field(default=None, description="终末地客户端路径")
- Arguments: Optional[str] = Field(default=None, description="游戏启动参数")
- WaitTime: Optional[int] = Field(default=None, description="游戏等待时间")
- EmulatorId: Optional[str] = Field(default=None, description="模拟器ID")
- EmulatorIndex: Optional[str] = Field(default=None, description="模拟器索引")
- CloseOnFinish: Optional[bool] = Field(default=None, description="结束后关闭游戏")
-
-
-class MaaEndConfig(BaseModel):
- Info: Optional[MaaEndConfig_Info] = Field(default=None, description="脚本信息")
- Run: Optional[MaaEndConfig_Run] = Field(default=None, description="运行配置")
- Game: Optional[MaaEndConfig_Game] = Field(default=None, description="游戏配置")
-
-
-class SrcUserConfig_Info(BaseModel):
- Name: Optional[str] = Field(default=None, description="用户名称")
- Status: Optional[bool] = Field(default=None, description="是否启用")
- Id: Optional[str] = Field(default=None, description="用户ID")
- Password: Optional[str] = Field(default=None, description="密码")
- Mode: Optional[Literal["简洁", "详细"]] = Field(
- default=None, description="脚本模式"
- )
- Server: Optional[
- Literal[
- "CN-Official",
- "CN-Bilibili",
- "VN-Official",
- "OVERSEA-America",
- "OVERSEA-Asia",
- "OVERSEA-Europe",
- "OVERSEA-TWHKMO",
- ]
- ] = Field(default=None, description="游戏服务器")
- RemainedDay: Optional[int] = Field(default=None, description="剩余天数")
- Notes: Optional[str] = Field(default=None, description="备注")
- Tag: Optional[str] = Field(default=None, description="用户标签信息")
-
-
-class SrcUserConfig_Stage(BaseModel):
- Channel: Literal["Relic", "Materials", "Ornament"] | None = Field(
- default=None, description="关卡通道"
- )
- Relic: (
- Literal[
- "-",
- "Cavern_of_Corrosion_Path_of_Possession",
- "Cavern_of_Corrosion_Path_of_Hidden_Salvation",
- "Cavern_of_Corrosion_Path_of_Thundersurge",
- "Cavern_of_Corrosion_Path_of_Aria",
- "Cavern_of_Corrosion_Path_of_Uncertainty",
- "Cavern_of_Corrosion_Path_of_Cavalier",
- "Cavern_of_Corrosion_Path_of_Dreamdive",
- "Cavern_of_Corrosion_Path_of_Darkness",
- "Cavern_of_Corrosion_Path_of_Elixir_Seekers",
- "Cavern_of_Corrosion_Path_of_Conflagration",
- "Cavern_of_Corrosion_Path_of_Holy_Hymn",
- "Cavern_of_Corrosion_Path_of_Providence",
- "Cavern_of_Corrosion_Path_of_Drifting",
- "Cavern_of_Corrosion_Path_of_Jabbing_Punch",
- "Cavern_of_Corrosion_Path_of_Gelid_Wind",
- ]
- | None
- ) = Field(default=None, description="遗器关卡")
- Materials: (
- Literal[
- "-",
- "Calyx_Golden_Memories_Planarcadia",
- "Calyx_Golden_Aether_Planarcadia",
- "Calyx_Golden_Treasures_Planarcadia",
- "Calyx_Golden_Memories_Amphoreus",
- "Calyx_Golden_Aether_Amphoreus",
- "Calyx_Golden_Treasures_Amphoreus",
- "Calyx_Golden_Memories_Penacony",
- "Calyx_Golden_Aether_Penacony",
- "Calyx_Golden_Treasures_Penacony",
- "Calyx_Golden_Memories_The_Xianzhou_Luofu",
- "Calyx_Golden_Aether_The_Xianzhou_Luofu",
- "Calyx_Golden_Treasures_The_Xianzhou_Luofu",
- "Calyx_Golden_Memories_Jarilo_VI",
- "Calyx_Golden_Aether_Jarilo_VI",
- "Calyx_Golden_Treasures_Jarilo_VI",
- "Calyx_Crimson_Destruction_Herta_StorageZone",
- "Calyx_Crimson_Destruction_Luofu_ScalegorgeWaterscape",
- "Calyx_Crimson_Preservation_Herta_SupplyZone",
- "Calyx_Crimson_Preservation_Penacony_ClockStudiosThemePark",
- "Calyx_Crimson_The_Hunt_Jarilo_OutlyingSnowPlains",
- "Calyx_Crimson_The_Hunt_Penacony_SoulGladScorchsandAuditionVenue",
- "Calyx_Crimson_The_Hunt_Amphoreus_MemortisShoreRuinsofTime",
- "Calyx_Crimson_Abundance_Jarilo_BackwaterPass",
- "Calyx_Crimson_Abundance_Luofu_FyxestrollGarden",
- "Calyx_Crimson_Erudition_Jarilo_RivetTown",
- "Calyx_Crimson_Erudition_Penacony_PenaconyGrandTheater",
- "Calyx_Crimson_Harmony_Jarilo_RobotSettlement",
- "Calyx_Crimson_Harmony_Penacony_TheReverieDreamscape",
- "Calyx_Crimson_Nihility_Jarilo_GreatMine",
- "Calyx_Crimson_Nihility_Luofu_AlchemyCommission",
- "Calyx_Crimson_Remembrance_Amphoreus_StrifeRuinsCastrumKremnos",
- "Calyx_Crimson_Elation_Planarcadia_WorldEndTavern",
- "Stagnant_Shadow_Quanta",
- "Stagnant_Shadow_Gust",
- "Stagnant_Shadow_Fulmination",
- "Stagnant_Shadow_Blaze",
- "Stagnant_Shadow_Spike",
- "Stagnant_Shadow_Rime",
- "Stagnant_Shadow_Mirage",
- "Stagnant_Shadow_Icicle",
- "Stagnant_Shadow_Doom",
- "Stagnant_Shadow_Puppetry",
- "Stagnant_Shadow_Abomination",
- "Stagnant_Shadow_Scorch",
- "Stagnant_Shadow_Celestial",
- "Stagnant_Shadow_Perdition",
- "Stagnant_Shadow_Nectar",
- "Stagnant_Shadow_Roast",
- "Stagnant_Shadow_Ire",
- "Stagnant_Shadow_Duty",
- "Stagnant_Shadow_Timbre",
- "Stagnant_Shadow_Mechwolf",
- "Stagnant_Shadow_Gloam",
- "Stagnant_Shadow_Sloggyre",
- "Stagnant_Shadow_Gelidmoon",
- "Stagnant_Shadow_Deepsheaf",
- "Stagnant_Shadow_Cinders",
- "Stagnant_Shadow_Sirens",
- "Stagnant_Shadow_Ashes",
- "Stagnant_Shadow_Soundburst",
- ]
- | None
- ) = Field(default=None, description="材料关卡")
- Ornament: (
- Literal[
- "-",
- "Divergent_Universe_Within_the_West_Wind",
- "Divergent_Universe_Moonlit_Blood",
- "Divergent_Universe_Unceasing_Strife",
- "Divergent_Universe_Famished_Worker",
- "Divergent_Universe_Eternal_Comedy",
- "Divergent_Universe_To_Sweet_Dreams",
- "Divergent_Universe_Pouring_Blades",
- "Divergent_Universe_Fruit_of_Evil",
- "Divergent_Universe_Permafrost",
- "Divergent_Universe_Gentle_Words",
- "Divergent_Universe_Smelted_Heart",
- "Divergent_Universe_Untoppled_Walls",
- ]
- | None
- ) = Field(default=None, description="饰品关卡")
- ExtractReservedTrailblazePower: Optional[bool] = Field(
- default=None, description="使用储备开拓力"
- )
- UseFuel: Optional[bool] = Field(default=None, description="使用燃料")
- FuelReserve: Optional[int] = Field(default=None, description="保留的燃料数量")
- EchoOfWar: Optional[str] = Field(default=None, description="历战余响关卡")
- SimulatedUniverseWorld: Optional[str] = Field(
- default=None, description="模拟宇宙关卡"
- )
-
-
-class SrcUserConfig_Data(BaseModel):
- LastProxyDate: Optional[str] = Field(default=None, description="上次代理日期")
- ProxyTimes: Optional[int] = Field(default=None, description="代理次数")
- IfPassCheck: Optional[bool] = Field(default=None, description="是否通过检查")
-
-
-class SrcUserConfig_Notify(BaseModel):
- Enabled: Optional[bool] = Field(default=None, description="是否启用通知")
- IfSendStatistic: Optional[bool] = Field(
- default=None, description="是否发送统计信息"
- )
- IfSendMail: Optional[bool] = Field(default=None, description="是否发送邮件")
- ToAddress: Optional[str] = Field(default=None, description="收件地址")
- IfServerChan: Optional[bool] = Field(default=None, description="是否启用Server酱")
- ServerChanKey: Optional[str] = Field(default=None, description="Server酱密钥")
-
-
-class SrcUserConfig(BaseModel):
- Info: Optional[SrcUserConfig_Info] = Field(default=None, description="基础信息")
- Stage: Optional[SrcUserConfig_Stage] = Field(default=None, description="关卡配置")
- Data: Optional[SrcUserConfig_Data] = Field(default=None, description="用户数据")
- Notify: Optional[SrcUserConfig_Notify] = Field(default=None, description="单独通知")
-
-
-class SrcConfig_Info(BaseModel):
- Name: Optional[str] = Field(default=None, description="SRC脚本名称")
- Path: Optional[str] = Field(default=None, description="SRC路径")
-
-
-class SrcConfig_Emulator(BaseModel):
- Id: Optional[str] = Field(default=None, description="模拟器ID")
- Index: Optional[str] = Field(default=None, description="模拟器索引")
-
-
-class SrcConfig_Run(BaseModel):
- TaskTransitionMethod: Optional[Literal["ExitGame", "ExitEmulator"]] = Field(
- default=None, description="任务切换方式"
- )
- ProxyTimesLimit: Optional[int] = Field(default=None, description="代理次数限制")
- RunTimesLimit: Optional[int] = Field(default=None, description="运行次数限制")
- RunTimeLimit: Optional[int] = Field(
- default=None, description="运行时间限制(分钟)"
- )
-
-
-class SrcConfig(BaseModel):
- Info: Optional[SrcConfig_Info] = Field(default=None, description="脚本基础信息")
- Emulator: Optional[SrcConfig_Emulator] = Field(
- default=None, description="模拟器配置"
- )
- Run: Optional[SrcConfig_Run] = Field(default=None, description="脚本运行配置")
-
-
-class PlanIndexItem(BaseModel):
- uid: str = Field(..., description="唯一标识符")
- type: Literal["MaaPlanConfig"] = Field(..., description="配置类型")
-
-
-class MaaPlanConfig_Info(BaseModel):
- Name: Optional[str] = Field(default=None, description="计划表名称")
- Mode: Optional[Literal["ALL", "Weekly"]] = Field(
- default=None, description="计划表模式"
- )
-
-
-class MaaPlanConfig_Item(BaseModel):
- MedicineNumb: Optional[int] = Field(default=None, description="吃理智药")
- SeriesNumb: Optional[Literal["0", "6", "5", "4", "3", "2", "1", "-1"]] = Field(
- None, description="连战次数"
- )
- Stage: Optional[str] = Field(default=None, description="关卡选择")
- Stage_1: Optional[str] = Field(default=None, description="备选关卡 - 1")
- Stage_2: Optional[str] = Field(default=None, description="备选关卡 - 2")
- Stage_3: Optional[str] = Field(default=None, description="备选关卡 - 3")
- Stage_Remain: Optional[str] = Field(default=None, description="剩余理智关卡")
-
-
-class MaaPlanConfig(BaseModel):
- Info: Optional[MaaPlanConfig_Info] = Field(default=None, description="基础信息")
- ALL: Optional[MaaPlanConfig_Item] = Field(default=None, description="全局")
- Monday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周一")
- Tuesday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周二")
- Wednesday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周三")
- Thursday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周四")
- Friday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周五")
- Saturday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周六")
- Sunday: Optional[MaaPlanConfig_Item] = Field(default=None, description="周日")
-
-
-class HistoryIndexItem(BaseModel):
- date: str = Field(..., description="日期")
- status: Literal["DONE", "ERROR"] = Field(..., description="状态")
- jsonFile: str = Field(..., description="对应JSON文件")
-
-
-class HistoryData(BaseModel):
- index: Optional[List[HistoryIndexItem]] = Field(
- default=None, description="历史记录索引列表"
- )
- recruit_statistics: Optional[Dict[str, int]] = Field(
- default=None, description="公招统计数据, key为星级, value为对应的公招数量"
- )
- drop_statistics: Optional[Dict[str, Dict[str, int]]] = Field(
- default=None,
- description="掉落统计数据, 格式为 { '关卡号': { '掉落物': 数量 } }",
- )
- error_info: Optional[Dict[str, str]] = Field(
- default=None, description="报错信息, key为时间戳, value为错误描述"
- )
- log_content: Optional[str] = Field(
- default=None, description="日志内容, 仅在提取单条历史记录数据时返回"
- )
-
-
-class ScriptCreateIn(BaseModel):
- type: Literal["MAA", "SRC", "General", "MaaEnd"] = Field(
- ..., description="脚本类型: MAA脚本, 通用脚本, SRC脚本, MaaEnd脚本"
- )
- scriptId: str | None = Field(
- default=None, description="直接从该脚本ID复制创建, 仅在复制创建时使用"
- )
-
-
-class ScriptCreateOut(OutBase):
- scriptId: str = Field(..., description="新创建的脚本ID")
- data: Union[MaaConfig, SrcConfig, GeneralConfig, MaaEndConfig] = Field(
- ..., description="脚本配置数据"
- )
-
-
-class ScriptGetIn(BaseModel):
- scriptId: Optional[str] = Field(
- default=None, description="脚本ID, 未携带时表示获取所有脚本数据"
- )
-
-
-class ScriptGetOut(OutBase):
- index: List[ScriptIndexItem] = Field(..., description="脚本索引列表")
- data: Dict[str, Union[MaaConfig, SrcConfig, GeneralConfig, MaaEndConfig]] = Field(
- ..., description="脚本数据字典, key来自于index列表的uid"
- )
-
-
-class ScriptUpdateIn(BaseModel):
- scriptId: str = Field(..., description="脚本ID")
- data: Union[MaaConfig, SrcConfig, GeneralConfig, MaaEndConfig] = Field(
- ..., description="脚本更新数据"
- )
-
-
-class ScriptDeleteIn(BaseModel):
- scriptId: str = Field(..., description="脚本ID")
-
-
-class ScriptReorderIn(BaseModel):
- indexList: List[str] = Field(..., description="脚本ID列表, 按新顺序排列")
-
-
-class ScriptFileIn(BaseModel):
- scriptId: str = Field(..., description="脚本ID")
- jsonFile: str = Field(..., description="配置文件路径")
-
-
-class ScriptUrlIn(BaseModel):
- scriptId: str = Field(..., description="脚本ID")
- url: str = Field(..., description="配置文件URL")
-
-
-class ScriptUploadIn(BaseModel):
- scriptId: str = Field(..., description="脚本ID")
- config_name: str = Field(..., description="配置名称")
- author: str = Field(..., description="作者")
- description: str = Field(..., description="描述")
-
-
-class UserInBase(BaseModel):
- scriptId: str = Field(..., description="所属脚本ID")
-
-
-class UserGetIn(UserInBase):
- userId: Optional[str] = Field(
- default=None, description="用户ID, 未携带时表示获取所有用户数据"
- )
-
-
-class UserGetOut(OutBase):
- index: List[UserIndexItem] = Field(..., description="用户索引列表")
- data: Dict[
- str, Union[MaaUserConfig, SrcUserConfig, GeneralUserConfig, MaaEndUserConfig]
- ] = Field(..., description="用户数据字典, key来自于index列表的uid")
-
-
-class UserCreateOut(OutBase):
- userId: str = Field(..., description="新创建的用户ID")
- data: Union[MaaUserConfig, SrcUserConfig, GeneralUserConfig, MaaEndUserConfig] = (
- Field(..., description="用户配置数据")
- )
-
-
-class UserUpdateIn(UserInBase):
- userId: str = Field(..., description="用户ID")
- data: Union[MaaUserConfig, SrcUserConfig, GeneralUserConfig, MaaEndUserConfig] = (
- Field(..., description="用户更新数据")
- )
-
-
-class UserDeleteIn(UserInBase):
- userId: str = Field(..., description="用户ID")
-
-
-class UserReorderIn(UserInBase):
- indexList: List[str] = Field(..., description="用户ID列表, 按新顺序排列")
-
-
-class UserSetIn(UserInBase):
- userId: str = Field(..., description="用户ID")
- jsonFile: str = Field(..., description="JSON文件路径, 用于导入自定义基建文件")
-
-
-class EmulatorGetIn(BaseModel):
- emulatorId: Optional[str] = Field(
- default=None, description="模拟器ID, 未携带时表示获取所有模拟器数据"
- )
-
-
-class EmulatorGetOut(OutBase):
- index: List[EmulatorConfigIndexItem] = Field(..., description="模拟器索引列表")
- data: Dict[str, EmulatorConfig] = Field(
- ..., description="模拟器数据字典, key来自于index列表的uid"
- )
-
-
-class EmulatorCreateOut(OutBase):
- emulatorId: str = Field(..., description="新创建的模拟器 ID")
- data: EmulatorConfig = Field(..., description="模拟器配置数据")
-
-
-class EmulatorUpdateIn(BaseModel):
- emulatorId: str = Field(..., description="模拟器 ID")
- data: EmulatorConfig = Field(..., description="模拟器更新数据")
-
-
-class EmulatorDeleteIn(BaseModel):
- emulatorId: str = Field(..., description="模拟器 ID")
-
-
-class EmulatorReorderIn(BaseModel):
- indexList: List[str] = Field(..., description="模拟器 ID列表, 按新顺序排列")
-
-
-class EmulatorOperateIn(BaseModel):
- emulatorId: str = Field(..., description="模拟器 ID")
- operate: Literal["open", "close", "show"] = Field(..., description="操作类型")
- index: str = Field(..., description="模拟器索引")
-
-
-class DeviceStatus(BaseModel):
- """设备状态枚举"""
-
- ONLINE: int = Field(default=0, description="设备在线")
- OFFLINE: int = Field(default=1, description="设备离线")
- STARTING: int = Field(default=2, description="设备开启中")
- CLOSEING: int = Field(default=3, description="设备关闭中")
- ERROR: int = Field(default=4, description="错误")
- NOT_FOUND: int = Field(default=5, description="未找到设备")
- UNKNOWN: int = Field(default=10, description="未知状态")
-
-
-class DeviceInfo(BaseModel):
- """设备信息"""
-
- title: str = Field(..., description="设备标题/名称")
- status: int = Field(..., description="设备状态, 参考DeviceStatus枚举值")
- adb_address: str = Field(..., description="ADB连接地址")
-
-
-class EmulatorStatusOut(OutBase):
- data: Dict[str, Dict[str, DeviceInfo]] = Field(
- ...,
- description="模拟器状态信息, 外层key为模拟器ID, 内层key为设备索引, value为设备信息",
- )
-
-
-class EmulatorSearchResult(BaseModel):
- type: str = Field(..., description="模拟器类型")
- path: str = Field(..., description="模拟器路径")
- name: str = Field(..., description="模拟器名称")
-
-
-class EmulatorSearchOut(OutBase):
- emulators: List[EmulatorSearchResult] = Field(
- default_factory=list, description="搜索到的模拟器列表"
- )
-
-
-class WebhookInBase(BaseModel):
- scriptId: Optional[str] = Field(
- default=None, description="所属脚本ID, 获取全局设置的Webhook数据时无需携带"
- )
- userId: Optional[str] = Field(
- default=None, description="所属用户ID, 获取全局设置的Webhook数据时无需携带"
- )
-
-
-class WebhookGetIn(WebhookInBase):
- webhookId: Optional[str] = Field(
- default=None, description="Webhook ID, 未携带时表示获取所有Webhook数据"
- )
-
-
-class WebhookGetOut(OutBase):
- index: List[WebhookIndexItem] = Field(..., description="Webhook索引列表")
- data: Dict[str, Webhook] = Field(
- ..., description="Webhook数据字典, key来自于index列表的uid"
- )
-
-
-class WebhookCreateOut(OutBase):
- webhookId: str = Field(..., description="新创建的Webhook ID")
- data: Webhook = Field(..., description="Webhook配置数据")
-
-
-class WebhookUpdateIn(WebhookInBase):
- webhookId: str = Field(..., description="Webhook ID")
- data: Webhook = Field(..., description="Webhook更新数据")
-
-
-class WebhookDeleteIn(WebhookInBase):
- webhookId: str = Field(..., description="Webhook ID")
-
-
-class WebhookReorderIn(WebhookInBase):
- indexList: List[str] = Field(..., description="Webhook ID列表, 按新顺序排列")
-
-
-class WebhookTestIn(WebhookInBase):
- data: Webhook = Field(..., description="Webhook配置数据")
-
-
-class PlanCreateIn(BaseModel):
- type: Literal["MaaPlan"]
-
-
-class PlanCreateOut(OutBase):
- planId: str = Field(..., description="新创建的计划ID")
- data: MaaPlanConfig = Field(..., description="计划配置数据")
-
-
-class PlanGetIn(BaseModel):
- planId: Optional[str] = Field(
- default=None, description="计划ID, 未携带时表示获取所有计划数据"
- )
-
-
-class PlanGetOut(OutBase):
- index: List[PlanIndexItem] = Field(..., description="计划索引列表")
- data: Dict[str, MaaPlanConfig] = Field(..., description="计划列表或单个计划数据")
-
-
-class PlanUpdateIn(BaseModel):
- planId: str = Field(..., description="计划ID")
- data: MaaPlanConfig = Field(..., description="计划更新数据")
-
-
-class PlanDeleteIn(BaseModel):
- planId: str = Field(..., description="计划ID")
-
-
-class PlanReorderIn(BaseModel):
- indexList: List[str] = Field(..., description="计划ID列表, 按新顺序排列")
-
-
-class QueueCreateOut(OutBase):
- queueId: str = Field(..., description="新创建的队列ID")
- data: QueueConfig = Field(..., description="队列配置数据")
-
-
-class QueueGetIn(BaseModel):
- queueId: Optional[str] = Field(
- default=None, description="队列ID, 未携带时表示获取所有队列数据"
- )
-
-
-class QueueGetOut(OutBase):
- index: List[QueueIndexItem] = Field(..., description="队列索引列表")
- data: Dict[str, QueueConfig] = Field(
- ..., description="队列数据字典, key来自于index列表的uid"
- )
-
-
-class QueueUpdateIn(BaseModel):
- queueId: str = Field(..., description="队列ID")
- data: QueueConfig = Field(..., description="队列更新数据")
-
-
-class QueueDeleteIn(BaseModel):
- queueId: str = Field(..., description="队列ID")
-
-
-class QueueReorderIn(BaseModel):
- indexList: List[str] = Field(..., description="按新顺序排列的调度队列UID列表")
-
-
-class QueueSetInBase(BaseModel):
- queueId: str = Field(..., description="所属队列ID")
-
-
-class TimeSetGetIn(QueueSetInBase):
- timeSetId: Optional[str] = Field(
- default=None, description="时间设置ID, 未携带时表示获取所有时间设置数据"
- )
-
-
-class TimeSetGetOut(OutBase):
- index: List[TimeSetIndexItem] = Field(..., description="时间设置索引列表")
- data: Dict[str, TimeSet] = Field(
- ..., description="时间设置数据字典, key来自于index列表的uid"
- )
-
-
-class TimeSetCreateOut(OutBase):
- timeSetId: str = Field(..., description="新创建的时间设置ID")
- data: TimeSet = Field(..., description="时间设置配置数据")
-
-
-class TimeSetUpdateIn(QueueSetInBase):
- timeSetId: str = Field(..., description="时间设置ID")
- data: TimeSet = Field(..., description="时间设置更新数据")
-
-
-class TimeSetDeleteIn(QueueSetInBase):
- timeSetId: str = Field(..., description="时间设置ID")
-
-
-class TimeSetReorderIn(QueueSetInBase):
- indexList: List[str] = Field(..., description="时间设置ID列表, 按新顺序排列")
-
-
-class QueueItemGetIn(QueueSetInBase):
- queueItemId: Optional[str] = Field(
- default=None, description="队列项ID, 未携带时表示获取所有队列项数据"
- )
-
-
-class QueueItemGetOut(OutBase):
- index: List[QueueItemIndexItem] = Field(..., description="队列项索引列表")
- data: Dict[str, QueueItem] = Field(
- ..., description="队列项数据字典, key来自于index列表的uid"
- )
-
-
-class QueueItemCreateOut(OutBase):
- queueItemId: str = Field(..., description="新创建的队列项ID")
- data: QueueItem = Field(..., description="队列项配置数据")
-
-
-class QueueItemUpdateIn(QueueSetInBase):
- queueItemId: str = Field(..., description="队列项ID")
- data: QueueItem = Field(..., description="队列项更新数据")
-
-
-class QueueItemDeleteIn(QueueSetInBase):
- queueItemId: str = Field(..., description="队列项ID")
-
-
-class QueueItemReorderIn(QueueSetInBase):
- indexList: List[str] = Field(..., description="队列项ID列表, 按新顺序排列")
-
-
-class DispatchIn(BaseModel):
- taskId: str = Field(
- ...,
- description="目标任务ID, 设置类任务可选对应脚本ID或用户ID, 代理类任务可选对应队列ID或脚本ID",
- )
-
-
-class TaskCreateIn(DispatchIn):
- mode: Literal["AutoProxy", "ManualReview", "ScriptConfig"] = Field(
- ..., description="任务模式"
- )
-
-
-class TaskCreateOut(OutBase):
- taskId: str = Field(..., description="新创建的任务ID")
-
-
-class WebSocketMessage(BaseModel):
- id: str = Field(..., description="消息ID, 为Main时表示消息来自主进程")
- type: Literal["Update", "Message", "Info", "Signal"] = Field(
- ...,
- description="消息类型 Update: 更新数据, Message: 请求弹出对话框, Info: 需要在UI显示的消息, Signal: 程序信号",
- )
- data: Dict[str, Any] = Field(..., description="消息数据, 具体内容根据type类型而定")
-
-
-class PowerIn(BaseModel):
- signal: Literal[
- "NoAction",
- "Shutdown",
- "ShutdownForce",
- "Reboot",
- "Hibernate",
- "Sleep",
- "KillSelf",
- ] = Field(..., description="电源操作信号")
-
-
-class PowerOut(OutBase):
- signal: Literal[
- "NoAction",
- "Shutdown",
- "ShutdownForce",
- "Reboot",
- "Hibernate",
- "Sleep",
- "KillSelf",
- ] = Field(..., description="电源操作信号")
-
-
-class HistorySearchIn(BaseModel):
- mode: Literal["DAILY", "WEEKLY", "MONTHLY"] = Field(..., description="合并模式")
- start_date: str = Field(..., description="开始日期, 格式YYYY-MM-DD")
- end_date: str = Field(..., description="结束日期, 格式YYYY-MM-DD")
-
-
-class HistorySearchOut(OutBase):
- data: Dict[str, Dict[str, HistoryData]] = Field(
- ...,
- description="历史记录索引数据字典, 格式为 { '日期': { '用户名': [历史记录信息] } }",
- )
-
-
-class HistoryDataGetIn(BaseModel):
- jsonPath: str = Field(..., description="需要提取数据的历史记录JSON文件")
-
-
-class HistoryDataGetOut(OutBase):
- data: HistoryData = Field(..., description="历史记录数据")
-
-
-class ToolsGetOut(OutBase):
- data: ToolsConfig = Field(..., description="工具配置数据")
-
-
-class ToolsUpdateIn(BaseModel):
- data: ToolsConfig = Field(..., description="工具配置需要更新的数据")
-
-
-class SettingGetOut(OutBase):
- data: GlobalConfig = Field(..., description="全局设置数据")
-
-
-class SettingUpdateIn(BaseModel):
- data: GlobalConfig = Field(..., description="全局设置需要更新的数据")
-
-
-class UpdateCheckIn(BaseModel):
- current_version: str = Field(..., description="当前前端版本号")
- if_force: bool = Field(default=False, description="是否强制拉取更新信息")
-
-
-class UpdateCheckOut(OutBase):
- if_need_update: bool = Field(..., description="是否需要更新前端")
- latest_version: str = Field(..., description="最新前端版本号")
- update_info: Dict[str, List[str]] = Field(..., description="版本更新信息字典")
-
-
-# ============== WebSocket 调试相关模型 ==============
-
-
-class WSClientCreateIn(BaseModel):
- """创建 WebSocket 客户端请求"""
-
- name: str = Field(..., description="客户端名称,用于标识")
- url: str = Field(
- ..., description="WebSocket 服务器地址,如 ws://localhost:5140/path"
- )
- ping_interval: float = Field(default=15.0, description="心跳发送间隔(秒)")
- ping_timeout: float = Field(default=30.0, description="心跳超时时间(秒)")
- reconnect_interval: float = Field(default=5.0, description="重连间隔(秒)")
- max_reconnect_attempts: int = Field(
- default=-1, description="最大重连次数,-1为无限"
- )
-
-
-class WSClientCreateOut(OutBase):
- """创建客户端响应"""
-
- data: Optional[Dict[str, Any]] = Field(default=None, description="返回数据")
-
-
-class WSClientConnectIn(BaseModel):
- """连接请求"""
-
- name: str = Field(..., description="客户端名称")
-
-
-class WSClientDisconnectIn(BaseModel):
- """断开连接请求"""
-
- name: str = Field(..., description="客户端名称")
-
-
-class WSClientRemoveIn(BaseModel):
- """删除客户端请求"""
-
- name: str = Field(..., description="客户端名称")
-
-
-class WSClientSendIn(BaseModel):
- """发送消息请求"""
-
- name: str = Field(..., description="客户端名称")
- message: Dict[str, Any] = Field(..., description="要发送的 JSON 消息")
-
-
-class WSClientSendJsonIn(BaseModel):
- """发送自定义 JSON 消息请求"""
-
- name: str = Field(..., description="客户端名称")
- msg_id: str = Field(default="Client", description="消息 ID")
- msg_type: str = Field(..., description="消息类型")
- data: Dict[str, Any] = Field(default_factory=dict, description="消息数据")
-
-
-class WSClientAuthIn(BaseModel):
- """发送认证请求"""
-
- name: str = Field(..., description="客户端名称")
- token: str = Field(..., description="认证 Token")
- auth_type: str = Field(default="auth", description="认证消息类型")
- extra_data: Optional[Dict[str, Any]] = Field(
- default=None, description="额外认证数据"
- )
-
-
-class WSClientStatusIn(BaseModel):
- """获取客户端状态请求"""
-
- name: str = Field(..., description="客户端名称")
-
-
-class WSClientStatusOut(OutBase):
- """客户端状态响应"""
-
- data: Optional[Dict[str, Any]] = Field(default=None, description="状态数据")
-
-
-class WSClientListOut(OutBase):
- """客户端列表响应"""
-
- data: Optional[Dict[str, Any]] = Field(default=None, description="客户端列表")
-
-
-class WSMessageHistoryOut(OutBase):
- """消息历史响应"""
-
- data: Optional[Dict[str, Any]] = Field(default=None, description="消息历史")
-
-
-class WSClearHistoryIn(BaseModel):
- """清空消息历史请求"""
-
- name: Optional[str] = Field(default=None, description="客户端名称,为空则清空所有")
-
-
-class WSCommandsOut(OutBase):
- """可用命令列表响应"""
-
- data: Optional[Dict[str, Any]] = Field(default=None, description="命令列表")
diff --git a/app/models/shared.py b/app/models/shared.py
new file mode 100644
index 00000000..f5a26675
--- /dev/null
+++ b/app/models/shared.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+from typing import Any, Literal
+
+from pydantic import BaseModel, Field
+
+
+class TagItem(BaseModel):
+ text: str = Field(..., description="标签文本")
+ color: Literal[
+ "red",
+ "blue",
+ "green",
+ "yellow",
+ "orange",
+ "purple",
+ "pink",
+ "brown",
+ "black",
+ "white",
+ "gray",
+ "silver",
+ "gold",
+ ] = Field(..., description="标签颜色")
+
+
+class WebSocketMessage(BaseModel):
+ id: str = Field(..., description="消息ID, 为Main时表示消息来自主进程")
+ type: Literal["Update", "Message", "Info", "Signal"] = Field(
+ ...,
+ description="消息类型 Update: 更新数据, Message: 请求弹出对话框, Info: 需要在UI显示的消息, Signal: 程序信号",
+ )
+ data: dict[str, Any] = Field(..., description="消息数据, 具体内容根据type类型而定")
+
+
+class DeviceInfo(BaseModel):
+ """API 层使用的设备信息模型。"""
+
+ title: str = Field(..., description="设备标题/名称")
+ status: int = Field(..., description="设备状态, 参考 DeviceStatus 枚举值")
+ adb_address: str = Field(..., description="ADB连接地址")
+
+
+__all__ = ["TagItem", "WebSocketMessage", "DeviceInfo"]
diff --git a/app/models/src.py b/app/models/src.py
new file mode 100644
index 00000000..368e0cfb
--- /dev/null
+++ b/app/models/src.py
@@ -0,0 +1,313 @@
+from __future__ import annotations
+
+import json
+from datetime import datetime
+from pathlib import Path
+from typing import Annotated, Any, ClassVar, Literal
+
+from pydantic import BaseModel, Field, field_validator
+
+from app.core.config.base import MultipleConfig
+from app.core.config.pydantic import PydanticConfigBase
+from app.core.config.shortcuts import config, ref, sub_configs, virtual
+from app.core.config.types import DayCount, EncryptedString, NonNegativeInt, PositiveInt
+from app.utils.constants import STARRAIL_STAGE_BOOK, UTC4
+from .common import Webhook
+from .tag_helpers import notes_tag, proxy_date_tag, remained_day_tag
+
+
+RELIC_OPTIONS: tuple[str, ...] = (
+ "-",
+ "Cavern_of_Corrosion_Path_of_Possession",
+ "Cavern_of_Corrosion_Path_of_Hidden_Salvation",
+ "Cavern_of_Corrosion_Path_of_Thundersurge",
+ "Cavern_of_Corrosion_Path_of_Aria",
+ "Cavern_of_Corrosion_Path_of_Uncertainty",
+ "Cavern_of_Corrosion_Path_of_Cavalier",
+ "Cavern_of_Corrosion_Path_of_Dreamdive",
+ "Cavern_of_Corrosion_Path_of_Darkness",
+ "Cavern_of_Corrosion_Path_of_Elixir_Seekers",
+ "Cavern_of_Corrosion_Path_of_Conflagration",
+ "Cavern_of_Corrosion_Path_of_Holy_Hymn",
+ "Cavern_of_Corrosion_Path_of_Providence",
+ "Cavern_of_Corrosion_Path_of_Drifting",
+ "Cavern_of_Corrosion_Path_of_Jabbing_Punch",
+ "Cavern_of_Corrosion_Path_of_Gelid_Wind",
+)
+
+MATERIAL_OPTIONS: tuple[str, ...] = (
+ "-",
+ "Calyx_Golden_Memories_Planarcadia",
+ "Calyx_Golden_Aether_Planarcadia",
+ "Calyx_Golden_Treasures_Planarcadia",
+ "Calyx_Golden_Memories_Amphoreus",
+ "Calyx_Golden_Aether_Amphoreus",
+ "Calyx_Golden_Treasures_Amphoreus",
+ "Calyx_Golden_Memories_Penacony",
+ "Calyx_Golden_Aether_Penacony",
+ "Calyx_Golden_Treasures_Penacony",
+ "Calyx_Golden_Memories_The_Xianzhou_Luofu",
+ "Calyx_Golden_Aether_The_Xianzhou_Luofu",
+ "Calyx_Golden_Treasures_The_Xianzhou_Luofu",
+ "Calyx_Golden_Memories_Jarilo_VI",
+ "Calyx_Golden_Aether_Jarilo_VI",
+ "Calyx_Golden_Treasures_Jarilo_VI",
+ "Calyx_Crimson_Destruction_Herta_StorageZone",
+ "Calyx_Crimson_Destruction_Luofu_ScalegorgeWaterscape",
+ "Calyx_Crimson_Preservation_Herta_SupplyZone",
+ "Calyx_Crimson_Preservation_Penacony_ClockStudiosThemePark",
+ "Calyx_Crimson_The_Hunt_Jarilo_OutlyingSnowPlains",
+ "Calyx_Crimson_The_Hunt_Penacony_SoulGladScorchsandAuditionVenue",
+ "Calyx_Crimson_The_Hunt_Amphoreus_MemortisShoreRuinsofTime",
+ "Calyx_Crimson_Abundance_Jarilo_BackwaterPass",
+ "Calyx_Crimson_Abundance_Luofu_FyxestrollGarden",
+ "Calyx_Crimson_Erudition_Jarilo_RivetTown",
+ "Calyx_Crimson_Erudition_Penacony_PenaconyGrandTheater",
+ "Calyx_Crimson_Harmony_Jarilo_RobotSettlement",
+ "Calyx_Crimson_Harmony_Penacony_TheReverieDreamscape",
+ "Calyx_Crimson_Nihility_Jarilo_GreatMine",
+ "Calyx_Crimson_Nihility_Luofu_AlchemyCommission",
+ "Calyx_Crimson_Remembrance_Amphoreus_StrifeRuinsCastrumKremnos",
+ "Calyx_Crimson_Elation_Planarcadia_WorldEndTavern",
+ "Stagnant_Shadow_Quanta",
+ "Stagnant_Shadow_Gust",
+ "Stagnant_Shadow_Fulmination",
+ "Stagnant_Shadow_Blaze",
+ "Stagnant_Shadow_Spike",
+ "Stagnant_Shadow_Rime",
+ "Stagnant_Shadow_Mirage",
+ "Stagnant_Shadow_Icicle",
+ "Stagnant_Shadow_Doom",
+ "Stagnant_Shadow_Puppetry",
+ "Stagnant_Shadow_Abomination",
+ "Stagnant_Shadow_Scorch",
+ "Stagnant_Shadow_Celestial",
+ "Stagnant_Shadow_Perdition",
+ "Stagnant_Shadow_Nectar",
+ "Stagnant_Shadow_Roast",
+ "Stagnant_Shadow_Ire",
+ "Stagnant_Shadow_Duty",
+ "Stagnant_Shadow_Timbre",
+ "Stagnant_Shadow_Mechwolf",
+ "Stagnant_Shadow_Gloam",
+ "Stagnant_Shadow_Sloggyre",
+ "Stagnant_Shadow_Gelidmoon",
+ "Stagnant_Shadow_Deepsheaf",
+ "Stagnant_Shadow_Cinders",
+ "Stagnant_Shadow_Sirens",
+ "Stagnant_Shadow_Ashes",
+ "Stagnant_Shadow_Soundburst",
+)
+
+ORNAMENT_OPTIONS: tuple[str, ...] = (
+ "-",
+ "Divergent_Universe_Within_the_West_Wind",
+ "Divergent_Universe_Moonlit_Blood",
+ "Divergent_Universe_Unceasing_Strife",
+ "Divergent_Universe_Famished_Worker",
+ "Divergent_Universe_Eternal_Comedy",
+ "Divergent_Universe_To_Sweet_Dreams",
+ "Divergent_Universe_Pouring_Blades",
+ "Divergent_Universe_Fruit_of_Evil",
+ "Divergent_Universe_Permafrost",
+ "Divergent_Universe_Gentle_Words",
+ "Divergent_Universe_Smelted_Heart",
+ "Divergent_Universe_Untoppled_Walls",
+)
+
+ECHO_OF_WAR_OPTIONS: tuple[str, ...] = (
+ "-",
+ "Echo_of_War_Rusted_Crypt_of_the_Iron_Carcass",
+ "Echo_of_War_Glance_of_Twilight",
+ "Echo_of_War_Inner_Beast_Battlefield",
+ "Echo_of_War_Salutations_of_Ashen_Dreams",
+ "Echo_of_War_Borehole_Planet_Past_Nightmares",
+ "Echo_of_War_Divine_Seed",
+ "Echo_of_War_End_of_the_Eternal_Freeze",
+ "Echo_of_War_Destruction_Beginning",
+)
+
+SIM_WORLD_OPTIONS: tuple[str, ...] = (
+ "-",
+ "Simulated_Universe_World_3",
+ "Simulated_Universe_World_4",
+ "Simulated_Universe_World_5",
+ "Simulated_Universe_World_6",
+ "Simulated_Universe_World_8",
+)
+
+
+@config
+@sub_configs(Notify_CustomWebhooks=[Webhook])
+class SrcUserConfig(PydanticConfigBase):
+ """SRC用户配置"""
+
+ related_config: ClassVar[dict[str, MultipleConfig[Any]]] = {}
+
+ class InfoModel(BaseModel):
+ Name: str = "新用户"
+ Status: bool = True
+ Id: str = ""
+ Password: EncryptedString = ""
+ Mode: Literal["简洁", "详细"] = "简洁"
+ Server: Literal[
+ "CN-Official",
+ "CN-Bilibili",
+ "VN-Official",
+ "OVERSEA-America",
+ "OVERSEA-Asia",
+ "OVERSEA-Europe",
+ "OVERSEA-TWHKMO",
+ ] = "CN-Official"
+ RemainedDay: DayCount = -1
+ Notes: str = "无"
+ Tag: Annotated[
+ str,
+ virtual("getTags"),
+ ] = "[ ]"
+
+ class StageModel(BaseModel):
+ Channel: Literal["Relic", "Materials", "Ornament"] = "Relic"
+ Relic: str = "-"
+ Materials: str = "-"
+ Ornament: str = "-"
+ ExtractReservedTrailblazePower: bool = False
+ UseFuel: bool = False
+ FuelReserve: NonNegativeInt = Field(default=5, le=9999)
+ EchoOfWar: str = "-"
+ SimulatedUniverseWorld: str = "-"
+
+ @field_validator("Relic", mode="before")
+ @classmethod
+ def _validate_relic(cls, value: Any) -> str:
+ text = value if isinstance(value, str) else str(value)
+ return text if text in RELIC_OPTIONS else "-"
+
+ @field_validator("Materials", mode="before")
+ @classmethod
+ def _validate_materials(cls, value: Any) -> str:
+ text = value if isinstance(value, str) else str(value)
+ return text if text in MATERIAL_OPTIONS else "-"
+
+ @field_validator("Ornament", mode="before")
+ @classmethod
+ def _validate_ornament(cls, value: Any) -> str:
+ text = value if isinstance(value, str) else str(value)
+ return text if text in ORNAMENT_OPTIONS else "-"
+
+ @field_validator("EchoOfWar", mode="before")
+ @classmethod
+ def _validate_echo_of_war(cls, value: Any) -> str:
+ text = value if isinstance(value, str) else str(value)
+ return text if text in ECHO_OF_WAR_OPTIONS else "-"
+
+ @field_validator("SimulatedUniverseWorld", mode="before")
+ @classmethod
+ def _validate_sim_world(cls, value: Any) -> str:
+ text = value if isinstance(value, str) else str(value)
+ return text if text in SIM_WORLD_OPTIONS else "-"
+
+ class DataModel(BaseModel):
+ LastProxyDate: str = "2000-01-01"
+ ProxyTimes: NonNegativeInt = Field(default=0, le=9999)
+ IfPassCheck: bool = True
+
+ @field_validator("LastProxyDate", mode="before")
+ @classmethod
+ def _normalize_ymd(cls, value: Any) -> str:
+ text = value if isinstance(value, str) else str(value)
+ try:
+ datetime.strptime(text, "%Y-%m-%d")
+ return text
+ except ValueError:
+ return "2000-01-01"
+
+ class NotifyModel(BaseModel):
+ Enabled: bool = False
+ IfSendStatistic: bool = False
+ IfSendMail: bool = False
+ ToAddress: str = ""
+ IfServerChan: bool = False
+ ServerChanKey: str = ""
+
+ Info: InfoModel = Field(default_factory=InfoModel)
+ Stage: StageModel = Field(default_factory=StageModel)
+ Data: DataModel = Field(default_factory=DataModel)
+ Notify: NotifyModel = Field(default_factory=NotifyModel)
+
+ def getTags(self) -> str: # noqa: N802
+ """生成用户标签列表,返回JSON字符串格式的TagItem列表"""
+ tags: list[dict[str, str]] = []
+
+ if not self.get("Data", "IfPassCheck"):
+ tags.append({"text": "人工排查未通过", "color": "red"})
+
+ tags.append(
+ proxy_date_tag(
+ self.get("Data", "LastProxyDate"),
+ self.get("Data", "ProxyTimes"),
+ UTC4,
+ )
+ )
+
+ tags.append(remained_day_tag(self.get("Info", "RemainedDay")))
+
+ tags.append(
+ {
+ "text": f"关卡:{STARRAIL_STAGE_BOOK.get(self.get('Stage', self.get('Stage', 'Channel')), '未知关卡')}",
+ "color": "blue",
+ }
+ )
+ tags.append(
+ {
+ "text": f"周本:{STARRAIL_STAGE_BOOK.get(self.get('Stage', 'EchoOfWar'), '未知关卡')}",
+ "color": "blue",
+ }
+ )
+ tags.append(
+ {
+ "text": f"模拟宇宙:{STARRAIL_STAGE_BOOK.get(self.get('Stage', 'SimulatedUniverseWorld'), '未知关卡')}",
+ "color": "blue",
+ }
+ )
+
+ tags.append(notes_tag(self.get("Info", "Notes")))
+
+ return json.dumps(tags, ensure_ascii=False)
+
+
+@config
+@sub_configs(UserData=[SrcUserConfig])
+class SrcConfig(PydanticConfigBase):
+ """SRC配置"""
+
+ related_config: ClassVar[dict[str, MultipleConfig[Any]]] = {}
+
+ class InfoModel(BaseModel):
+ Name: str = "新 SRC 脚本"
+ Path: str = str(Path.cwd())
+
+ class EmulatorModel(BaseModel):
+ Id: Annotated[
+ str,
+ ref(
+ "EmulatorConfig",
+ default="-",
+ allow_values=("-",),
+ on_delete="set_default",
+ ),
+ ] = "-"
+ Index: str = "-"
+
+ class RunModel(BaseModel):
+ TaskTransitionMethod: Literal["ExitGame", "ExitEmulator"] = "ExitGame"
+ ProxyTimesLimit: NonNegativeInt = Field(default=0, le=9999)
+ RunTimesLimit: PositiveInt = Field(default=3, le=9999)
+ RunTimeLimit: PositiveInt = Field(default=10, le=9999)
+
+ Info: InfoModel = Field(default_factory=InfoModel)
+ Emulator: EmulatorModel = Field(default_factory=EmulatorModel)
+ Run: RunModel = Field(default_factory=RunModel)
+
+
+__all__ = ["SrcUserConfig", "SrcConfig"]
diff --git a/app/models/tag_helpers.py b/app/models/tag_helpers.py
new file mode 100644
index 00000000..87a8f9e6
--- /dev/null
+++ b/app/models/tag_helpers.py
@@ -0,0 +1,38 @@
+from __future__ import annotations
+
+import json
+from datetime import datetime
+from typing import Any
+
+
+def proxy_date_tag(
+ last_date: str, proxy_times: int, tz: Any, label: str = "日常"
+) -> dict[str, str]:
+ if (
+ datetime.strptime(last_date, "%Y-%m-%d").date()
+ == datetime.now(tz=tz).date()
+ ):
+ return {"text": f"{label}:已代理{proxy_times}次", "color": "green"}
+ return {"text": f"{label}:未代理", "color": "orange"}
+
+
+def remained_day_tag(remained_day: int) -> dict[str, str]:
+ if remained_day == -1:
+ color = "gold"
+ elif remained_day == 0:
+ color = "red"
+ elif remained_day <= 3:
+ color = "orange"
+ elif remained_day <= 7:
+ color = "yellow"
+ elif remained_day <= 30:
+ color = "blue"
+ else:
+ color = "green"
+ text = f"剩余天数:{remained_day}天" if remained_day >= 0 else "剩余天数:无期限"
+ return {"text": text, "color": color}
+
+
+def notes_tag(notes: str) -> dict[str, str]:
+ text = f"备注:{notes}" if len(notes) <= 20 else f"备注:{notes[:20]}..."
+ return {"text": text, "color": "pink"}
diff --git a/app/models/task.py b/app/models/task.py
index 9432354e..3b94cff5 100644
--- a/app/models/task.py
+++ b/app/models/task.py
@@ -25,34 +25,52 @@
from datetime import datetime
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
-from typing import List, Optional, Literal
+from typing import Any, Literal, Optional
+
+
+def _default_content() -> list[str]:
+ return []
+
+
+def _default_log_record() -> dict[datetime, LogRecord]:
+ return {}
+
+
+def _default_user_list() -> list[UserItem]:
+ return []
+
+
+def _default_script_list() -> list[ScriptItem]:
+ return []
+
+
+def _default_pending_tasks() -> set[asyncio.Task[Any]]:
+ return set()
@dataclass
class LogRecord:
-
- content: list[str] = field(default_factory=list)
+ content: list[str] = field(default_factory=_default_content)
status: str = "未开始监看日志"
@dataclass
class UserItem:
-
user_id: str # 用户ID
name: str # 用户名称
status: str # 用户执行状态
log_record: dict[datetime, LogRecord] = field(
- default_factory=dict
+ default_factory=_default_log_record
) # 用户本次代理的全部日志记录
_task_item_ref: Optional[weakref.ReferenceType[TaskItem]] = None
- def __setattr__(self, name, value):
+ def __setattr__(self, name: str, value: Any) -> None:
super().__setattr__(name, value)
# 监听所有字段变化
if name in ("user_id", "name", "status") and self._task_item_ref is not None:
ti = self._task_item_ref()
if ti is not None:
- asyncio.create_task(ti.on_change())
+ ti.create_tracked_task(ti.on_change())
@property
def result(self) -> str:
@@ -69,16 +87,17 @@ def result(self) -> str:
@dataclass
class ScriptItem:
-
script_id: str # 脚本ID
name: str # 脚本名称
status: str # 脚本执行状态
- user_list: List[UserItem] = field(default_factory=list) # 用户信息列表
+ user_list: list[UserItem] = field(
+ default_factory=_default_user_list
+ ) # 用户信息列表
current_index: int = -1 # 当前执行的用户索引,-1 表示未开始
log: str = "" # 脚本执行日志
_task_item_ref: Optional[weakref.ReferenceType[TaskItem]] = None
- def __setattr__(self, name, value):
+ def __setattr__(self, name: str, value: Any) -> None:
super().__setattr__(name, value)
# 如果 user_list 被整体替换,重新绑定
@@ -87,7 +106,7 @@ def __setattr__(self, name, value):
object.__setattr__(user, "_task_item_ref", self._task_item_ref)
if name not in ("_task_item_ref",) and self.task_info is not None:
- asyncio.create_task(self.task_info.on_change())
+ self.task_info.create_tracked_task(self.task_info.on_change())
@property
def task_info(self) -> Optional[TaskItem]:
@@ -114,10 +133,15 @@ class TaskItem(ABC):
queue_id: str | None # 执行的队列ID
script_id: str | None # 执行的脚本ID
user_id: str | None # 执行的用户ID
- script_list: List[ScriptItem] = field(default_factory=list) # 脚本信息列表
+ script_list: list[ScriptItem] = field(
+ default_factory=_default_script_list
+ ) # 脚本信息列表
current_index: int = -1 # 当前执行的脚本索引,-1 表示未开始
+ _pending_tasks: set[asyncio.Task[Any]] = field(
+ default_factory=_default_pending_tasks, init=False
+ )
- def __setattr__(self, name, value):
+ def __setattr__(self, name: str, value: Any) -> None:
super().__setattr__(name, value)
# 如果 script_list 被整体替换,重新绑定
@@ -125,7 +149,7 @@ def __setattr__(self, name, value):
for item in self.script_list:
self._bind_task_item(item)
- def _bind_task_item(self, item: ScriptItem):
+ def _bind_task_item(self, item: ScriptItem) -> None:
"""绑定 TaskItem 及其内部所有 UserItem 到当前 TaskItem"""
ti_ref = weakref.ref(self)
object.__setattr__(item, "_task_item_ref", ti_ref)
@@ -133,13 +157,38 @@ def _bind_task_item(self, item: ScriptItem):
for user in item.user_list:
object.__setattr__(user, "_task_item_ref", ti_ref)
+ def create_tracked_task(self, coro: Any) -> asyncio.Task[Any]:
+ """创建并跟踪异步任务,避免任务异常被静默吞掉。"""
+
+ task = asyncio.create_task(coro)
+ self._pending_tasks.add(task)
+
+ def _finalize(done_task: asyncio.Task[Any]) -> None:
+ self._pending_tasks.discard(done_task)
+ if done_task.cancelled():
+ return
+ exception = done_task.exception()
+ if exception is not None:
+ # 在事件循环中转抛,便于统一异常处理器捕获
+ loop = done_task.get_loop()
+ loop.call_exception_handler(
+ {
+ "message": "TaskItem 子任务执行失败",
+ "exception": exception,
+ "task": done_task,
+ }
+ )
+
+ task.add_done_callback(_finalize)
+ return task
+
@abstractmethod
- async def on_change(self):
+ async def on_change(self) -> None:
"""统一回调入口"""
raise NotImplementedError("子类必须实现 on_change")
@property
- def asdict(self) -> list:
+ def asdict(self) -> list[dict[str, str | list[dict[str, str]]]]:
"""将 TaskItem 转换为字典形式"""
return [
{
@@ -162,30 +211,30 @@ def result(self) -> str:
if not self.script_list:
return "任务未加载"
- return "\n\n\n".join(
- [
- f"{script.name}:\n\n"
- f" 已完成用户数:{sum(1 for user in script.user_list if user.status == '完成')};未完成用户数:{sum(1 for user in script.user_list if user.status != '完成')}\n\n"
- f" {script.result.replace('\n', '\n ')}"
- for script in self.script_list
- ]
- )
+ formatted_result: list[str] = []
+ formatted_result = [
+ f"{script.name}:\n\n"
+ f" 已完成用户数:{sum(1 for user in script.user_list if user.status == '完成')};未完成用户数:{sum(1 for user in script.user_list if user.status != '完成')}\n\n"
+ f" {script.result.replace('\n', '\n ')}"
+ for script in self.script_list
+ ]
+ return "\n\n\n".join(formatted_result)
@dataclass
class TaskExecuteBase(ABC):
- task: asyncio.Task | None = None
+ task: asyncio.Task[None] | None = None
_task_group: asyncio.TaskGroup | None = None
accomplish: asyncio.Event = field(default_factory=asyncio.Event)
@abstractmethod
- async def main_task(self): ...
+ async def main_task(self) -> None: ...
@abstractmethod
- async def final_task(self): ...
+ async def final_task(self) -> None: ...
@abstractmethod
- async def on_crash(self, e): ...
+ async def on_crash(self, e: Exception) -> None: ...
- async def _execute_task(self, parent_tg: asyncio.TaskGroup):
+ async def _execute_task(self, parent_tg: asyncio.TaskGroup) -> None:
self._task_group = parent_tg
try:
await self.main_task()
@@ -200,19 +249,19 @@ async def _execute_task(self, parent_tg: asyncio.TaskGroup):
finally:
self.accomplish.set()
- def spawn(self, child: TaskExecuteBase) -> asyncio.Task:
+ def spawn(self, child: TaskExecuteBase) -> asyncio.Task[None]:
if self._task_group is None:
raise RuntimeError("子任务必须在主任务中启动")
return self._task_group.create_task(child._execute_task(self._task_group))
- def execute(self):
+ def execute(self) -> None:
if self.task is not None and not self.task.done():
raise RuntimeError("任务已在运行")
if self._task_group is not None:
raise RuntimeError("execute() 仅可由顶层任务调用,子任务请使用 spawn()")
- async def _root_coro():
+ async def _root_coro() -> None:
async with asyncio.TaskGroup() as tg:
self.task = tg.create_task(self._execute_task(tg))
diff --git a/app/services/__init__.py b/app/services/__init__.py
index ba7a7de4..52466b68 100644
--- a/app/services/__init__.py
+++ b/app/services/__init__.py
@@ -20,9 +20,20 @@
# Contact: DLmaster_361@163.com
+from .git_service import GitService
+from .log_service import LogService
from .matomo import Matomo
+from .migration import MigrationService
from .notification import Notify
from .system import System
from .update import Updater
-__all__ = ["Matomo", "Notify", "System", "Updater"]
+__all__ = [
+ "GitService",
+ "LogService",
+ "Matomo",
+ "MigrationService",
+ "Notify",
+ "System",
+ "Updater",
+]
diff --git a/app/services/git_service.py b/app/services/git_service.py
new file mode 100644
index 00000000..5a1583ac
--- /dev/null
+++ b/app/services/git_service.py
@@ -0,0 +1,80 @@
+# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
+# Copyright © 2024-2025 DLmaster361
+# Copyright © 2025 MoeSnowyFox
+# Copyright © 2025-2026 AUTO-MAS Team
+
+# This file is part of AUTO-MAS.
+
+# AUTO-MAS is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of
+# the License, or (at your option) any later version.
+
+# AUTO-MAS is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty
+# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
+# the GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with AUTO-MAS. If not, see .
+
+# Contact: DLmaster_361@163.com
+
+from __future__ import annotations
+
+import asyncio
+from datetime import datetime
+from typing import Any
+
+from app.utils import get_logger
+
+logger = get_logger("Git服务")
+
+
+class GitService:
+ """Provides async access to Git repository version information."""
+
+ def __init__(self, repo: Any, loop: asyncio.AbstractEventLoop) -> None:
+ """
+ Args:
+ repo: A ``git.Repo`` instance (or ``None`` if Git is unavailable).
+ loop: The running asyncio event loop used for executor calls.
+ """
+ self._repo = repo
+ self._loop = loop
+
+ async def get_git_version(self) -> tuple[bool, str, str]:
+ """获取Git版本信息,如果Git不可用则返回默认值"""
+
+ def _get_git_info() -> tuple[bool, str, str]:
+ if self._repo is None:
+ logger.warning("Git仓库不可用,返回默认版本信息")
+ return False, "unknown", "unknown"
+
+ # 获取当前 commit
+ current_commit = self._repo.head.commit
+ # 获取 commit 哈希
+ commit_hash = current_commit.hexsha
+ # 获取 commit 时间
+ commit_time = datetime.fromtimestamp(current_commit.committed_date)
+
+ # 检查是否为最新 commit
+ try:
+ # 获取远程分支的最新 commit
+ origin = self._repo.remotes.origin
+ origin.fetch() # 拉取最新信息
+ remote_commit = self._repo.commit(
+ f"origin/{self._repo.active_branch.name}"
+ )
+ is_latest = bool(current_commit.hexsha == remote_commit.hexsha)
+ except (ValueError, OSError) as e:
+ logger.warning(f"无法获取远程分支信息: {e}")
+ is_latest = False
+
+ return is_latest, commit_hash, commit_time.strftime("%Y-%m-%d %H:%M:%S")
+
+ # 在线程池中执行 Git 操作
+ is_latest, commit_hash, commit_time = await self._loop.run_in_executor(
+ None, _get_git_info
+ )
+ return is_latest, commit_hash, commit_time
diff --git a/app/services/log_service.py b/app/services/log_service.py
new file mode 100644
index 00000000..43a41ca9
--- /dev/null
+++ b/app/services/log_service.py
@@ -0,0 +1,484 @@
+# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
+# Copyright © 2024-2025 DLmaster361
+# Copyright © 2025 MoeSnowyFox
+# Copyright © 2025-2026 AUTO-MAS Team
+
+# This file is part of AUTO-MAS.
+
+# AUTO-MAS is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of
+# the License, or (at your option) any later version.
+
+# AUTO-MAS is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty
+# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
+# the GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with AUTO-MAS. If not, see .
+
+# Contact: DLmaster_361@163.com
+
+from __future__ import annotations
+
+import json
+import re
+import shutil
+from collections import defaultdict
+from datetime import date, datetime, timedelta
+from pathlib import Path
+from typing import Any, Dict, List, Literal
+
+from app.utils import get_logger
+from app.utils.constants import UTC4
+
+logger = get_logger("日志服务")
+
+
+class LogService:
+ """Handles log saving, statistic merging, history search, and cleanup."""
+
+ def __init__(self, history_path: Path) -> None:
+ self._history_path = history_path
+
+ # ------------------------------------------------------------------
+ # Private helpers
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def _save_simple_log(
+ log_path: Path,
+ logs: list[str],
+ result_key: str,
+ result_value: str,
+ ) -> None:
+ """
+ Shared implementation for simple log types (MaaEnd, SRC, General).
+
+ Writes the concatenated log lines to ``log_path.with_suffix('.log')``
+ and a minimal JSON statistics file next to it.
+
+ Args:
+ log_path: Base path (suffix will be replaced).
+ logs: Raw log lines.
+ result_key: JSON key for the result (e.g. ``"src_result"``).
+ result_value: Result label for this run.
+ """
+ data: Dict[str, str] = {result_key: result_value}
+
+ log_path.parent.mkdir(parents=True, exist_ok=True)
+ log_path.with_suffix(".log").write_text("".join(logs), encoding="utf-8")
+ log_path.with_suffix(".json").write_text(
+ json.dumps(data, ensure_ascii=False, indent=4), encoding="utf-8"
+ )
+
+ # ------------------------------------------------------------------
+ # Public API
+ # ------------------------------------------------------------------
+
+ async def save_maa_log(
+ self, log_path: Path, logs: list[str], maa_result: str
+ ) -> bool:
+ """
+ 保存MAA日志并生成对应统计数据
+
+ Args:
+ log_path (Path): 日志文件保存路径
+ logs (list): 日志列表
+ maa_result (str): MAA任务结果
+ Returns:
+ bool: 是否存在高资
+ """
+
+ logger.info(f"开始处理 MAA 日志, 日志长度: {len(logs)}, 日志标记: {maa_result}")
+
+ data: dict[str, Any] = {
+ "recruit_statistics": defaultdict(int),
+ "drop_statistics": defaultdict(dict),
+ "sanity": 0,
+ "sanity_full_at": "",
+ "maa_result": maa_result,
+ }
+
+ if_six_star = False
+
+ # 提取理智相关信息
+ for log_line in logs:
+ # 提取当前理智值:理智: 5/180
+ sanity_match = re.search(r"理智:\s*(\d+)/\d+", log_line)
+ if sanity_match:
+ data["sanity"] = int(sanity_match.group(1))
+
+ # 提取理智回满时间:理智将在 2025-09-26 18:57 回满。(17h 29m 后)
+ sanity_full_match = re.search(
+ r"(理智将在\s*\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s*回满。\(\d+h\s+\d+m\s+后\))",
+ log_line,
+ )
+ if sanity_full_match:
+ data["sanity_full_at"] = sanity_full_match.group(1)
+
+ # 公招统计(仅统计招募到的)
+ confirmed_recruit = False
+ current_star_level = None
+ i = 0
+ while i < len(logs):
+ if "公招识别结果:" in logs[i]:
+ current_star_level = None # 每次识别公招时清空之前的星级
+ i += 1
+ while i < len(logs) and "Tags" not in logs[i]: # 读取所有公招标签
+ i += 1
+
+ if i < len(logs) and "Tags" in logs[i]: # 识别星级
+ star_match = re.search(r"(\d+)\s*★ Tags", logs[i])
+ if star_match:
+ current_star_level = f"{star_match.group(1)}★"
+ if current_star_level == "6★":
+ if_six_star = True
+
+ if "已确认招募" in logs[i]: # 只有确认招募后才统计
+ confirmed_recruit = True
+
+ if confirmed_recruit and current_star_level:
+ data["recruit_statistics"][current_star_level] += 1
+ confirmed_recruit = False # 重置, 等待下一次公招
+ current_star_level = None # 清空已处理的星级
+
+ i += 1
+
+ # 掉落统计
+ # 存储所有关卡的掉落统计
+ all_stage_drops: dict[str, dict[str, int]] = {}
+
+ # 查找所有Fight任务的开始和结束位置
+ fight_tasks: list[tuple[int, int]] = []
+ for i, line in enumerate(logs):
+ if "开始任务: Fight" in line or "开始任务: 理智作战" in line:
+ # 查找对应的任务结束位置
+ end_index = -1
+ for j in range(i + 1, len(logs)):
+ if "完成任务: Fight" in logs[j] or "完成任务: 理智作战" in logs[j]:
+ end_index = j
+ break
+ # 如果遇到新的Fight任务开始, 则当前任务没有正常结束
+ if j < len(logs) and (
+ "开始任务: Fight" in logs[j] or "开始任务: 理智作战" in logs[j]
+ ):
+ break
+
+ # 如果找到了结束位置, 记录这个任务的范围
+ if end_index != -1:
+ fight_tasks.append((i, end_index))
+
+ # 处理每个Fight任务
+ for start_idx, end_idx in fight_tasks:
+ # 提取当前任务的日志
+ task_logs = logs[start_idx : end_idx + 1]
+
+ # 查找任务中的最后一次掉落统计
+ last_drop_stats: dict[str, int] = {}
+ current_stage = None
+
+ for line in task_logs:
+ # 匹配掉落统计行, 如"1-7 掉落统计:"
+ drop_match = re.search(r"([\u4e00-\u9fffA-Za-z0-9\-]+) 掉落统计:", line)
+ if drop_match:
+ # 发现新的掉落统计, 重置当前关卡的掉落数据
+ current_stage = drop_match.group(1)
+ last_drop_stats = {}
+ continue
+
+ # 如果已经找到了关卡, 处理掉落物
+ if current_stage:
+ item_match: List[str] = re.findall(
+ r"^(?!\[)(\S+?)\s*:\s*([\d,]+[kK]?)(?:\s*\(\+[\d,]+[kK]?\))?",
+ line,
+ re.M,
+ )
+ for item, total in item_match:
+ total = total.replace(",", "")
+ if total.lower().endswith("k"):
+ total = int(total[:-1]) * 1000
+ else:
+ total = int(total)
+
+ # 黑名单
+ if item not in [
+ "当前次数",
+ "理智",
+ "最快截图耗时",
+ "专精等级",
+ "剩余时间",
+ ]:
+ last_drop_stats[item] = total
+
+ # 如果任务中有掉落统计, 更新总统计
+ if current_stage and last_drop_stats:
+ if current_stage not in all_stage_drops:
+ all_stage_drops[current_stage] = {}
+
+ # 累加掉落数据
+ for item, count in last_drop_stats.items():
+ all_stage_drops[current_stage].setdefault(item, 0)
+ all_stage_drops[current_stage][item] += count
+
+ # 将累加后的掉落数据保存到结果中
+ data["drop_statistics"] = all_stage_drops
+
+ # 保存日志
+ log_path.parent.mkdir(parents=True, exist_ok=True)
+ log_path.write_text("".join(logs), encoding="utf-8")
+ # 保存统计数据
+ log_path.with_suffix(".json").write_text(
+ json.dumps(data, ensure_ascii=False, indent=4), encoding="utf-8"
+ )
+
+ logger.success(f"MAA 日志统计完成, 日志路径: {log_path}")
+
+ return if_six_star
+
+ async def save_maaend_log(
+ self, log_path: Path, logs: list[str], maaend_result: str
+ ) -> None:
+ """
+ Save MaaEnd logs and generate basic statistics data.
+
+ Args:
+ log_path (Path): Target log file path.
+ logs (list[str]): Log lines.
+ maaend_result (str): Result label for this run.
+ """
+
+ logger.info(
+ f"开始处理MaaEnd日志, 日志长度: {len(logs)}, 日志标记: {maaend_result}"
+ )
+
+ self._save_simple_log(log_path, logs, "maaend_result", maaend_result)
+
+ logger.success(f"MaaEnd日志统计完成, 日志路径: {log_path.with_suffix('.log')}")
+
+ async def save_src_log(
+ self, log_path: Path, logs: list[str], src_result: str
+ ) -> None:
+ """
+ 保存SRC日志并生成对应统计数据
+
+ Args:
+ log_path (Path): 日志文件保存路径
+ logs (list): 日志内容列表
+ src_result (str): 待保存的日志结果信息
+ """
+
+ logger.info(f"开始处理SRC日志, 日志长度: {len(logs)}, 日志标记: {src_result}")
+
+ self._save_simple_log(log_path, logs, "src_result", src_result)
+
+ logger.success(f"SRC日志统计完成, 日志路径: {log_path.with_suffix('.log')}")
+
+ async def save_general_log(
+ self, log_path: Path, logs: list[str], general_result: str
+ ) -> None:
+ """
+ 保存通用日志并生成对应统计数据
+
+ :param log_path: 日志文件保存路径
+ :param logs: 日志内容列表
+ :param general_result: 待保存的日志结果信息
+ """
+
+ logger.info(
+ f"开始处理通用日志, 日志长度: {len(logs)}, 日志标记: {general_result}"
+ )
+
+ self._save_simple_log(log_path, logs, "general_result", general_result)
+
+ logger.success(f"通用日志统计完成, 日志路径: {log_path.with_suffix('.log')}")
+
+ async def merge_statistic_info(
+ self, statistic_path_list: List[Path]
+ ) -> dict[str, Any]:
+ """
+ 合并指定数据统计信息文件
+
+ Args:
+ statistic_path_list (List[Path]): 数据统计信息文件列表
+
+ Returns:
+ dict: 合并后的数据统计信息
+ """
+
+ data: Dict[str, Any] = {"index": {}}
+
+ for json_file in statistic_path_list:
+ try:
+ single_data = json.loads(json_file.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as e:
+ logger.warning(
+ f"无法解析文件 {json_file}, 错误信息: {type(e).__name__}: {str(e)}"
+ )
+ continue
+
+ for key in single_data.keys():
+ if key not in data:
+ data[key] = {}
+
+ # 合并公招统计
+ if key == "recruit_statistics":
+ for star_level, count in single_data[key].items():
+ if star_level not in data[key]:
+ data[key][star_level] = 0
+ data[key][star_level] += count
+
+ # 合并掉落统计
+ elif key == "drop_statistics":
+ for stage, drops in single_data[key].items():
+ if stage not in data[key]:
+ data[key][stage] = {} # 初始化关卡
+
+ for item, count in drops.items():
+ if item not in data[key][stage]:
+ data[key][stage][item] = 0
+ data[key][stage][item] += count
+
+ # 处理理智相关字段 - 使用最后一个文件的值
+ elif key in ["sanity", "sanity_full_at"]:
+ data[key] = single_data[key]
+
+ # 录入运行结果
+ elif key in [
+ "maa_result",
+ "maaend_result",
+ "src_result",
+ "general_result",
+ ]:
+ actual_date = (
+ datetime.strptime(
+ f"{json_file.parent.parent.name} {json_file.stem}",
+ "%Y-%m-%d %H-%M-%S",
+ )
+ .replace(tzinfo=UTC4)
+ .astimezone()
+ )
+
+ if single_data[key] != "Success!":
+ if "error_info" not in data:
+ data["error_info"] = {}
+ data["error_info"][
+ actual_date.strftime("%Y-%m-%d %H:%M:%S")
+ ] = single_data[key]
+
+ data["index"][actual_date] = {
+ "date": actual_date.strftime("%Y-%m-%d %H:%M:%S"),
+ "status": (
+ "DONE" if single_data[key] == "Success!" else "ERROR"
+ ),
+ "jsonFile": str(json_file),
+ }
+
+ data["index"] = [data["index"][_] for _ in sorted(data["index"])]
+
+ # 确保返回的字典始终包含 index 字段,即使为空
+ result = {k: v for k, v in data.items() if v}
+ if "index" not in result:
+ result["index"] = []
+
+ return result
+
+ async def search_history(
+ self,
+ mode: Literal["DAILY", "WEEKLY", "MONTHLY"],
+ start_date: date,
+ end_date: date,
+ ) -> dict[str, dict[str, list[Path]]]:
+ """
+ 搜索指定时间范围内的历史记录
+
+ Args:
+ mode (Literal["DAILY", "WEEKLY", "MONTHLY"]): 合并模式
+ start_date (date): 开始日期
+ end_date (date): 结束日期
+ """
+
+ logger.info(
+ f"开始搜索历史记录, 合并模式: {mode}, 日期范围: {start_date} 至 {end_date}"
+ )
+
+ history_dict: dict[str, dict[str, list[Path]]] = {}
+
+ for date_folder in self._history_path.iterdir():
+ if not date_folder.is_dir():
+ continue # 只处理日期文件夹
+
+ try:
+ folder_date = datetime.strptime(date_folder.name, "%Y-%m-%d").date()
+
+ if not (start_date <= folder_date <= end_date):
+ continue # 只统计在范围内的日期
+
+ if mode == "DAILY":
+ date_name = folder_date.strftime("%Y-%m-%d")
+ elif mode == "WEEKLY":
+ date_name = folder_date.strftime("%G-W%V")
+ elif mode == "MONTHLY":
+ date_name = folder_date.strftime("%Y-%m")
+ else:
+ raise ValueError("无效的合并模式")
+
+ if date_name not in history_dict:
+ history_dict[date_name] = {}
+
+ for user_folder in date_folder.iterdir():
+ if not user_folder.is_dir():
+ continue # 只处理用户文件夹
+
+ if user_folder.stem not in history_dict[date_name]:
+ history_dict[date_name][user_folder.stem] = list(
+ user_folder.with_suffix("").glob("*.json")
+ )
+ else:
+ history_dict[date_name][user_folder.stem] += list(
+ user_folder.with_suffix("").glob("*.json")
+ )
+
+ except ValueError as e:
+ logger.warning(f"非日期格式的目录: {date_folder}, 错误: {e}")
+
+ logger.success(f"历史记录搜索完成, 共计 {len(history_dict)} 条记录")
+
+ return {
+ k: v
+ for k, v in sorted(history_dict.items(), key=lambda kv: kv[0], reverse=True)
+ }
+
+ async def clean_old_history(self, retention_days: int) -> None:
+ """删除超过用户设定天数的历史记录文件(基于目录日期)
+
+ Args:
+ retention_days: Number of days to retain. ``0`` means keep forever.
+ """
+
+ if retention_days == 0:
+ logger.info("历史记录永久保留, 跳过历史记录清理")
+ return
+
+ logger.info("开始清理超过设定天数的历史记录")
+
+ deleted_count = 0
+
+ for date_folder in self._history_path.iterdir():
+ if not date_folder.is_dir():
+ continue # 只处理日期文件夹
+
+ try:
+ # 只检查 `YYYY-MM-DD` 格式的文件夹
+ folder_date = datetime.strptime(date_folder.name, "%Y-%m-%d").date()
+ if datetime.now(tz=UTC4).date() - folder_date > timedelta(
+ days=retention_days
+ ):
+ shutil.rmtree(date_folder, ignore_errors=True)
+ deleted_count += 1
+ logger.debug(f"已删除超期日志目录: {date_folder}")
+ except ValueError:
+ logger.warning(f"非日期格式的目录: {date_folder}")
+
+ logger.success(f"清理完成: {deleted_count} 个日期目录")
diff --git a/app/services/matomo.py b/app/services/matomo.py
index 97995047..4b8e7da1 100644
--- a/app/services/matomo.py
+++ b/app/services/matomo.py
@@ -28,7 +28,6 @@
import time
from typing import Dict, Any, Optional
-from app.core import Config
from app.utils import get_logger
logger = get_logger("信息上报")
@@ -41,10 +40,9 @@ class _MatomoHandler:
site_id = "3"
def __init__(self):
-
self.session = None
- async def _get_session(self):
+ async def _get_session(self) -> aiohttp.ClientSession:
"""获取HTTP会话"""
if self.session is None or self.session.closed:
@@ -59,6 +57,7 @@ async def close(self):
def _build_base_params(self, custom_vars: Optional[Dict[str, Any]] = None):
"""构建基础参数"""
+ from app.core import Config
params = {
"idsite": self.site_id,
"rec": "1",
@@ -103,8 +102,6 @@ async def send_event(
"""
try:
session = await self._get_session()
- if session is None:
- return
params = self._build_base_params(custom_vars)
params.update({"e_c": category, "e_a": action, "e_n": name, "e_v": value})
diff --git a/app/services/migration.py b/app/services/migration.py
new file mode 100644
index 00000000..40df9b1e
--- /dev/null
+++ b/app/services/migration.py
@@ -0,0 +1,387 @@
+# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
+# Copyright © 2024-2025 DLmaster361
+# Copyright © 2025 MoeSnowyFox
+# Copyright © 2025-2026 AUTO-MAS Team
+
+# This file is part of AUTO-MAS.
+
+# AUTO-MAS is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of
+# the License, or (at your option) any later version.
+
+# AUTO-MAS is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty
+# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
+# the GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with AUTO-MAS. If not, see .
+
+# Contact: DLmaster_361@163.com
+
+from __future__ import annotations
+
+import json
+import shutil
+import sqlite3
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Dict, Optional, cast
+
+import tomllib
+
+from app.core.config.base import dump_toml
+from app.utils import get_logger
+
+if TYPE_CHECKING:
+ from app.core.config.manager import AppConfig
+
+logger = get_logger("数据迁移")
+
+
+class MigrationService:
+ """Handles data-file version migration for the application database."""
+
+ def __init__(self, config: AppConfig) -> None:
+ self._config = config
+
+ def _resolve_config_path(self, stem: str) -> Path:
+ """返回运行期 TOML 配置路径。"""
+ return self._config.config_path / f"{stem}.toml"
+
+ def _read_mapping_config(self, path: Path) -> dict[str, Any]:
+ """读取 TOML/JSON 字典配置文件并返回映射对象。"""
+ if not path.exists():
+ return {}
+
+ text = path.read_text(encoding="utf-8")
+ if not text.strip():
+ return {}
+
+ if path.suffix == ".toml":
+ data = tomllib.loads(text)
+ else:
+ data = json.loads(text)
+ if isinstance(data, dict):
+ return cast(dict[str, Any], data)
+ return {}
+
+ def _write_mapping_config(self, path: Path, data: dict[str, Any]) -> None:
+ """将字典配置写入 TOML/JSON 文件。"""
+ if path.suffix == ".toml":
+ path.write_text(dump_toml(data), encoding="utf-8")
+ return
+
+ path.write_text(
+ json.dumps(data, ensure_ascii=False, indent=4), encoding="utf-8"
+ )
+
+ async def check_data(self) -> None:
+ """检查用户数据文件并处理数据文件版本更新"""
+
+ # 生成主数据库
+ if not self._config.database_path.exists():
+ db = sqlite3.connect(self._config.database_path)
+ cur = db.cursor()
+ cur.execute("CREATE TABLE version(v text)")
+ cur.execute("INSERT INTO version VALUES(?)", ("v1.11",))
+ db.commit()
+ cur.close()
+ db.close()
+
+ # 数据文件版本更新
+ db = sqlite3.connect(self._config.database_path)
+ cur = db.cursor()
+ cur.execute("SELECT * FROM version WHERE True")
+ version = cur.fetchall()
+
+ if version[0][0] != "v1.11":
+ logger.info(
+ "数据文件版本更新开始",
+ )
+ if_streaming = False
+ # v1.7-->v1.8
+ if version[0][0] == "v1.7" or if_streaming:
+ logger.info(
+ "数据文件版本更新: v1.7-->v1.8",
+ )
+ if_streaming = True
+
+ if (Path.cwd() / "config/QueueConfig").exists():
+ for QueueConfig in (Path.cwd() / "config/QueueConfig").glob(
+ "*.json"
+ ):
+ with QueueConfig.open(encoding="utf-8") as f:
+ queue_config = json.load(f)
+
+ queue_config["QueueSet"]["TimeEnabled"] = queue_config[
+ "QueueSet"
+ ]["Enabled"]
+
+ for i in range(10):
+ queue_config["Queue"][f"Script_{i}"] = queue_config[
+ "Queue"
+ ][f"Member_{i + 1}"]
+ queue_config["Time"][f"Enabled_{i}"] = queue_config["Time"][
+ f"TimeEnabled_{i}"
+ ]
+ queue_config["Time"][f"Set_{i}"] = queue_config["Time"][
+ f"TimeSet_{i}"
+ ]
+
+ with QueueConfig.open("w", encoding="utf-8") as f:
+ json.dump(queue_config, f, ensure_ascii=False, indent=4)
+
+ cur.execute("DELETE FROM version WHERE v = ?", ("v1.7",))
+ cur.execute("INSERT INTO version VALUES(?)", ("v1.8",))
+ db.commit()
+ # v1.8-->v1.9
+ if version[0][0] == "v1.8" or if_streaming:
+ logger.info(
+ "数据文件版本更新: v1.8-->v1.9",
+ )
+ if_streaming = True
+
+ await self._config.ScriptConfig.connect(
+ self._resolve_config_path("ScriptConfig")
+ )
+ await self._config.PlanConfig.connect(
+ self._resolve_config_path("PlanConfig")
+ )
+ await self._config.QueueConfig.connect(
+ self._resolve_config_path("QueueConfig")
+ )
+
+ if (Path.cwd() / "config/config.json").exists():
+ (Path.cwd() / "config/config.json").rename(
+ Path.cwd() / "config/Config.json"
+ )
+ await self._config.connect(self._resolve_config_path("Config"))
+
+ plan_dict = {"固定": "Fixed"}
+
+ if (Path.cwd() / "config/MaaPlanConfig").exists():
+ for MaaPlanConfig in (
+ Path.cwd() / "config/MaaPlanConfig"
+ ).iterdir():
+ if (
+ MaaPlanConfig.is_dir()
+ and (MaaPlanConfig / "config.json").exists()
+ ):
+ maa_plan_config = json.loads(
+ (MaaPlanConfig / "config.json").read_text(
+ encoding="utf-8"
+ )
+ )
+ uid, pc = await self._config.add_plan("MaaPlan")
+ plan_dict[MaaPlanConfig.name] = str(uid)
+
+ await pc.load(maa_plan_config)
+
+ script_dict: Dict[str, Optional[str]] = {"禁用": None}
+
+ if (Path.cwd() / "config/MaaConfig").exists():
+ for MaaConfig in (Path.cwd() / "config/MaaConfig").iterdir():
+ if MaaConfig.is_dir():
+ maa_config = json.loads(
+ (MaaConfig / "config.json").read_text(encoding="utf-8")
+ )
+ maa_config["Info"] = maa_config["MaaSet"]
+ maa_config["Run"] = maa_config["RunSet"]
+
+ uid, sc = await self._config.add_script("MAA")
+ script_dict[MaaConfig.name] = str(uid)
+ await sc.load(maa_config)
+
+ if (MaaConfig / "Default/gui.json").exists():
+ (Path.cwd() / f"data/{uid}/Default/ConfigFile").mkdir(
+ parents=True, exist_ok=True
+ )
+ shutil.copy(
+ MaaConfig / "Default/gui.json",
+ Path.cwd()
+ / f"data/{uid}/Default/ConfigFile/gui.json",
+ )
+
+ for user in (MaaConfig / "UserData").iterdir():
+ if user.is_dir() and (user / "config.json").exists():
+ user_config = json.loads(
+ (user / "config.json").read_text(
+ encoding="utf-8"
+ )
+ )
+
+ user_config["Info"]["StageMode"] = plan_dict.get(
+ user_config["Info"]["StageMode"], "Fixed"
+ )
+ user_config["Info"]["Password"] = ""
+
+ user_uid, uc = await self._config.add_user(str(uid))
+ await uc.load(user_config)
+
+ if (user / "Routine/gui.json").exists():
+ (
+ Path.cwd()
+ / f"data/{uid}/{user_uid}/ConfigFile"
+ ).mkdir(parents=True, exist_ok=True)
+ shutil.copy(
+ user / "Routine/gui.json",
+ Path.cwd()
+ / f"data/{uid}/{user_uid}/ConfigFile/gui.json",
+ )
+ if (
+ user / "Infrastructure/infrastructure.json"
+ ).exists():
+ (
+ Path.cwd()
+ / f"data/{uid}/{user_uid}/Infrastructure"
+ ).mkdir(parents=True, exist_ok=True)
+ shutil.copy(
+ user / "Infrastructure/infrastructure.json",
+ Path.cwd()
+ / f"data/{uid}/{user_uid}/Infrastructure/infrastructure.json",
+ )
+
+ if (Path.cwd() / "config/GeneralConfig").exists():
+ for GeneralConfig in (
+ Path.cwd() / "config/GeneralConfig"
+ ).iterdir():
+ if GeneralConfig.is_dir():
+ general_config = json.loads(
+ (GeneralConfig / "config.json").read_text(
+ encoding="utf-8"
+ )
+ )
+ general_config["Info"] = {
+ "Name": general_config["Script"]["Name"],
+ "RootPath": general_config["Script"]["RootPath"],
+ }
+
+ general_config["Script"]["ConfigPathMode"] = (
+ "File"
+ if "所有文件"
+ in general_config["Script"]["ConfigPathMode"]
+ else "Folder"
+ )
+
+ uid, sc = await self._config.add_script("General")
+ script_dict[GeneralConfig.name] = str(uid)
+ await sc.load(general_config)
+
+ for user in (GeneralConfig / "SubData").iterdir():
+ if user.is_dir() and (user / "config.json").exists():
+ user_config = json.loads(
+ (user / "config.json").read_text(
+ encoding="utf-8"
+ )
+ )
+
+ user_uid, uc = await self._config.add_user(str(uid))
+ await uc.load(user_config)
+
+ if (user / "ConfigFiles").exists():
+ (Path.cwd() / f"data/{uid}/{user_uid}").mkdir(
+ parents=True, exist_ok=True
+ )
+ shutil.move(
+ user / "ConfigFiles",
+ Path.cwd()
+ / f"data/{uid}/{user_uid}/ConfigFile",
+ )
+
+ if (Path.cwd() / "config/QueueConfig").exists():
+ for QueueConfig in (Path.cwd() / "config/QueueConfig").glob(
+ "*.json"
+ ):
+ queue_config = json.loads(
+ QueueConfig.read_text(encoding="utf-8")
+ )
+
+ uid, qc = await self._config.add_queue()
+
+ queue_config["Info"] = queue_config["QueueSet"]
+ await qc.load(queue_config)
+
+ for i in range(10):
+ _, item = await self._config.add_queue_item(str(uid))
+ _, time = await self._config.add_time_set(str(uid))
+
+ await time.load(
+ {
+ "Info": {
+ "Enabled": queue_config["Time"][f"Enabled_{i}"],
+ "Time": queue_config["Time"][f"Set_{i}"],
+ }
+ }
+ )
+ await item.load(
+ {
+ "Info": {
+ "ScriptId": script_dict.get(
+ queue_config["Queue"][f"Script_{i}"], "-"
+ )
+ }
+ }
+ )
+
+ if (Path.cwd() / "config/QueueConfig").exists():
+ shutil.rmtree(Path.cwd() / "config/QueueConfig")
+ if (Path.cwd() / "config/MaaPlanConfig").exists():
+ shutil.rmtree(Path.cwd() / "config/MaaPlanConfig")
+ if (Path.cwd() / "config/MaaConfig").exists():
+ shutil.rmtree(Path.cwd() / "config/MaaConfig")
+ if (Path.cwd() / "config/GeneralConfig").exists():
+ shutil.rmtree(Path.cwd() / "config/GeneralConfig")
+ if (Path.cwd() / "data/gameid.txt").exists():
+ (Path.cwd() / "data/gameid.txt").unlink()
+ if (Path.cwd() / "data/key").exists():
+ shutil.rmtree(Path.cwd() / "data/key")
+
+ cur.execute("DELETE FROM version WHERE v = ?", ("v1.8",))
+ cur.execute("INSERT INTO version VALUES(?)", ("v1.9",))
+ db.commit()
+ # v1.9-->v1.10
+ if version[0][0] == "v1.9" or if_streaming:
+ logger.info(
+ "数据文件版本更新: v1.9-->v1.10",
+ )
+ if_streaming = True
+
+ global_config_path = self._resolve_config_path("Config")
+ if global_config_path.exists():
+ data = self._read_mapping_config(global_config_path)
+ data["Data"]["LastStageUpdated"] = ""
+ data["Data"]["Stage"] = "{ }"
+ data["Function"]["IfBlockAd"] = data["Function"].get(
+ "IfSkipMumuSplashAds", False
+ )
+ self._write_mapping_config(global_config_path, data)
+
+ cur.execute("DELETE FROM version WHERE v = ?", ("v1.9",))
+ cur.execute("INSERT INTO version VALUES(?)", ("v1.10",))
+ db.commit()
+ # v1.10-->v1.11
+ if version[0][0] == "v1.10" or if_streaming:
+ logger.info(
+ "数据文件版本更新: v1.10-->v1.11",
+ )
+ if_streaming = True
+
+ script_config_path = self._resolve_config_path("ScriptConfig")
+ if script_config_path.exists():
+ data = script_config_path.read_text(encoding="utf-8")
+ data = data.replace("IfWakeUp", "IfStartUp")
+ data = data.replace("IfAutoRoguelike", "IfRoguelike")
+ data = data.replace("IfBase", "IfInfrast")
+ data = data.replace("IfCombat", "IfFight")
+ data = data.replace("IfMission", "IfAward")
+ data = data.replace("IfRecruiting", "IfRecruit")
+ script_config_path.write_text(data, encoding="utf-8")
+
+ cur.execute("DELETE FROM version WHERE v = ?", ("v1.10",))
+ cur.execute("INSERT INTO version VALUES(?)", ("v1.11",))
+ db.commit()
+
+ cur.close()
+ db.close()
+ logger.success("数据文件版本更新完成")
diff --git a/app/services/notification.py b/app/services/notification.py
index 1df7810b..1218f16b 100644
--- a/app/services/notification.py
+++ b/app/services/notification.py
@@ -1,7 +1,6 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
# Copyright © 2024-2025 DLmaster361
# Copyright © 2025-2026 AUTO-MAS Team
-import asyncio
# This file is part of AUTO-MAS.
@@ -26,23 +25,22 @@
import smtplib
import httpx
from datetime import datetime
-from plyer import notification
+from importlib import import_module
from email.header import Header
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formataddr
from pathlib import Path
-from typing import Literal
+from typing import Any, Literal, cast
-from app.core import Config
-from app.models.config import Webhook
+from app.models import Webhook
from app.utils import get_logger, ImageUtils
logger = get_logger("通知服务")
+notification = import_module("plyer").notification
class Notification:
-
async def push_plyer(self, title: str, message: str, ticker: str, t: int) -> None:
"""
推送系统通知
@@ -59,6 +57,7 @@ async def push_plyer(self, title: str, message: str, ticker: str, t: int) -> Non
通知持续时间
"""
+ from app.core import Config
if not Config.get("Notify", "IfPushPlyer"):
return
@@ -95,6 +94,7 @@ async def send_mail(
收件人地址
"""
+ from app.core import Config
if Config.get("Notify", "SMTPServerAddress") == "":
raise ValueError("邮件通知的SMTP服务器地址不能为空")
if Config.get("Notify", "AuthorizationCode") == "":
@@ -115,10 +115,13 @@ async def send_mail(
raise ValueError("邮件通知的接收邮箱格式错误或为空")
# 定义邮件正文
+ message: MIMEText | MIMEMultipart
if mode == "文本":
message = MIMEText(content, "plain", "utf-8")
elif mode == "网页":
message = MIMEMultipart("alternative")
+ else:
+ raise ValueError(f"不支持的邮件模式: {mode}")
message["From"] = formataddr(
(
Header("AUTO-MAS通知服务", "utf-8").encode(),
@@ -175,6 +178,7 @@ async def ServerChanPush(self, title: str, content: str, send_key: str) -> None:
params = {"title": title, "desp": content}
headers = {"Content-Type": "application/json;charset=utf-8"}
+ from app.core import Config
async with httpx.AsyncClient(proxy=Config.proxy) as client:
response = await client.post(url, json=params, headers=headers)
result = response.json()
@@ -211,7 +215,6 @@ async def WebhookPush(self, title: str, content: str, webhook: Webhook) -> None:
# 替换模板变量
try:
-
# 准备模板变量
template_vars = {
"title": title,
@@ -230,11 +233,16 @@ async def WebhookPush(self, title: str, content: str, webhook: Webhook) -> None:
template_obj = json.loads(template)
# 递归替换JSON对象中的变量
- def replace_variables(obj):
+ def replace_variables(obj: Any) -> Any:
if isinstance(obj, dict):
- return {k: replace_variables(v) for k, v in obj.items()}
+ obj_mapping = cast(dict[str, Any], obj)
+ return {
+ key: replace_variables(value)
+ for key, value in obj_mapping.items()
+ }
elif isinstance(obj, list):
- return [replace_variables(item) for item in obj]
+ obj_list = cast(list[Any], obj)
+ return [replace_variables(item) for item in obj_list]
elif isinstance(obj, str):
result = obj
for key, value in template_vars.items():
@@ -279,30 +287,43 @@ def replace_variables(obj):
headers = {"Content-Type": "application/json"}
headers.update(json.loads(webhook.get("Data", "Headers")))
+ from app.core import Config
async with httpx.AsyncClient(proxy=Config.proxy, timeout=10) as client:
+ response: httpx.Response
if webhook.get("Data", "Method") == "POST":
if isinstance(data, dict):
+ payload = cast(dict[str, Any], data)
response = await client.post(
- url=webhook.get("Data", "Url"), json=data, headers=headers
+ url=webhook.get("Data", "Url"), json=payload, headers=headers
)
elif isinstance(data, str):
response = await client.post(
url=webhook.get("Data", "Url"), content=data, headers=headers
)
+ else:
+ response = await client.post(
+ url=webhook.get("Data", "Url"),
+ content=str(data),
+ headers=headers,
+ )
elif webhook.get("Data", "Method") == "GET":
if isinstance(data, dict):
# Flatten params to ensure all values are str or list of str
- params = {}
- for k, v in data.items():
+ params: dict[str, str] = {}
+ for k, v in cast(dict[str, Any], data).items():
if isinstance(v, (dict, list)):
- params[k] = json.dumps(v, ensure_ascii=False)
+ params[str(k)] = json.dumps(v, ensure_ascii=False)
else:
- params[k] = str(v)
+ params[str(k)] = str(v)
else:
- params = {"message": str(data)}
+ params: dict[str, str] = {"message": str(data)}
response = await client.get(
url=webhook.get("Data", "Url"), params=params, headers=headers
)
+ else:
+ raise ValueError(
+ f"不支持的 Webhook 方法: {webhook.get('Data', 'Method')}"
+ )
# 检查响应
if response.status_code == 200:
@@ -312,7 +333,7 @@ def replace_variables(obj):
else:
raise Exception(f"HTTP {response.status_code}: {response.text}")
- async def _WebHookPush(self, title, content, webhook_url) -> None:
+ async def _WebHookPush(self, title: str, content: str, webhook_url: str) -> None:
"""
WebHook 推送通知 (即将弃用)
@@ -327,6 +348,7 @@ async def _WebHookPush(self, title, content, webhook_url) -> None:
content = f"{title}\n{content}"
data = {"msgtype": "text", "text": {"content": content}}
+ from app.core import Config
async with httpx.AsyncClient(proxy=Config.proxy) as client:
response = await client.post(url=webhook_url, json=data)
info = response.json()
@@ -365,6 +387,7 @@ async def CompanyWebHookBotPushImage(
"image": {"base64": image_base64, "md5": image_md5},
}
+ from app.core import Config
async with httpx.AsyncClient(proxy=Config.proxy) as client:
response = await client.post(url=webhook_url, json=data)
info = response.json()
@@ -422,7 +445,7 @@ async def send_koishi(
if success:
logger.success(f"Koishi 通知推送成功: {message[:50]}")
else:
- logger.error(f"Koishi 通知推送失败: 发送消息失败")
+ logger.error("Koishi 通知推送失败: 发送消息失败")
return success
@@ -440,6 +463,7 @@ async def send_test_notification(self) -> None:
)
# 发送邮件通知
+ from app.core import Config
if Config.get("Notify", "IfSendMail"):
await self.send_mail(
"文本",
diff --git a/app/services/system.py b/app/services/system.py
index 350b4959..c82994a2 100644
--- a/app/services/system.py
+++ b/app/services/system.py
@@ -32,20 +32,18 @@
from pathlib import Path
from typing import Literal, Optional
-from app.core import Config
from app.utils import ProcessRunner, get_logger
logger = get_logger("系统服务")
class _SystemHandler:
-
ES_CONTINUOUS = 0x80000000
ES_SYSTEM_REQUIRED = 0x00000001
countdown = 60
def __init__(self) -> None:
- self.power_task: Optional[asyncio.Task] = None
+ self.power_task: Optional[asyncio.Task[None]] = None
async def set_Sleep(self, if_allow_sleep: bool) -> None:
"""
@@ -77,7 +75,6 @@ async def set_SelfStart(self, if_self_start: bool) -> None:
"""
if if_self_start and not await self.is_startup():
-
# 创建任务计划
# 获取当前用户和时间
@@ -167,9 +164,7 @@ async def set_SelfStart(self, if_self_start: bool) -> None:
Path(xml_file).unlink()
elif not if_self_start and await self.is_startup():
-
try:
-
result = await ProcessRunner.run_process(
"schtasks", "/delete", "/tn", "AUTO-MAS_AutoStart", "/f"
)
@@ -204,13 +199,10 @@ async def set_power(
"""
if sys.platform.startswith("win"):
-
if mode == "NoAction":
-
logger.info("不执行系统电源操作")
elif mode == "Shutdown":
-
await self.kill_emulator_processes()
logger.info("执行关机操作")
subprocess.run(["shutdown", "/s", "/t", "0"])
@@ -220,66 +212,59 @@ async def set_power(
subprocess.run(["shutdown", "/s", "/t", "0", "/f"])
elif mode == "Reboot":
-
await self.kill_emulator_processes()
logger.info("执行重启操作")
subprocess.run(["shutdown", "/r", "/t", "0"])
elif mode == "Hibernate":
-
logger.info("执行休眠操作")
subprocess.run(["shutdown", "/h"])
elif mode == "Sleep":
-
logger.info("执行睡眠操作")
subprocess.run(
["rundll32.exe", "powrprof.dll,SetSuspendState", "0,1,0"]
)
- elif mode == "KillSelf" and Config.server is not None:
-
- logger.info("执行退出主程序操作")
- if not from_frontend:
- await Config.send_websocket_message(
- id="Main", type="Signal", data={"RequestClose": "请求前端关闭"}
- )
- Config.server.should_exit = True
+ elif mode == "KillSelf":
+ from app.core import Config
+ if Config.server is not None:
+ logger.info("执行退出主程序操作")
+ if not from_frontend:
+ await Config.send_websocket_message(
+ id="Main", type="Signal", data={"RequestClose": "请求前端关闭"}
+ )
+ Config.server.should_exit = True
elif sys.platform.startswith("linux"):
-
if mode == "NoAction":
-
logger.info("不执行系统电源操作")
elif mode == "Shutdown":
-
logger.info("执行关机操作")
subprocess.run(["shutdown", "-h", "now"])
elif mode == "Reboot":
-
logger.info("执行重启操作")
subprocess.run(["shutdown", "-r", "now"])
elif mode == "Hibernate":
-
logger.info("执行休眠操作")
subprocess.run(["systemctl", "hibernate"])
elif mode == "Sleep":
-
logger.info("执行睡眠操作")
subprocess.run(["systemctl", "suspend"])
- elif mode == "KillSelf" and Config.server is not None:
-
- logger.info("执行退出主程序操作")
- if not from_frontend:
- await Config.send_websocket_message(
- id="Main", type="Signal", data={"RequestClose": "请求前端关闭"}
- )
- Config.server.should_exit = True
+ elif mode == "KillSelf":
+ from app.core import Config
+ if Config.server is not None:
+ logger.info("执行退出主程序操作")
+ if not from_frontend:
+ await Config.send_websocket_message(
+ id="Main", type="Signal", data={"RequestClose": "请求前端关闭"}
+ )
+ Config.server.should_exit = True
async def _power_task(
self,
@@ -302,6 +287,7 @@ async def start_power_task(self):
"""开始电源任务"""
if self.power_task is None or self.power_task.done():
+ from app.core import Config
self.power_task = asyncio.create_task(self._power_task(Config.power_sign))
logger.info(
f"电源任务已启动, {self.countdown}秒后执行: {Config.power_sign}"
@@ -380,7 +366,7 @@ async def kill_process(self, path: Path) -> None:
logger.success(f"进程已中止: {path}")
- async def search_pids(self, path: Path) -> list:
+ async def search_pids(self, path: Path) -> list[int]:
"""
根据路径查找进程PID
@@ -390,7 +376,7 @@ async def search_pids(self, path: Path) -> list:
logger.info(f"开始查找进程 PID: {path}")
- pids = []
+ pids: list[int] = []
for proc in psutil.process_iter(["pid", "exe"]):
with suppress(
psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess
diff --git a/app/services/update.py b/app/services/update.py
index 7e3511ab..ce2460f1 100644
--- a/app/services/update.py
+++ b/app/services/update.py
@@ -30,10 +30,16 @@
import subprocess
from packaging import version
from datetime import datetime, timedelta
-from typing import List, Dict, Optional
+from typing import Any, List, Dict, Optional, cast
from pathlib import Path
+from tenacity import (
+ AsyncRetrying,
+ RetryCallState,
+ retry_if_exception_type,
+ stop_after_attempt,
+ wait_exponential,
+)
-from app.core import Config
from app.utils.constants import MIRROR_ERROR_INFO
from app.utils import ProcessRunner, get_logger
from .system import System
@@ -41,10 +47,39 @@
logger = get_logger("更新服务")
-class _UpdateHandler:
+_VERSION_CORE_PATTERN = re.compile(r"(?i)v?(\d+(?:\.\d+)+)")
+
+
+def _log_retry_before_sleep(retry_state: RetryCallState) -> None:
+ """输出重试等待日志。"""
+
+ attempt = retry_state.attempt_number
+ reason = retry_state.outcome.exception() if retry_state.outcome else None
+ logger.warning(f"下载失败,准备第 {attempt + 1} 次重试,原因: {reason}")
+
+
+def _parse_release_version(raw_version: str) -> version.Version:
+ normalized_version = raw_version.strip()
+ if normalized_version.startswith(("v", "V")):
+ normalized_version = normalized_version[1:]
+ try:
+ return version.parse(normalized_version)
+ except version.InvalidVersion:
+ match = _VERSION_CORE_PATTERN.search(normalized_version)
+ if match is None:
+ raise
+
+ fallback_version = match.group(1)
+ logger.debug(
+ f"Version {raw_version} is not PEP 440, fallback to core version {fallback_version}"
+ )
+ return version.parse(fallback_version)
+
+
+class _UpdateHandler:
def __init__(self) -> None:
- self.is_locked: bool = False
+ self._operation_lock = asyncio.Lock()
self.remote_version: Optional[str] = None
self.last_check_time: Optional[datetime] = None
self.update_version_info: Optional[Dict[str, List[str]]] = None
@@ -53,6 +88,7 @@ def __init__(self) -> None:
async def check_update(
self, current_version: str, if_force: bool = False
) -> tuple[bool, str, Dict[str, List[str]]]:
+ """检查更新并返回是否有新版本、远端版本号与变更摘要。"""
if (
not if_force
@@ -62,15 +98,17 @@ async def check_update(
and self.last_check_time > datetime.now() - timedelta(hours=4)
):
logger.info("四小时内已进行过一次检查, 直接使用缓存的版本更新信息")
+ current_parsed_version = _parse_release_version(current_version)
+ remote_parsed_version = _parse_release_version(self.remote_version)
return (
- bool(
- version.parse(self.remote_version) > version.parse(current_version)
- ),
+ bool(remote_parsed_version > current_parsed_version),
self.remote_version,
self.update_version_info,
)
+ from app.core import Config
logger.info("开始检查更新")
+ version_info: dict[str, Any] = {}
# 使用 httpx 异步请求
async with httpx.AsyncClient(
@@ -80,9 +118,9 @@ async def check_update(
f"https://mirrorchyan.com/api/resources/AUTO_MAS/latest?user_agent=AutoMasGui&os=win&arch=x64¤t_version={current_version}&cdk={Config.get('Update', 'MirrorChyanCDK') if Config.get('Update', 'Source') == 'MirrorChyan' else ''}&channel={Config.get('Update', 'Channel')}"
)
if response.status_code == 200:
- version_info = response.json()
+ version_info = cast(dict[str, Any], response.json())
else:
- result = response.json()
+ result = cast(dict[str, Any], response.json())
if result["code"] != 0:
if result["code"] in MIRROR_ERROR_INFO:
@@ -97,20 +135,24 @@ async def check_update(
logger.success("获取版本信息成功")
self.last_check_time = datetime.now()
- self.remote_version = version_info["data"]["version_name"]
+ data = cast(dict[str, Any], version_info.get("data", {}))
+ version_name = data.get("version_name")
+ self.remote_version = str(version_name) if version_name is not None else None
if self.remote_version is None:
raise Exception("Mirror 酱未返回版本号, 请稍后重试")
- if "url" in version_info["data"]:
- self.mirror_chyan_download_url = version_info["data"]["url"]
+ if "url" in data:
+ self.mirror_chyan_download_url = cast(Optional[str], data.get("url"))
- if version.parse(self.remote_version) > version.parse(current_version):
+ current_parsed_version = _parse_release_version(current_version)
+ remote_parsed_version = _parse_release_version(self.remote_version)
+ if remote_parsed_version > current_parsed_version:
# 版本更新信息
version_info_json: Dict[str, Dict[str, List[str]]] = json.loads(
re.sub(
r"^$",
r"\1",
- version_info["data"]["release_note"].splitlines()[0].strip(),
+ str(data.get("release_note", "")).splitlines()[0].strip(),
)
)
@@ -118,9 +160,8 @@ async def check_update(
for v_i in [
info
for ver, info in version_info_json.items()
- if version.parse(ver) > version.parse(current_version)
+ if _parse_release_version(ver) > current_parsed_version
]:
-
for key, value in v_i.items():
if key not in self.update_version_info:
self.update_version_info[key] = []
@@ -132,111 +173,94 @@ async def check_update(
return False, current_version, {}
async def download_update(self) -> None:
+ """下载更新包并通过 WebSocket 上报进度。"""
+ from app.core import Config
logger.info("收到前端下载请求")
- if self.is_locked:
+ if self._operation_lock.locked():
await Config.send_websocket_message(
id="Update",
type="Signal",
data={"Failed": "已有更新任务在进行中, 请勿重复操作"},
)
return None
+ async with self._operation_lock:
+ if self.remote_version is None:
+ await Config.send_websocket_message(
+ id="Update",
+ type="Signal",
+ data={"Failed": "未检测到可用的远程版本, 请先检查更新"},
+ )
+ return None
- self.is_locked = True
-
- if self.remote_version is None:
- await Config.send_websocket_message(
- id="Update",
- type="Signal",
- data={"Failed": "未检测到可用的远程版本, 请先检查更新"},
- )
- self.is_locked = False
- return None
-
- if (Path.cwd() / f"UpdatePack_{self.remote_version}.zip").exists():
- logger.info(
- f"更新包已存在: {Path.cwd() / f'UpdatePack_{self.remote_version}.zip'}"
- )
- await Config.send_websocket_message(
- id="Update",
- type="Signal",
- data={
- "Accomplish": str(
- Path.cwd() / f"UpdatePack_{self.remote_version}.zip"
- )
- },
- )
- self.is_locked = False
- return None
-
- if Config.get("Update", "Source") == "GitHub":
+ if (Path.cwd() / f"UpdatePack_{self.remote_version}.zip").exists():
+ logger.info(
+ f"更新包已存在: {Path.cwd() / f'UpdatePack_{self.remote_version}.zip'}"
+ )
+ await Config.send_websocket_message(
+ id="Update",
+ type="Signal",
+ data={
+ "Accomplish": str(
+ Path.cwd() / f"UpdatePack_{self.remote_version}.zip"
+ )
+ },
+ )
+ return None
- download_url = f"https://github.com/AUTO-MAS-Project/AUTO-MAS/releases/download/{self.remote_version}/AUTO-MAS-Lite-Setup-{self.remote_version}-x64.zip"
+ if Config.get("Update", "Source") == "GitHub":
+ download_url = f"https://github.com/AUTO-MAS-Project/AUTO-MAS/releases/download/{self.remote_version}/AUTO-MAS-Lite-Setup-{self.remote_version}-x64.zip"
- elif Config.get("Update", "Source") == "MirrorChyan":
+ elif Config.get("Update", "Source") == "MirrorChyan":
+ if self.mirror_chyan_download_url is None:
+ logger.warning("MirrorChyan 未返回下载链接, 使用自建下载站")
+ download_url = f"https://download.auto-mas.top/d/AUTO-MAS/AUTO-MAS-Lite-Setup-{self.remote_version}-x64.zip"
+ else:
+ download_url = self.mirror_chyan_download_url
- if self.mirror_chyan_download_url is None:
- logger.warning("MirrorChyan 未返回下载链接, 使用自建下载站")
+ elif Config.get("Update", "Source") == "AutoSite":
download_url = f"https://download.auto-mas.top/d/AUTO-MAS/AUTO-MAS-Lite-Setup-{self.remote_version}-x64.zip"
- else:
- download_url = self.mirror_chyan_download_url
-
- elif Config.get("Update", "Source") == "AutoSite":
- download_url = f"https://download.auto-mas.top/d/AUTO-MAS/AUTO-MAS-Lite-Setup-{self.remote_version}-x64.zip"
-
- elif Config.get("Update", "Source") == "CNB":
- download_url = f"https://cnb.cool/AUTO-MAS-Project/AUTO-MAS/-/releases/download/{self.remote_version}/AUTO-MAS-Lite-Setup-{self.remote_version}-x64.zip"
-
- else:
- await Config.send_websocket_message(
- id="Update",
- type="Signal",
- data={
- "Failed": f"未知的下载源: {Config.get('Update', 'Source')}, 请检查配置文件"
- },
- )
- self.is_locked = False
- return None
- logger.info(f"开始下载: {download_url}")
+ else:
+ await Config.send_websocket_message(
+ id="Update",
+ type="Signal",
+ data={
+ "Failed": f"未知的下载源: {Config.get('Update', 'Source')}, 请检查配置文件"
+ },
+ )
+ return None
- check_times = 3
- while check_times != 0:
+ logger.info(f"开始下载: {download_url}")
- try:
+ async def _download_once() -> None:
# 清理可能存在的临时文件
if (Path.cwd() / "download.temp").exists():
(Path.cwd() / "download.temp").unlink()
start_time = time.time()
+ downloaded_size = 0
- # 使用 httpx 异步流式下载
async with httpx.AsyncClient(follow_redirects=True) as client:
async with client.stream(
"GET", download_url, timeout=30.0
) as response:
status_code = response.status_code
-
if status_code not in [200, 206]:
- if check_times != -1:
- check_times -= 1
-
- logger.warning(
- f"连接失败: {download_url}, 状态码: {status_code}, 剩余重试次数: {check_times}"
+ raise httpx.HTTPStatusError(
+ f"下载响应状态码异常: {status_code}",
+ request=response.request,
+ response=response,
)
- await asyncio.sleep(1)
- continue
logger.info(f"连接成功: {download_url}, 状态码: {status_code}")
file_size = int(response.headers.get("content-length", 0) or 0)
- downloaded_size = 0
last_download_size = 0
- speed = 0
+ speed = 0.0
last_time = time.time()
- # 使用 aiofiles 异步写入临时文件
async with aiofiles.open(
Path.cwd() / "download.temp", "wb"
) as f:
@@ -246,7 +270,6 @@ async def download_update(self) -> None:
await f.write(chunk)
downloaded_size += len(chunk)
- # 更新指定线程的下载进度, 每秒更新一次
if time.time() - last_time >= 1.0:
elapsed = time.time() - last_time
if elapsed <= 0:
@@ -267,7 +290,6 @@ async def download_update(self) -> None:
},
)
- # 重命名临时文件为最终包
(Path.cwd() / "download.temp").rename(
Path.cwd() / f"UpdatePack_{self.remote_version}.zip"
)
@@ -284,31 +306,34 @@ async def download_update(self) -> None:
)
},
)
- self.is_locked = False
- break
- except Exception as e:
-
- if check_times != -1:
- check_times -= 1
-
- logger.exception(
- f"下载出错: {download_url}, 错误信息: {e}, 剩余重试次数: {check_times}"
+ try:
+ async for attempt in AsyncRetrying(
+ stop=stop_after_attempt(3),
+ wait=wait_exponential(multiplier=1, min=1, max=10),
+ retry=retry_if_exception_type(
+ (httpx.HTTPError, OSError, asyncio.TimeoutError)
+ ),
+ before_sleep=_log_retry_before_sleep,
+ reraise=True,
+ ):
+ with attempt:
+ await _download_once()
+ except (httpx.HTTPError, OSError, asyncio.TimeoutError) as e:
+ if (Path.cwd() / "download.temp").exists():
+ (Path.cwd() / "download.temp").unlink()
+ logger.exception(f"下载出错: {download_url}, 错误信息: {e}")
+ await Config.send_websocket_message(
+ id="Update",
+ type="Signal",
+ data={"Failed": f"下载失败: {download_url}"},
)
- await asyncio.sleep(1)
-
- else:
-
- if (Path.cwd() / "download.temp").exists():
- (Path.cwd() / "download.temp").unlink()
- await Config.send_websocket_message(
- id="Update", type="Signal", data={"Failed": f"下载失败: {download_url}"}
- )
- self.is_locked = False
async def install_update(self):
+ """解压并安装已下载的更新包。"""
- if self.is_locked:
+ from app.core import Config
+ if self._operation_lock.locked():
await Config.send_websocket_message(
id="Update",
type="Signal",
@@ -316,77 +341,74 @@ async def install_update(self):
)
return None
- logger.info("开始应用更新")
- self.is_locked = True
+ async with self._operation_lock:
+ logger.info("开始应用更新")
- versions = {
- version.parse(match.group(1)): f.name
- for f in Path.cwd().glob("UpdatePack_*.zip")
- if (match := re.match(r"UpdatePack_(.+)\.zip$", f.name))
- }
- logger.info(f"检测到的更新包: {versions.values()}")
+ versions = {
+ _parse_release_version(match.group(1)): f.name
+ for f in Path.cwd().glob("UpdatePack_*.zip")
+ if (match := re.match(r"UpdatePack_(.+)\.zip$", f.name))
+ }
+ logger.info(f"检测到的更新包: {versions.values()}")
- if not versions:
- await Config.send_websocket_message(
- id="Update",
- type="Signal",
- data={"Failed": "未检测到更新包, 请先下载更新"},
- )
- self.is_locked = False
- return None
+ if not versions:
+ await Config.send_websocket_message(
+ id="Update",
+ type="Signal",
+ data={"Failed": "未检测到更新包, 请先下载更新"},
+ )
+ return None
- update_package = Path.cwd() / versions[max(versions)]
+ update_package = Path.cwd() / versions[max(versions)]
- logger.info(f"开始解压: {update_package} 到 {Path.cwd()}")
+ logger.info(f"开始解压: {update_package} 到 {Path.cwd()}")
- try:
- with zipfile.ZipFile(update_package, "r") as zip_ref:
- zip_ref.extractall(Path.cwd())
- except Exception as e:
- logger.error(f"解压失败, {type(e).__name__}: {e}")
- await Config.send_websocket_message(
- id="Update",
- type="Info",
- data={"Error": f"解压失败, {type(e).__name__}: {e}"},
+ try:
+ with zipfile.ZipFile(update_package, "r") as zip_ref:
+ zip_ref.extractall(Path.cwd())
+ except (OSError, zipfile.BadZipFile, RuntimeError) as e:
+ logger.error(f"解压失败, {type(e).__name__}: {e}")
+ await Config.send_websocket_message(
+ id="Update",
+ type="Info",
+ data={"Error": f"解压失败, {type(e).__name__}: {e}"},
+ )
+ return None
+
+ logger.success(f"解压完成: {update_package} 到 {Path.cwd()}")
+
+ logger.info("正在删除临时文件与旧更新包文件")
+ if (Path.cwd() / "changes.json").exists():
+ (Path.cwd() / "changes.json").unlink()
+ for f in versions.values():
+ if (Path.cwd() / f).exists():
+ (Path.cwd() / f).unlink()
+
+ logger.info("正在清理旧版本注册表项")
+ await ProcessRunner.run_process(
+ "reg",
+ "delete",
+ r"HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{D116A92A-E174-4699-B777-61C5FD837B19}_is1",
+ "/f",
)
- self.is_locked = False
- return None
-
- logger.success(f"解压完成: {update_package} 到 {Path.cwd()}")
-
- logger.info("正在删除临时文件与旧更新包文件")
- if (Path.cwd() / "changes.json").exists():
- (Path.cwd() / "changes.json").unlink()
- for f in versions.values():
- if (Path.cwd() / f).exists():
- (Path.cwd() / f).unlink()
-
- logger.info("正在清理旧版本注册表项")
- await ProcessRunner.run_process(
- "reg",
- "delete",
- r"HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{D116A92A-E174-4699-B777-61C5FD837B19}_is1",
- "/f",
- )
- logger.success("清理完成")
-
- logger.info("启动更新程序")
- self.is_locked = False
- subprocess.Popen(
- [
- Path.cwd() / "AUTO-MAS-Setup.exe",
- "/SP-",
- "/SILENT",
- "/NOCANCEL",
- "/FORCECLOSEAPPLICATIONS",
- "/LANG=Chinese",
- f"/DIR={Path.cwd()}",
- ],
- creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
- | subprocess.DETACHED_PROCESS
- | subprocess.CREATE_NO_WINDOW,
- )
- await System.set_power("KillSelf")
+ logger.success("清理完成")
+
+ logger.info("启动更新程序")
+ subprocess.Popen(
+ [
+ Path.cwd() / "AUTO-MAS-Setup.exe",
+ "/SP-",
+ "/SILENT",
+ "/NOCANCEL",
+ "/FORCECLOSEAPPLICATIONS",
+ "/LANG=Chinese",
+ f"/DIR={Path.cwd()}",
+ ],
+ creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
+ | subprocess.DETACHED_PROCESS
+ | subprocess.CREATE_NO_WINDOW,
+ )
+ await System.set_power("KillSelf")
Updater = _UpdateHandler()
diff --git a/app/task/MAA/AutoProxy.py b/app/task/MAA/AutoProxy.py
index e906eb05..776d7897 100644
--- a/app/task/MAA/AutoProxy.py
+++ b/app/task/MAA/AutoProxy.py
@@ -26,11 +26,12 @@
import shutil
from pathlib import Path
from datetime import datetime, timedelta
+from typing import Any, cast
from app.core import Config
from app.models.task import TaskExecuteBase, ScriptItem, LogRecord
-from app.models.ConfigBase import MultipleConfig
-from app.models.config import MaaConfig, MaaUserConfig
+from app.core.config.base import MultipleConfig
+from app.models import MaaConfig, MaaUserConfig
from app.models.emulator import DeviceInfo, DeviceBase
from app.services import Notify, System
from app.tools import skland_sign_in
@@ -78,14 +79,11 @@ def __init__(
self.check_result = "-"
async def check(self) -> str:
-
if self.script_config.get(
"Run", "ProxyTimesLimit"
) != 0 and self.cur_user_config.get(
"Data", "ProxyTimes"
- ) >= self.script_config.get(
- "Run", "ProxyTimesLimit"
- ):
+ ) >= self.script_config.get("Run", "ProxyTimesLimit"):
self.cur_user_item.status = "跳过"
return "今日代理次数已达上限, 跳过该用户"
@@ -101,7 +99,6 @@ async def check(self) -> str:
return "Pass"
async def prepare(self):
-
self.maa_process_manager = ProcessManager()
self.maa_log_monitor = LogMonitor(
(1, 20),
@@ -379,7 +376,6 @@ async def set_maa(self, emulator_info: DeviceInfo):
task_set = {}
# 每个任务类型匹配第一个配置作为配置基础
for en_task, zh_task in zip(MAA_TASKS, MAA_TASKS_ZH):
-
for task_item in gui_new_set["Configurations"]["Default"]["TaskQueue"]:
if task_item.get("TaskType", "") == en_task:
task_set[en_task] = task_item
@@ -450,7 +446,7 @@ async def set_maa(self, emulator_info: DeviceInfo):
uuid.UUID(self.cur_user_config.get("Info", "StageMode"))
]
plan_data = {
- stage_key: plan.get_current_info(stage_key).getValue()
+ stage_key: plan.get_current_info(stage_key)
for stage_key in MAA_STAGE_KEY
}
@@ -545,11 +541,11 @@ async def set_maa(self, emulator_info: DeviceInfo):
# 导出任务配置
self.task_dict["StartUp"] = True
- task_queue = gui_new_set["Configurations"]["Default"]["TaskQueue"] = []
+ task_queue: list[dict[str, Any]] = []
+ gui_new_set["Configurations"]["Default"]["TaskQueue"] = task_queue
for task_type in MAA_TASKS:
-
task_set[task_type]["IsEnable"] = self.task_dict[task_type]
- task_queue.append(task_set[task_type])
+ task_queue.append(cast(dict[str, Any], task_set[task_type]))
# 剩余理智关卡配置
if (
@@ -558,7 +554,7 @@ async def set_maa(self, emulator_info: DeviceInfo):
and self.task_dict["Fight"]
and plan_data.get("Stage_Remain", "-") != "-"
):
- remain_fight = MAA_REMAIN_FIGHT_BASE.copy()
+ remain_fight = cast(dict[str, Any], MAA_REMAIN_FIGHT_BASE.copy())
remain_fight["StagePlan"] = [
(
""
@@ -589,7 +585,6 @@ async def check_log(self, log_content: list[str], latest_time: datetime) -> None
elif "任务出错: 开始唤醒" in log:
self.cur_user_log.status = "MAA 未能正确登录 PRTS"
elif "任务已全部完成!" in log:
-
for en_task, zh_task in zip(MAA_TASKS, MAA_TASKS_ZH):
if f"完成任务: {zh_task}" in log:
self.task_dict[en_task] = False
@@ -628,7 +623,6 @@ async def check_log(self, log_content: list[str], latest_time: datetime) -> None
self.wait_event.set()
async def final_task(self):
-
if self.check_result != "Pass":
return
@@ -645,10 +639,9 @@ async def final_task(self):
except Exception as e:
logger.exception(f"关闭模拟器失败: {e}")
- user_logs_list = []
+ user_logs_list: list[Path] = []
if_six_star = False
for t, log_item in self.cur_user_item.log_record.items():
-
if log_item.status == "MAA 正常运行中":
log_item.status = "任务被用户手动中止"
diff --git a/app/task/MAA/ManualReview.py b/app/task/MAA/ManualReview.py
index d73a0d79..72a8b5dc 100644
--- a/app/task/MAA/ManualReview.py
+++ b/app/task/MAA/ManualReview.py
@@ -26,11 +26,12 @@
import shutil
from pathlib import Path
from datetime import datetime, timedelta
+from typing import Any, cast
from app.core import Config, Broadcast
from app.models.task import TaskExecuteBase, ScriptItem, LogRecord
-from app.models.ConfigBase import MultipleConfig
-from app.models.config import MaaConfig, MaaUserConfig
+from app.core.config.base import MultipleConfig
+from app.models import MaaConfig, MaaUserConfig
from app.models.emulator import DeviceInfo, DeviceBase
from app.services import System
from app.utils import get_logger, LogMonitor, ProcessManager
@@ -66,7 +67,6 @@ def __init__(
self.check_result = "-"
async def check(self) -> str:
-
if (
self.cur_user_config.get("Info", "Mode") == "详细"
and not (
@@ -79,10 +79,9 @@ async def check(self) -> str:
return "Pass"
async def prepare(self):
-
self.maa_process_manager = ProcessManager()
self.maa_log_monitor = LogMonitor((1, 20), "%Y-%m-%d %H:%M:%S", self.check_log)
- self.message_queue = asyncio.Queue()
+ self.message_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
await Broadcast.subscribe(self.message_queue)
self.wait_event = asyncio.Event()
self.log_start_time = datetime.now()
@@ -122,7 +121,6 @@ async def main_task(self):
self.cur_user_item.status = "运行"
while True:
-
try:
self.script_info.log = "正在启动模拟器"
emulator_info = await self.emulator_manager.open(
@@ -130,7 +128,6 @@ async def main_task(self):
ARKNIGHTS_PACKAGE_NAME[self.cur_user_config.get("Info", "Server")],
)
except Exception as e:
-
logger.exception(
f"用户: {self.cur_user_item.user_id} - 模拟器启动失败: {e}"
)
@@ -156,8 +153,13 @@ async def main_task(self):
"options": ["是", "否"],
},
)
- result = await self._wait_for_user_response(uid)
- if not result.get("data", {}).get("choice", False):
+ result: dict[str, Any] = await self._wait_for_user_response(uid)
+ data = result.get("data")
+ choice = False
+ if isinstance(data, dict):
+ data_dict = cast(dict[str, Any], data)
+ choice = bool(data_dict.get("choice"))
+ if not choice:
break
continue
@@ -182,7 +184,6 @@ async def main_task(self):
self.run_book["SignIn"] = True
break
else:
-
logger.error(
f"用户: {self.cur_user_item.user_id} - MAA进程异常: {self.cur_user_log.status}"
)
@@ -209,12 +210,16 @@ async def main_task(self):
"options": ["是", "否"],
},
)
- result = await self._wait_for_user_response(uid)
- if not result.get("data", {}).get("choice", False):
+ result: dict[str, Any] = await self._wait_for_user_response(uid)
+ data = result.get("data")
+ choice = False
+ if isinstance(data, dict):
+ data_dict = cast(dict[str, Any], data)
+ choice = bool(data_dict.get("choice"))
+ if not choice:
break
if self.run_book["SignIn"]:
-
try:
await self.emulator_manager.setVisible(
self.script_config.get("Emulator", "Index"), True
@@ -233,15 +238,20 @@ async def main_task(self):
"options": ["是", "否"],
},
)
- result = await self._wait_for_user_response(uid)
- if result.get("data", {}).get("choice", False):
+ result: dict[str, Any] = await self._wait_for_user_response(uid)
+ data = result.get("data")
+ choice = False
+ if isinstance(data, dict):
+ data_dict = cast(dict[str, Any], data)
+ choice = bool(data_dict.get("choice"))
+ if choice:
self.run_book["PassCheck"] = True
- async def _wait_for_user_response(self, message_id: str):
+ async def _wait_for_user_response(self, message_id: str) -> dict[str, Any]:
"""等待用户交互响应"""
logger.info(f"等待客户端回应消息: {message_id}")
while True:
- message = await self.message_queue.get()
+ message: dict[str, Any] = await self.message_queue.get()
if message.get("id") == message_id and message.get("type") == "Response":
self.message_queue.task_done()
logger.success(f"收到客户端回应消息: {message_id}")
@@ -251,7 +261,7 @@ async def _wait_for_user_response(self, message_id: str):
async def set_maa(self, emulator_info: DeviceInfo):
"""配置MAA运行参数"""
- logger.info(f"开始配置MAA运行参数: 人工排查")
+ logger.info("开始配置MAA运行参数: 人工排查")
await self.maa_process_manager.kill()
await System.kill_process(self.maa_exe_path)
@@ -380,7 +390,6 @@ async def check_log(self, log_content: list[str], latest_time: datetime) -> None
self.wait_event.set()
async def final_task(self):
-
if self.check_result != "Pass":
return
diff --git a/app/task/MAA/ScriptConfig.py b/app/task/MAA/ScriptConfig.py
index 8dbd5a93..3a3832e1 100644
--- a/app/task/MAA/ScriptConfig.py
+++ b/app/task/MAA/ScriptConfig.py
@@ -26,12 +26,11 @@
from app.core import Config
from app.models.task import TaskExecuteBase, ScriptItem
-from app.models.ConfigBase import MultipleConfig
-from app.models.config import MaaConfig, MaaUserConfig
+from app.core.config.base import MultipleConfig
+from app.models import MaaConfig, MaaUserConfig
from app.models.emulator import DeviceBase
from app.services import System
from app.utils import get_logger, ProcessManager
-from app.utils.constants import MAA_TASKS
logger = get_logger("MAA 脚本设置")
@@ -58,7 +57,6 @@ def __init__(
self.cur_user_item = self.script_info.user_list[self.script_info.current_index]
async def prepare(self):
-
self.maa_process_manager = ProcessManager()
self.wait_event = asyncio.Event()
@@ -67,7 +65,6 @@ async def prepare(self):
self.maa_exe_path = self.maa_root_path / "MAA.exe"
async def main_task(self):
-
await self.prepare()
await self.set_maa()
@@ -144,7 +141,6 @@ async def set_maa(self):
logger.success(f"MAA运行参数配置完成: 设置脚本 {self.cur_user_item.user_id}")
async def final_task(self):
-
await self.maa_process_manager.kill()
await System.kill_process(self.maa_exe_path)
diff --git a/app/task/MAA/manager.py b/app/task/MAA/manager.py
index bdc0a3e1..99d1963e 100644
--- a/app/task/MAA/manager.py
+++ b/app/task/MAA/manager.py
@@ -27,8 +27,8 @@
from app.core import Config, EmulatorManager
from app.models.task import TaskExecuteBase, ScriptItem, UserItem
-from app.models.ConfigBase import MultipleConfig
-from app.models.config import MaaConfig, MaaUserConfig
+from app.core.config.base import MultipleConfig
+from app.models import MaaConfig, MaaUserConfig
from app.services import Notify
from app.utils import get_logger
from app.utils.constants import TASK_MODE_ZH
@@ -158,7 +158,6 @@ async def prepare(self):
)
async def main_task(self):
-
self.check_result = await self.check()
if self.check_result != "Pass":
logger.error(f"未通过配置检查: {self.check_result}")
@@ -189,14 +188,13 @@ async def final_task(self):
if self.check_result != "Pass":
self.script_info.status = "异常"
- return self.check_result
+ return
logger.info("MAA 主任务已结束, 开始执行后续操作")
await Config.ScriptConfig[uuid.UUID(self.script_info.script_id)].unlock()
logger.success(f"已解锁脚本配置 {self.script_info.script_id}")
if self.task_info.mode in ["AutoProxy", "ManualReview"]:
-
await self.emulator_manager.close(
self.script_config.get("Emulator", "Index")
)
@@ -250,7 +248,6 @@ async def final_task(self):
self.script_info.status = "完成"
async def on_crash(self, e: Exception):
-
self.script_info.status = "异常"
logger.exception(f"MAA任务出现异常: {e}")
await Config.send_websocket_message(
diff --git a/app/task/MAA/tools/UpdateMAA.py b/app/task/MAA/tools/UpdateMAA.py
index 36bade69..cf4b8517 100644
--- a/app/task/MAA/tools/UpdateMAA.py
+++ b/app/task/MAA/tools/UpdateMAA.py
@@ -24,7 +24,6 @@
from app.services import System
from app.utils import ProcessRunner, get_logger
-from app.utils.constants import MAA_TASKS
logger = get_logger("MAA 更新工具")
diff --git a/app/task/MAA/tools/bilibili.py b/app/task/MAA/tools/bilibili.py
index 98b18067..b56c8d97 100644
--- a/app/task/MAA/tools/bilibili.py
+++ b/app/task/MAA/tools/bilibili.py
@@ -22,13 +22,14 @@
import json
from pathlib import Path
+from typing import Any
from app.core import Config
async def agree_bilibili(maa_tasks_path: Path, if_agree: bool):
"""向MAA写入Bilibili协议相关任务"""
- data: dict = json.loads(maa_tasks_path.read_text(encoding="utf-8"))
+ data: dict[str, Any] = json.loads(maa_tasks_path.read_text(encoding="utf-8"))
if if_agree and Config.get("Function", "IfAgreeBilibili"):
data["BilibiliAgreement_AUTO"] = {
"algorithm": "OcrDetect",
diff --git a/app/task/MAA/tools/notify.py b/app/task/MAA/tools/notify.py
index d58f9cc7..21e66c11 100644
--- a/app/task/MAA/tools/notify.py
+++ b/app/task/MAA/tools/notify.py
@@ -22,13 +22,16 @@
from app.core import Config
from app.services import Notify
from app.utils import get_logger
-from app.models.config import MaaUserConfig
+from typing import Any
logger = get_logger("MAA 通知工具")
async def push_notification(
- mode: str, title: str, message: dict, user_config: MaaUserConfig | None
+ mode: str,
+ title: str,
+ message: dict[str, Any],
+ user_config: Any | None,
) -> None:
"""通过所有渠道推送通知"""
@@ -66,7 +69,7 @@ async def push_notification(
if Config.get("Notify", "IfKoishiSupport"):
await Notify.send_koishi(f"{title}\n\n{message_text}\nAUTO-MAS 敬上")
elif mode == "统计信息":
- formatted = []
+ formatted: list[str] = []
if "drop_statistics" in message:
for stage, items in message["drop_statistics"].items():
formatted.append(f"掉落统计({stage}):")
@@ -114,28 +117,29 @@ async def push_notification(
and user_config.get("Notify", "Enabled")
and user_config.get("Notify", "IfSendStatistic")
):
- if user_config.get("Notify", "IfSendMail"):
- if user_config.get("Notify", "ToAddress"):
+ user_config_any = user_config
+ if user_config_any.get("Notify", "IfSendMail"):
+ if user_config_any.get("Notify", "ToAddress"):
await Notify.send_mail(
"网页",
title,
message_html,
- user_config.get("Notify", "ToAddress"),
+ user_config_any.get("Notify", "ToAddress"),
)
else:
logger.error("用户邮箱地址为空, 无法发送用户单独的邮件通知")
- if user_config.get("Notify", "IfServerChan"):
- if user_config.get("Notify", "ServerChanKey"):
+ if user_config_any.get("Notify", "IfServerChan"):
+ if user_config_any.get("Notify", "ServerChanKey"):
await Notify.ServerChanPush(
title,
f"{serverchan_message}\nAUTO-MAS 敬上",
- user_config.get("Notify", "ServerChanKey"),
+ user_config_any.get("Notify", "ServerChanKey"),
)
else:
logger.error(
"用户ServerChan密钥为空, 无法发送用户单独的ServerChan通知"
)
- for webhook in user_config.Notify_CustomWebhooks.values():
+ for webhook in user_config_any.Notify_CustomWebhooks.values():
await Notify.WebhookPush(
title, f"{message_text}\nAUTO-MAS 敬上", webhook
)
@@ -164,26 +168,27 @@ async def push_notification(
and user_config.get("Notify", "Enabled")
and user_config.get("Notify", "IfSendSixStar")
):
- if user_config.get("Notify", "IfSendMail"):
- if user_config.get("Notify", "ToAddress"):
+ user_config_any = user_config
+ if user_config_any.get("Notify", "IfSendMail"):
+ if user_config_any.get("Notify", "ToAddress"):
await Notify.send_mail(
"网页",
title,
message_html,
- user_config.get("Notify", "ToAddress"),
+ user_config_any.get("Notify", "ToAddress"),
)
else:
logger.error("用户邮箱地址为空, 无法发送用户单独的邮件通知")
- if user_config.get("Notify", "IfServerChan"):
- if user_config.get("Notify", "ServerChanKey"):
+ if user_config_any.get("Notify", "IfServerChan"):
+ if user_config_any.get("Notify", "ServerChanKey"):
await Notify.ServerChanPush(
title,
"好羡慕~\nAUTO-MAS 敬上",
- user_config.get("Notify", "ServerChanKey"),
+ user_config_any.get("Notify", "ServerChanKey"),
)
else:
logger.error(
"用户ServerChan密钥为空, 无法发送用户单独的ServerChan通知"
)
- for webhook in user_config.Notify_CustomWebhooks.values():
+ for webhook in user_config_any.Notify_CustomWebhooks.values():
await Notify.WebhookPush(title, "好羡慕~\nAUTO-MAS 敬上", webhook)
diff --git a/app/task/MaaEnd/AutoProxy.py b/app/task/MaaEnd/AutoProxy.py
index 0709b928..e7c94729 100644
--- a/app/task/MaaEnd/AutoProxy.py
+++ b/app/task/MaaEnd/AutoProxy.py
@@ -19,25 +19,24 @@
# Contact: DLmaster_361@163.com
-import re
import uuid
import json
-import json5
-import shutil
import asyncio
from pathlib import Path
from datetime import datetime, timedelta
+from typing import Any
from app.core import Config
from app.models.task import TaskExecuteBase, ScriptItem, LogRecord
-from app.models.ConfigBase import MultipleConfig
-from app.models.config import MaaEndConfig, MaaEndUserConfig
+from app.core.config.base import MultipleConfig
+from app.models import MaaEndConfig, MaaEndUserConfig
from app.models.emulator import DeviceBase, DeviceInfo
from app.services import Notify, System
-from app.utils import get_logger, LogMonitor, ProcessManager
-from app.tools import skland_sign_in
+from app.utils import get_logger, LogMonitor, ProcessManager, skland_sign_in
from app.utils.constants import UTC4, UTC8, MAAEND_KILLPROC_TASK
from .tools import login, push_notification, wait_and_focus_window
+from .tools.parse_log import parse_log
+from .ScriptConfig import CONFIG_FILE_NAME, keep_single_instance, replace_config_dir
logger = get_logger("MaaEnd 自动代理")
@@ -68,14 +67,11 @@ def __init__(
self.check_result = "-"
async def check(self) -> str:
-
if self.script_config.get(
"Run", "ProxyTimesLimit"
) != 0 and self.cur_user_config.get(
"Data", "ProxyTimes"
- ) >= self.script_config.get(
- "Run", "ProxyTimesLimit"
- ):
+ ) >= self.script_config.get("Run", "ProxyTimesLimit"):
self.cur_user_item.status = "跳过"
return "今日代理次数已达上限, 跳过该用户"
@@ -94,7 +90,6 @@ async def check(self) -> str:
return "Pass"
async def prepare(self):
-
self.maaend_process_manager = ProcessManager()
if self.emulator_manager is None:
self.game_process_manager = ProcessManager()
@@ -108,22 +103,14 @@ async def prepare(self):
self.maaend_log_path = self.maaend_root_path / "debug/maa.log"
self.maaend_log_monitor = LogMonitor(
- (1, 23), "%Y-%m-%d %H:%M:%S.%f", self.check_log
+ (1, 23),
+ "%Y-%m-%d %H:%M:%S.%f",
+ self.check_log,
+ parse_log=lambda logs: parse_log(self.maaend_root_path, logs),
)
self.run_book = False
- def _has_stop_sequence_completed(self, log: str) -> bool:
- return any(
- marker in log
- for marker in (
- "任务完成: 停止任务",
- "任务完成: ⛔ 结束进程",
- "任务完成: __MXU_KILLPROC__",
- "任务完成: StopTask",
- )
- )
-
async def main_task(self):
"""自动代理模式主逻辑"""
@@ -149,7 +136,7 @@ async def main_task(self):
logger.info(f"开始代理用户 {self.cur_user_uid}")
self.cur_user_item.status = "运行"
- self.task_dict: dict[str, dict[str, bool]] | None = None
+ self.task_dict: dict[str, bool] | None = None
self.unique_task: dict[str, str] = {}
if (
@@ -266,9 +253,7 @@ async def main_task(self):
logger.info(f"运行脚本任务: {self.maaend_exe_path}")
self.wait_event.clear()
t = datetime.now()
- await self.maaend_process_manager.open_process(
- self.maaend_exe_path, stdout=asyncio.subprocess.PIPE
- )
+ await self.maaend_process_manager.open_process(self.maaend_exe_path)
# 静默模式隐藏 MaaEnd 窗口
if Config.get("Function", "IfSilence"):
@@ -279,12 +264,11 @@ async def main_task(self):
await asyncio.sleep(0.1)
await asyncio.sleep(1)
- if isinstance(
- self.maaend_process_manager.main_process, asyncio.subprocess.Process
- ):
- await self.maaend_log_monitor.start_monitor_process(
- self.maaend_process_manager.main_process, "stdout"
- )
+ await self.maaend_log_monitor.start_monitor_file(
+ self.maaend_log_path,
+ self.log_start_time,
+ self.maaend_log_path.with_name("maa.bak.log"),
+ )
await self.wait_event.wait()
await self.maaend_log_monitor.stop()
@@ -317,7 +301,6 @@ async def main_task(self):
async def handle_pre_maaend_error(
self, error_message: str, e: Exception | None = None
):
-
if e is None:
logger.error(f"用户: {self.cur_user_uid} - {error_message}")
await Config.send_websocket_message(
@@ -375,34 +358,37 @@ async def set_maaend(self, device_info: DeviceInfo | None) -> None:
await self.maaend_process_manager.kill()
await System.kill_process(self.maaend_exe_path)
- # 基础配置内容
- maaend_config_path = (
- Path.cwd()
- / f"data/{self.script_info.script_id}/{'Default' if self.cur_user_config.get('Info', 'Mode') == '简洁' else self.cur_user_uid}/ConfigFile"
- )
- shutil.rmtree(self.maaend_set_path, ignore_errors=True)
- shutil.copytree(maaend_config_path, self.maaend_set_path)
+ source_config_path = None
+ if self.cur_user_config.get("Info", "Mode") == "简洁":
+ source_config_path = (
+ Path.cwd() / f"data/{self.script_info.script_id}/Default/ConfigFile"
+ )
+ elif self.cur_user_config.get("Info", "Mode") == "详细":
+ source_config_path = (
+ Path.cwd()
+ / f"data/{self.script_info.script_id}/{self.cur_user_uid}/ConfigFile"
+ )
- # 初始化任务实例
- maaend_set = json.loads(
- (self.maaend_set_path / "mxu-MaaEnd.json").read_text(encoding="utf-8")
- )
+ if source_config_path is None:
+ raise RuntimeError("未找到 MaaEnd 配置目录")
- # 获取任务项单例
- for instance in maaend_set["instances"]:
- if instance["id"] == "automas":
- maaend_instance = instance
- break
- else:
- maaend_instance = {"id": "automas", "name": "AUTO-MAS", "tasks": []}
- maaend_set["instances"] = [maaend_instance]
+ source_config_file = source_config_path / CONFIG_FILE_NAME
+ if source_config_file.exists():
+ keep_single_instance(source_config_file)
+ replace_config_dir(source_config_path, self.maaend_set_path)
+
+ maaend_set, maaend_instance = keep_single_instance(
+ self.maaend_set_path / CONFIG_FILE_NAME
+ )
maaend_tasks = maaend_instance["tasks"]
- # 建立全局设置引用
- settings = maaend_set["settings"]
+ settings = maaend_set.get("settings")
+ if not isinstance(settings, dict):
+ settings = {}
+ maaend_set["settings"] = settings
# 直接运行任务
- settings["autoStartInstanceId"] = "automas"
+ settings["autoStartInstanceId"] = maaend_instance["id"]
settings["autoRunOnLaunch"] = True
# 模拟器相关配置
@@ -410,88 +396,61 @@ async def set_maaend(self, device_info: DeviceInfo | None) -> None:
"Game", "ControllerType"
)
if device_info is not None:
-
from app.core import MaaFWManager
maaend_instance["savedDevice"] = {
"adbDeviceName": (await MaaFWManager.convert_adb(device_info)).name
}
- # 加载 i18n 配置
- if settings["language"] == "system":
- settings["language"] = "zh-CN"
- maaend_i18n_raw = json.loads(
- (
- self.maaend_root_path
- / f"locales/interface/{settings['language'].lower().replace('-', '_')}.json"
- ).read_text(encoding="utf-8")
- )
- maaend_i18n = {}
- for task_definition_file in self.maaend_root_path.glob("tasks/*.json"):
- task_definition = json5.loads( # type: ignore
- task_definition_file.read_text(encoding="utf-8")
- )["task"][0]
- if task_definition["label"].startswith("$"):
- maaend_i18n[task_definition["name"]] = maaend_i18n_raw[
- task_definition["label"].lstrip("$")
- ]
- else:
- maaend_i18n[task_definition["name"]] = task_definition["label"]
-
# 配置任务启用状态
if self.task_dict is None:
# 任务列表为空则记录任务
self.task_dict = {}
- task = {}
+ task: dict[str, Any] = {}
for task in maaend_tasks:
- if (
- task["taskName"] == "__MXU_KILLPROC__"
- and task["optionValues"]["__MXU_KILLPROC_SELF_OPTION__"]["value"]
- ):
- continue
- task_name = maaend_i18n.get(task["taskName"], task["taskName"])
- if task_name not in self.task_dict:
- self.task_dict[task_name] = {}
- self.task_dict[task_name][task["id"]] = task["enabled"]
+ self.task_dict[task["id"]] = task["enabled"]
+ if task.get("taskName") == "__MXU_KILLPROC__" and task.get(
+ "optionValues", {}
+ ).get("__MXU_KILLPROC_SELF_OPTION__", {}).get("value", False):
+ self.task_dict.popitem()
else:
# 任务列表不为空则配置任务
for task in maaend_tasks:
- task_name = maaend_i18n.get(task["taskName"], task["taskName"])
- if task_name in self.task_dict:
- task["enabled"] = self.task_dict[task_name][task["id"]]
+ task["enabled"] = self.task_dict[task["id"]]
+
+ # 记录启用的无重复任务项以便简化判定
+ self.unique_task = {}
+ duplicate_task: set[str] = set()
+ for task in maaend_tasks:
+ if task["enabled"] and task["id"] in self.task_dict:
+ if task["taskName"] in self.unique_task:
+ self.unique_task.pop(task["taskName"])
+ duplicate_task.add(task["taskName"])
+ elif task["taskName"] not in duplicate_task:
+ self.unique_task[task["taskName"]] = task["id"]
# 配置协议空间
for task in maaend_tasks:
if task["taskName"] == "ProtocolSpace":
- task["optionValues"]["ProtocolSpaceTab"] = {
- "type": "select",
- "caseName": self.cur_user_config.get("Task", "ProtocolSpaceTab"),
- }
- task["optionValues"]["OperatorProgression"] = {
- "type": "select",
- "caseName": self.cur_user_config.get("Task", "OperatorProgression"),
- }
- task["optionValues"]["WeaponProgression"] = {
- "type": "select",
- "caseName": self.cur_user_config.get("Task", "WeaponProgression"),
- }
- task["optionValues"]["CrisisDrills"] = {
- "type": "select",
- "caseName": self.cur_user_config.get("Task", "CrisisDrills"),
- }
- task["optionValues"]["RewardsSetOption"] = {
- "type": "select",
- "caseName": self.cur_user_config.get("Task", "RewardsSetOption"),
- }
+ task["optionValues"]["ProtocolSpaceTab"] = self.cur_user_config.get(
+ "Task", "ProtocolSpaceTab"
+ )
+ task["optionValues"]["OperatorProgression"] = self.cur_user_config.get(
+ "Task", "OperatorProgression"
+ )
+ task["optionValues"]["WeaponProgression"] = self.cur_user_config.get(
+ "Task", "WeaponProgression"
+ )
+ task["optionValues"]["CrisisDrills"] = self.cur_user_config.get(
+ "Task", "CrisisDrills"
+ )
+ task["optionValues"]["RewardsSetOption"] = self.cur_user_config.get(
+ "Task", "RewardsSetOption"
+ )
break
# 完成任务后退出脚本
- if (
- maaend_tasks[-1]["taskName"] == "__MXU_KILLPROC__"
- and maaend_tasks[-1]["optionValues"]["__MXU_KILLPROC_SELF_OPTION__"][
- "value"
- ]
- ):
+ if maaend_tasks[-1]["taskName"] == "__MXU_KILLPROC__":
maaend_tasks[-1] = MAAEND_KILLPROC_TASK
else:
maaend_tasks.append(MAAEND_KILLPROC_TASK)
@@ -507,43 +466,23 @@ async def check_log(self, log_content: list[str], latest_time: datetime) -> None
log = "".join(log_content)
self.cur_user_log.content = log_content
self.script_info.log = log
+
if "资源加载失败" in log:
self.cur_user_log.status = "MaaEnd 资源加载失败"
- elif (not await self.maaend_process_manager.is_running()) or self._has_stop_sequence_completed(log):
- # MaaEnd may close stdout before asyncio refreshes returncode.
- # The explicit stop-task markers avoid missing the final completion pass.
-
+ elif not await self.maaend_process_manager.is_running():
if self.task_dict is None:
self.cur_user_log.status = "MaaEnd 未加载任何任务"
else:
- try:
- task_name = ""
- task_index = {
- k: {"index": 0, "list": list(v.keys())}
- for k, v in self.task_dict.items()
- }
- for log_line in self.cur_user_log.content:
- match = re.search(r"任务开始:\s*(.+)", log_line)
- task_name = match.group(1) if match else task_name
- if (
- task_name in self.task_dict
- and f"任务完成: {task_name}" in log_line
- ):
- self.task_dict[task_name][
- task_index[task_name]["list"][
- task_index[task_name]["index"]
- ]
- ] = False
- task_index[task_name]["index"] += 1
- elif f"任务失败: {task_name}" in log_line:
- task_index[task_name]["index"] += 1
-
- if any(any(_.values()) for _ in self.task_dict.values()):
- self.cur_user_log.status = "MaaEnd 部分任务执行失败"
- else:
- self.cur_user_log.status = "Success!"
- except:
- self.cur_user_log.status = "MaaEnd 任务执行情况解析失败"
+ for id in self.task_dict.keys():
+ if f"{id} - 任务完成" in log:
+ self.task_dict[id] = False
+ for task_name, task_id in self.unique_task.items():
+ if f"任务完成: {task_name}" in log:
+ self.task_dict[task_id] = False
+ if any(self.task_dict.values()):
+ self.cur_user_log.status = "MaaEnd 部分任务执行失败"
+ else:
+ self.cur_user_log.status = "Success!"
elif datetime.now() - latest_time > timedelta(
minutes=self.script_config.get("Run", "RunTimeLimit")
@@ -558,7 +497,6 @@ async def check_log(self, log_content: list[str], latest_time: datetime) -> None
self.wait_event.set()
async def final_task(self):
-
if self.check_result != "Pass":
return
@@ -577,9 +515,8 @@ async def final_task(self):
else:
await self.kill_managed_process()
- user_logs_list = []
+ user_logs_list: list[Path] = []
for t, log_item in self.cur_user_item.log_record.items():
-
dt = t.replace(tzinfo=datetime.now().astimezone().tzinfo).astimezone(UTC4)
log_path = (
Path.cwd()
diff --git a/app/task/MaaEnd/ManualReview.py b/app/task/MaaEnd/ManualReview.py
index b7bfbb22..c6f62251 100644
--- a/app/task/MaaEnd/ManualReview.py
+++ b/app/task/MaaEnd/ManualReview.py
@@ -23,11 +23,12 @@
import asyncio
from pathlib import Path
from datetime import datetime
+from typing import Any, cast
from app.core import Broadcast, Config
from app.models.task import TaskExecuteBase, ScriptItem
-from app.models.ConfigBase import MultipleConfig
-from app.models.config import MaaEndConfig, MaaEndUserConfig
+from app.core.config.base import MultipleConfig
+from app.models import MaaEndConfig, MaaEndUserConfig
from app.models.emulator import DeviceBase
from app.services import System
from app.utils import get_logger, ProcessManager
@@ -63,7 +64,6 @@ def __init__(
self.check_result = "-"
async def check(self) -> str:
-
if (
self.cur_user_config.get("Info", "Mode") == "详细"
and not (
@@ -82,10 +82,9 @@ async def check(self) -> str:
return "Pass"
async def prepare(self):
-
if self.emulator_manager is None:
self.game_process_manager = ProcessManager()
- self.message_queue = asyncio.Queue()
+ self.message_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
await Broadcast.subscribe(self.message_queue)
self.wait_event = asyncio.Event()
@@ -117,7 +116,6 @@ async def main_task(self):
self.cur_user_item.status = "运行"
while True:
-
try:
self.script_info.log = "正在启动游戏..."
if self.emulator_manager is None:
@@ -139,7 +137,6 @@ async def main_task(self):
"com.hypergryph.endfield",
)
except Exception as e:
-
logger.exception(f"用户 {self.cur_user_item.user_id} 游戏启动失败: {e}")
self.script_info.log = (
f"正在启动模拟器\n模拟器启动失败: {e}\n正在中止相关程序"
@@ -158,8 +155,13 @@ async def main_task(self):
"options": ["是", "否"],
},
)
- result = await self._wait_for_user_response(uid)
- if not result.get("data", {}).get("choice", False):
+ result: dict[str, Any] = await self._wait_for_user_response(uid)
+ data = result.get("data")
+ choice = False
+ if isinstance(data, dict):
+ data_dict = cast(dict[str, Any], data)
+ choice = bool(data_dict.get("choice"))
+ if not choice:
break
continue
@@ -193,12 +195,16 @@ async def main_task(self):
"options": ["是", "否"],
},
)
- result = await self._wait_for_user_response(uid)
- if not result.get("data", {}).get("choice", False):
+ result: dict[str, Any] = await self._wait_for_user_response(uid)
+ data = result.get("data")
+ choice = False
+ if isinstance(data, dict):
+ data_dict = cast(dict[str, Any], data)
+ choice = bool(data_dict.get("choice"))
+ if not choice:
break
if self.run_book["SignIn"]:
-
try:
if self.emulator_manager is not None:
await self.emulator_manager.setVisible(
@@ -218,15 +224,20 @@ async def main_task(self):
"options": ["是", "否"],
},
)
- result = await self._wait_for_user_response(uid)
- if result.get("data", {}).get("choice", False):
+ result: dict[str, Any] = await self._wait_for_user_response(uid)
+ data = result.get("data")
+ choice = False
+ if isinstance(data, dict):
+ data_dict = cast(dict[str, Any], data)
+ choice = bool(data_dict.get("choice"))
+ if choice:
self.run_book["PassCheck"] = True
- async def _wait_for_user_response(self, message_id: str):
+ async def _wait_for_user_response(self, message_id: str) -> dict[str, Any]:
"""等待用户交互响应"""
logger.info(f"等待客户端回应消息: {message_id}")
while True:
- message = await self.message_queue.get()
+ message: dict[str, Any] = await self.message_queue.get()
if message.get("id") == message_id and message.get("type") == "Response":
self.message_queue.task_done()
logger.success(f"收到客户端回应消息: {message_id}")
@@ -249,7 +260,6 @@ async def kill_managed_process(self) -> None:
logger.exception(f"关闭游戏失败: {e}")
async def final_task(self):
-
if self.check_result != "Pass":
return
diff --git a/app/task/MaaEnd/ScriptConfig.py b/app/task/MaaEnd/ScriptConfig.py
index de660c27..a7b100a0 100644
--- a/app/task/MaaEnd/ScriptConfig.py
+++ b/app/task/MaaEnd/ScriptConfig.py
@@ -23,16 +23,88 @@
import shutil
import asyncio
from pathlib import Path
+from typing import Any
from app.core import Config
from app.models.task import TaskExecuteBase, ScriptItem
-from app.models.ConfigBase import MultipleConfig
-from app.models.config import MaaEndConfig, MaaEndUserConfig
+from app.core.config.base import MultipleConfig
+from app.models import MaaEndConfig, MaaEndUserConfig
from app.models.emulator import DeviceBase
from app.services import System
from app.utils import get_logger, ProcessManager
logger = get_logger("MaaEnd 脚本设置")
+CONFIG_FILE_NAME = "mxu-MaaEnd.json"
+
+
+def _load_config(config_path: Path) -> dict[str, Any]:
+ return json.loads(config_path.read_text(encoding="utf-8"))
+
+
+def _dump_config(config_path: Path, data: dict[str, Any]) -> None:
+ config_path.write_text(
+ json.dumps(data, ensure_ascii=False, indent=4), encoding="utf-8"
+ )
+
+
+def _keep_single_instance(config_path: Path) -> tuple[dict[str, Any], dict[str, Any]]:
+ data = _load_config(config_path)
+
+ instances = data.get("instances", [])
+
+ selected_instance: dict[str, Any] | None = None
+
+ for instance in instances:
+ if instance.get("name") == "AUTO-MAS":
+ selected_instance = dict(instance)
+ break
+
+ if selected_instance is None:
+ active_id = str(data.get("lastActiveInstanceId", "")).strip()
+ if active_id:
+ for instance in instances:
+ if str(instance.get("id", "")).strip() == active_id:
+ selected_instance = dict(instance)
+ break
+
+ if selected_instance is None:
+ for instance in instances:
+ selected_instance = dict(instance)
+ break
+
+ if selected_instance is None:
+ selected_instance = {"id": "", "name": "AUTO-MAS", "tasks": []}
+
+ if "tasks" not in selected_instance:
+ selected_instance["tasks"] = []
+ if not str(selected_instance.get("id", "")).strip():
+ selected_instance["id"] = "AUTO-MAS"
+
+ selected_instance["name"] = "AUTO-MAS"
+ data["instances"] = [selected_instance]
+ data["lastActiveInstanceId"] = selected_instance["id"]
+
+ data.setdefault("settings", {})
+
+ _dump_config(config_path, data)
+ return data, selected_instance
+
+
+def keep_single_instance(config_path: Path) -> tuple[dict[str, Any], dict[str, Any]]:
+ """公开封装:保留单实例配置。"""
+
+ return _keep_single_instance(config_path)
+
+
+def _replace_config_dir(source_dir: Path, target_dir: Path) -> None:
+ shutil.rmtree(target_dir, ignore_errors=True)
+ shutil.copytree(source_dir, target_dir)
+
+
+def replace_config_dir(source_dir: Path, target_dir: Path) -> None:
+ """公开封装:替换配置目录。"""
+
+ _replace_config_dir(source_dir, target_dir)
class ScriptConfigTask(TaskExecuteBase):
@@ -57,7 +129,6 @@ def __init__(
self.cur_user_item = self.script_info.user_list[self.script_info.current_index]
async def prepare(self):
-
self.maaend_process_manager = ProcessManager()
self.wait_event = asyncio.Event()
@@ -70,7 +141,6 @@ async def prepare(self):
)
async def main_task(self):
-
await self.prepare()
await self.set_maaend()
@@ -88,43 +158,17 @@ async def set_maaend(self):
await System.kill_process(self.maaend_exe_path)
if self.config_file_path.exists():
- shutil.copytree(
- self.config_file_path, self.maaend_set_path, dirs_exist_ok=True
- )
+ config_path = self.config_file_path / CONFIG_FILE_NAME
+ if config_path.exists():
+ _keep_single_instance(config_path)
+ _replace_config_dir(self.config_file_path, self.maaend_set_path)
- # 初始化任务实例
- maaend_set = json.loads(
- (self.maaend_set_path / "mxu-MaaEnd.json").read_text(encoding="utf-8")
+ maaend_set, maaend_instance = _keep_single_instance(
+ self.maaend_set_path / CONFIG_FILE_NAME
)
- maaend_instances = maaend_set["instances"]
-
- # 创建任务项单例
- selected_instance = None
- for instance in maaend_instances:
- if instance["id"] == "automas":
- selected_instance = instance
- break
- else:
- for instance in maaend_instances:
- if instance["id"] == maaend_set["lastActiveInstanceId"]:
- selected_instance = instance
- break
- if selected_instance is None:
- selected_instance = (
- maaend_instances[0]
- if len(maaend_instances) > 0
- else {"id": "automas", "name": "AUTO-MAS", "tasks": []}
- )
-
- if "tasks" not in selected_instance:
- selected_instance["tasks"] = []
- selected_instance["id"] = "automas"
- selected_instance["name"] = "AUTO-MAS"
- maaend_set["instances"] = [selected_instance]
- maaend_set["lastActiveInstanceId"] = "automas"
# 不直接运行任务
- maaend_set["settings"]["autoStartInstanceId"] = "automas"
+ maaend_set["settings"]["autoStartInstanceId"] = maaend_instance["id"]
maaend_set["settings"]["autoRunOnLaunch"] = False
(self.maaend_set_path / "mxu-MaaEnd.json").write_text(
@@ -135,13 +179,12 @@ async def set_maaend(self):
)
async def final_task(self):
-
await self.maaend_process_manager.kill()
await System.kill_process(self.maaend_exe_path)
+ _keep_single_instance(self.maaend_set_path / CONFIG_FILE_NAME)
shutil.rmtree(self.config_file_path, ignore_errors=True)
- self.config_file_path.mkdir(parents=True, exist_ok=True)
- shutil.copytree(self.maaend_set_path, self.config_file_path, dirs_exist_ok=True)
+ shutil.copytree(self.maaend_set_path, self.config_file_path)
async def on_crash(self, e: Exception):
self.cur_user_item.status = "异常"
diff --git a/app/task/MaaEnd/manager.py b/app/task/MaaEnd/manager.py
index 50d21994..4492d074 100644
--- a/app/task/MaaEnd/manager.py
+++ b/app/task/MaaEnd/manager.py
@@ -25,8 +25,8 @@
from pathlib import Path
from app.core import Config, EmulatorManager
-from app.models.ConfigBase import MultipleConfig
-from app.models.config import MaaEndConfig, MaaEndUserConfig
+from app.core.config.base import MultipleConfig
+from app.models import MaaEndConfig, MaaEndUserConfig
from app.models.task import ScriptItem, TaskExecuteBase, UserItem
from app.services import Notify
from app.utils import get_logger
@@ -101,7 +101,6 @@ async def check(self) -> str:
return "Pass"
async def prepare(self):
-
# 锁定脚本配置并加载用户配置
await Config.ScriptConfig[uuid.UUID(self.script_info.script_id)].lock()
self.script_config = Config.ScriptConfig[uuid.UUID(self.script_info.script_id)]
@@ -147,7 +146,6 @@ async def prepare(self):
)
async def main_task(self):
-
self.check_result = await self.check()
if self.check_result != "Pass":
logger.error(f"未通过配置检查: {self.check_result}")
@@ -174,7 +172,6 @@ async def main_task(self):
await self.spawn(task)
async def final_task(self):
-
if self.check_result != "Pass":
self.script_info.status = "异常"
return
@@ -184,7 +181,6 @@ async def final_task(self):
logger.success(f"已解锁脚本配置 {self.script_info.script_id}")
if self.task_info.mode in ["AutoProxy", "ManualReview"]:
-
if self.emulator_manager is not None:
await self.emulator_manager.close(
self.script_config.get("Game", "EmulatorIndex")
diff --git a/app/task/MaaEnd/tools/notify.py b/app/task/MaaEnd/tools/notify.py
index 12f8de9d..be1a0ee4 100644
--- a/app/task/MaaEnd/tools/notify.py
+++ b/app/task/MaaEnd/tools/notify.py
@@ -18,9 +18,9 @@
# Contact: DLmaster_361@163.com
+from typing import Any
from app.core import Config
-from app.models.config import MaaEndUserConfig
from app.services import Notify
from app.utils import get_logger
@@ -28,10 +28,12 @@
async def push_notification(
- mode: str, title: str, message: dict, user_config: MaaEndUserConfig | None
+ mode: str,
+ title: str,
+ message: dict[str, Any],
+ user_config: Any | None,
) -> None:
"""通过所有渠道推送通知。"""
-
logger.info(f"开始推送通知, 模式: {mode}, 标题: {title}")
if mode == "代理结果" and (
@@ -46,8 +48,9 @@ async def push_notification(
f"已完成数: {message['completed_count']}, 未完成数: {message['uncompleted_count']}\n\n"
f"{message['result']}"
)
- template = Config.notify_env.get_template("general_result.html")
- message_html = template.render(message)
+ notify_env = Config.notify_env
+ template = notify_env.get_template("general_result.html")
+ message_html = str(template.render(message))
serverchan_message = message_text.replace("\n", "\n\n")
if Config.get("Notify", "IfSendMail"):
@@ -75,8 +78,9 @@ async def push_notification(
f"MaaEnd执行结果: {message['user_result']}\n\n"
)
- template = Config.notify_env.get_template("general_statistics.html")
- message_html = template.render(message)
+ notify_env = Config.notify_env
+ template = notify_env.get_template("general_statistics.html")
+ message_html = str(template.render(message))
serverchan_message = message_text.replace("\n", "\n\n")
if Config.get("Notify", "IfSendStatistic"):
@@ -105,28 +109,31 @@ async def push_notification(
and user_config.get("Notify", "Enabled")
and user_config.get("Notify", "IfSendStatistic")
):
- if user_config.get("Notify", "IfSendMail"):
- if user_config.get("Notify", "ToAddress"):
+ user_config_any = user_config
+ if user_config_any.get("Notify", "IfSendMail"):
+ if user_config_any.get("Notify", "ToAddress"):
await Notify.send_mail(
"网页",
title,
message_html,
- user_config.get("Notify", "ToAddress"),
+ user_config_any.get("Notify", "ToAddress"),
)
else:
logger.error("用户邮箱地址为空, 无法发送用户单独的邮件通知")
- if user_config.get("Notify", "IfServerChan"):
- if user_config.get("Notify", "ServerChanKey"):
+ if user_config_any.get("Notify", "IfServerChan"):
+ if user_config_any.get("Notify", "ServerChanKey"):
await Notify.ServerChanPush(
title,
f"{serverchan_message}\n\nAUTO-MAS 敬上",
- user_config.get("Notify", "ServerChanKey"),
+ user_config_any.get("Notify", "ServerChanKey"),
)
else:
- logger.error("用户ServerChan密钥为空, 无法发送用户单独的ServerChan通知")
+ logger.error(
+ "用户ServerChan密钥为空, 无法发送用户单独的ServerChan通知"
+ )
- for webhook in user_config.Notify_CustomWebhooks.values():
+ for webhook in user_config_any.Notify_CustomWebhooks.values():
await Notify.WebhookPush(
title, f"{message_text}\n\nAUTO-MAS 敬上", webhook
)
diff --git a/app/task/MaaEnd/tools/parse_log.py b/app/task/MaaEnd/tools/parse_log.py
new file mode 100644
index 00000000..5f8a82f7
--- /dev/null
+++ b/app/task/MaaEnd/tools/parse_log.py
@@ -0,0 +1,1467 @@
+import json
+import re
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import Any, cast
+
+
+TASK_EVENT_TEXT = {
+ "Tasker.Task.Starting": "任务开始",
+ "Tasker.Task.Succeeded": "任务完成",
+ "Tasker.Task.Failed": "任务失败",
+}
+
+RESOURCE_EVENT_TEXT = {
+ "Resource.Loading.Starting": "正在加载资源",
+ "Resource.Loading.Succeeded": "资源加载成功",
+ "Resource.Loading.Failed": "资源加载失败",
+}
+
+CONTROLLER_EVENT_TEXT = {
+ "Controller.Action.Starting": "正在连接窗口",
+ "Controller.Action.Succeeded": "窗口连接成功",
+ "Controller.Action.Failed": "窗口连接失败",
+}
+
+FULL_TS_RE = re.compile(r"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\]")
+ISO_TS_RE = re.compile(
+ r"^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:[.,]\d+)?(?:Z|[+-]\d{2}:\d{2})"
+)
+PANEL_TS_RE = re.compile(r"^\[(\d{2}:\d{2}:\d{2})\]\s*$")
+NEXT_FULL_TS_RE = re.compile(r"^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\]")
+NEXT_PANEL_TS_RE = re.compile(r"^\[\d{2}:\d{2}:\d{2}\]\s*$")
+AGENT_LOG_RE = re.compile(
+ r"^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:[.,]\d+)?(?:Z|[+-]\d{2}:\d{2})\s+([A-Z]+)\s+(.+)$"
+)
+THREAD_ID_RE = re.compile(r"\[Tx(\d+)\]")
+SIMPLE_JSON_RE = re.compile(
+ r"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\]\s+(\{.+\})\s*$"
+)
+EVENT_RE = re.compile(
+ r"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\]"
+ r"\[[A-Z]+\]"
+ r"\[Px\d+\]"
+ r"\[Tx\d+\]"
+ r"\[Utils/EventDispatcher\.hpp\]"
+ r"\[L\d+\]"
+ r"\[[^\]]+\]\s*(?:!!!OnEventNotify!!!\s*)?"
+ r"\[handle=[^\]]+\]\s+\[msg=([^\]]+)\]\s+\[details=(.+)\]\s*$"
+)
+WRAPPED_AGENT_RE = re.compile(
+ r'^\s*\{.*?"instance_id"\s*:\s*"([^"]+)".*?"line"\s*:\s*"((?:\\.|[^"])*)".*\}\s*$'
+)
+INLINE_INSTANCE_RE = re.compile(
+ r'(?:"instance_id"\s*:\s*"|instance_id\s*[:=]\s*)([A-Za-z0-9._-]+)'
+)
+TAURI_TS_RE = re.compile(r"^\[(\d{4}-\d{2}-\d{2})\]\[(\d{2}:\d{2}:\d{2})\]")
+TAURI_INSTANCE_RE = re.compile(
+ r"(?:instance_id|instance)\s*:\s*([A-Za-z0-9._-]+)|Instance found:\s*([A-Za-z0-9._-]+)"
+)
+TAURI_START_TASKS_RE = re.compile(r"\bmaa_start_tasks called\b")
+TAURI_TASK_ENTRY_RE = re.compile(r'TaskConfig\s*\{\s*entry:\s*"([^"]+)"')
+TAURI_POST_TASK_ID_RE = re.compile(r"post_task returned task_id:\s*([A-Za-z0-9._-]+)")
+TEXT_TASK_ID_RE = re.compile(r"\btask_id\b\s*[:=]\s*\"?([A-Za-z0-9._-]+)\"?")
+TEXT_TASK_ID_ALT_RE = re.compile(r"\btask_id_\b\s*[:=]\s*\"?([A-Za-z0-9._-]+)\"?")
+
+ENTRY_TASK_NAME_ALIASES: dict[str, str] = {
+ "AndroidOpenGame": "AndroidOpenGame",
+ "AutoCollectStart": "AutoCollect",
+ "AutoEcoFarmMain": "AutoEcoFarm",
+ "AutoEssenceMain": "AutoEssence",
+ "AutoStockpileMain": "AutoStockpile",
+ "BakerEntry": "BakerEntry",
+ "BatchAddFriendsMain": "BatchAddFriends",
+ "VisitFriendsMain": "VisitFriends",
+ "CreditShoppingMain": "CreditShoppingN2",
+ "DeliveryJobsMain": "DeliveryJobs",
+ "CraftingStart": "Crafting",
+ "DijiangRewards": "DijiangRewards",
+ "EnvironmentMonitoringMain": "EnvironmentMonitoring",
+ "EssenceFilterMain": "EssenceFilter",
+ "GearAssemblyStart": "GearAssembly",
+ "GiftOperatorMain": "GiftOperator",
+ "ImportBluePrints": "ImportBluePrints",
+ "ItemTransfer": "ItemTransfer",
+ "RealTimeTaskMain": "RealTimeTask",
+ "SellProductMain": "SellProduct",
+ "SimpleProductionBatchStart": "SimpleProductionBatchStart",
+ "DailyRewardStart": "DailyRewards",
+ "SeizeEntrustTaskMain": "SeizeEntrustTask",
+ "ProtocolSpaceEntry": "ProtocolSpace",
+ "WeaponUpgradeStart": "WeaponUpgrade",
+ "ResellMain": "AutoResell",
+ "AutoUseSpMedicationEntry": "AutoUseSpMedication",
+ "SimSpaceEntry": "ClaimSimulationRewards",
+ "WikiReadAll": "ReadAllWiki",
+ "ProdManualStart": "ReceiveProdManual",
+ "MXU_KILLPROC": "__MXU_KILLPROC__",
+}
+
+
+@dataclass(frozen=True)
+class SnapshotTask:
+ """配置快照中的单个前端任务。"""
+
+ id: str
+ task_name: str
+ enabled: bool
+
+
+@dataclass(frozen=True)
+class Snapshot:
+ """一份可用于还原 selected_task_id 的任务快照。"""
+
+ instance_id: str
+ source: str
+ closed_at: int | None
+ tasks: tuple[SnapshotTask, ...]
+
+
+@dataclass(frozen=True)
+class BatchTask:
+ """MXU 一次 start_tasks 批次中的单个运行时任务。"""
+
+ task_index: int
+ entry: str
+ task_id: str | None
+ posted_at: datetime | None
+
+
+@dataclass(frozen=True)
+class Batch:
+ """来自 mxu-tauri.log 的一次权威任务批次。"""
+
+ batch_id: int
+ instance_id: str
+ started_at: datetime | None
+ tasks: tuple[BatchTask, ...]
+
+
+@dataclass(frozen=True)
+class AuxiliaryData:
+ """固定文件解析得到的辅助上下文。"""
+
+ snapshots: tuple[Snapshot, ...]
+ batches: tuple[Batch, ...]
+ task_id_to_entry: dict[str, str]
+
+
+@dataclass(frozen=True)
+class RunContext:
+ """当前这一轮输入日志中能观察到的运行信息。"""
+
+ first_timestamp: datetime | None
+ last_timestamp: datetime | None
+ task_ids: frozenset[str]
+ entries: frozenset[str]
+
+
+@dataclass(frozen=True)
+class RuntimeTaskStart:
+ """运行时顶层 Tasker.Task.Starting 事件。"""
+
+ timestamp: datetime
+ entry: str
+ task_id: str
+
+
+_aux_cache_key: tuple[tuple[str, int, int], ...] | None = None
+_aux_cache_value: AuxiliaryData | None = None
+
+
+def _safe_load_json_dict(text: str) -> dict[str, Any] | None:
+ """安全解析 JSON,仅当结果是对象时返回。"""
+
+ try:
+ data = json.loads(text)
+ except json.JSONDecodeError:
+ return None
+ return cast(dict[str, Any], data) if isinstance(data, dict) else None
+
+
+def _read_lines(path: Path) -> list[str]:
+ """按 UTF-8 读取文本行,失败时返回空列表。"""
+
+ try:
+ return path.read_text(encoding="utf-8").splitlines()
+ except Exception:
+ return []
+
+
+def _decode_wrapped_line(match: re.Match[str]) -> str:
+ """还原被 JSON 包裹的一层 agent 日志。"""
+
+ try:
+ return json.loads('"' + match.group(2) + '"')
+ except json.JSONDecodeError:
+ return (
+ match.group(2)
+ .encode("utf-8", errors="replace")
+ .decode("unicode_escape", errors="replace")
+ )
+
+
+def _extract_task_id(details: dict[str, Any], raw_text: str) -> str:
+ """优先从结构化字段提取 task_id,失败后回退到正则。"""
+
+ task_id_value = details.get("task_id")
+ if isinstance(task_id_value, (int, str)):
+ return str(task_id_value).strip()
+
+ task_id_alt_value = details.get("task_id_")
+ if isinstance(task_id_alt_value, (int, str)):
+ return str(task_id_alt_value).strip()
+
+ task_id_match = TEXT_TASK_ID_RE.search(raw_text)
+ if task_id_match:
+ return task_id_match.group(1).strip()
+
+ task_id_alt_match = TEXT_TASK_ID_ALT_RE.search(raw_text)
+ if task_id_alt_match:
+ return task_id_alt_match.group(1).strip()
+
+ return ""
+
+
+def _extract_entry(details: dict[str, Any], raw_text: str) -> str:
+ """提取运行时 entry 名称。"""
+
+ entry_value = details.get("entry")
+ if isinstance(entry_value, str):
+ return entry_value.strip()
+
+ entry_match = re.search(r'\bentry\b\s*[:=]\s*"([A-Za-z0-9._-]+)"', raw_text)
+ if entry_match:
+ return entry_match.group(1).strip()
+
+ return ""
+
+
+def _parse_tauri_timestamp(line: str) -> datetime | None:
+ """解析 mxu-tauri.log 的时间戳。"""
+
+ match = TAURI_TS_RE.match(line)
+ if match is None:
+ return None
+
+ try:
+ return datetime.strptime(
+ f"{match.group(1)} {match.group(2)}.000", "%Y-%m-%d %H:%M:%S.%f"
+ )
+ except ValueError:
+ return None
+
+
+def _extract_timestamp(line: str) -> datetime | None:
+ """从任意支持的日志格式中抽取完整时间。"""
+
+ full_match = FULL_TS_RE.match(line)
+ if full_match is not None:
+ try:
+ return datetime.strptime(full_match.group(1), "%Y-%m-%d %H:%M:%S.%f")
+ except ValueError:
+ return None
+
+ iso_match = ISO_TS_RE.match(line)
+ if iso_match is not None:
+ try:
+ return datetime.strptime(
+ f"{iso_match.group(1)} {iso_match.group(2)}.000",
+ "%Y-%m-%d %H:%M:%S.%f",
+ )
+ except ValueError:
+ return None
+
+ simple_json_match = SIMPLE_JSON_RE.match(line)
+ if simple_json_match is not None:
+ try:
+ return datetime.strptime(simple_json_match.group(1), "%Y-%m-%d %H:%M:%S.%f")
+ except ValueError:
+ return None
+
+ if line.startswith("{") and '"time"' in line:
+ data = _safe_load_json_dict(line)
+ if data is not None and isinstance(data.get("time"), str):
+ return _extract_timestamp(data["time"])
+
+ return None
+
+
+def _emit_log_line(
+ result: list[str],
+ timestamp: str,
+ log_prefix: str,
+ message: str,
+ last_emit_key: str,
+ last_emit_time: datetime | None,
+) -> tuple[str, datetime | None]:
+ """写入去重后的日志行,避免极短时间内的重复刷屏。"""
+
+ emit_key = f"{log_prefix}|{message}"
+ emit_time = datetime.strptime(timestamp, "%Y-%m-%d %H:%M:%S.%f")
+ if (
+ emit_key == last_emit_key
+ and last_emit_time is not None
+ and abs((emit_time - last_emit_time).total_seconds()) <= 0.02
+ ):
+ return last_emit_key, last_emit_time
+
+ result.append(f"[{timestamp}] {log_prefix} - {message}\n")
+ return emit_key, emit_time
+
+
+def _normalize_name(name: str) -> str:
+ """统一名字比较口径,便于 entry 和 taskName 做模糊比对。"""
+
+ return "".join(ch.lower() for ch in name if ch.isalnum())
+
+
+def _entry_candidates(entry: str) -> tuple[str, ...]:
+ """把运行时 entry 扩展成一组可能的前端 taskName。"""
+
+ candidates: list[str] = [entry]
+
+ alias = ENTRY_TASK_NAME_ALIASES.get(entry)
+ if alias is not None:
+ candidates.append(alias)
+
+ for suffix in ("Main", "Start", "Sub"):
+ if entry.endswith(suffix) and len(entry) > len(suffix):
+ candidates.append(entry[: -len(suffix)])
+
+ if entry.endswith("Rewards") and len(entry) > len("Rewards"):
+ candidates.append(entry[: -len("Rewards")] + "Reward")
+ if entry.endswith("Reward") and len(entry) > len("Reward"):
+ candidates.append(entry[: -len("Reward")] + "Rewards")
+
+ unique_candidates: list[str] = []
+ seen: set[str] = set()
+ for candidate in candidates:
+ if candidate and candidate not in seen:
+ unique_candidates.append(candidate)
+ seen.add(candidate)
+ return tuple(unique_candidates)
+
+
+def _task_name_matches(task_name: str, entry: str) -> bool:
+ """判断前端 taskName 和运行时 entry 是否可认为是同一个任务。"""
+
+ normalized_task_name = _normalize_name(task_name)
+ return any(
+ normalized_task_name == _normalize_name(candidate)
+ for candidate in _entry_candidates(entry)
+ )
+
+
+def _build_snapshot_tasks(raw_tasks: Any) -> tuple[SnapshotTask, ...]:
+ """从配置文件中抽取快照任务列表。"""
+
+ if not isinstance(raw_tasks, list):
+ return ()
+
+ tasks: list[SnapshotTask] = []
+ raw_tasks_list = cast(list[Any], raw_tasks)
+ for raw_task in raw_tasks_list:
+ if not isinstance(raw_task, dict):
+ continue
+ raw_task_dict = cast(dict[str, Any], raw_task)
+ task_id = raw_task_dict.get("id")
+ task_name = raw_task_dict.get("taskName")
+ enabled = raw_task_dict.get("enabled")
+ if isinstance(task_id, str) and isinstance(task_name, str):
+ tasks.append(
+ SnapshotTask(
+ id=task_id,
+ task_name=task_name,
+ enabled=bool(enabled),
+ )
+ )
+ return tuple(tasks)
+
+
+def _load_snapshots(config_data: dict[str, Any]) -> tuple[Snapshot, ...]:
+ """读取当前实例和 recentlyClosed 中的任务快照。"""
+
+ snapshots: list[Snapshot] = []
+
+ raw_instances = config_data.get("instances")
+ if isinstance(raw_instances, list):
+ raw_instances_list = cast(list[Any], raw_instances)
+ for raw_instance in raw_instances_list:
+ if not isinstance(raw_instance, dict):
+ continue
+ raw_instance_dict = cast(dict[str, Any], raw_instance)
+ instance_id = raw_instance_dict.get("id")
+ if not isinstance(instance_id, str):
+ continue
+ snapshots.append(
+ Snapshot(
+ instance_id=instance_id,
+ source="instance",
+ closed_at=None,
+ tasks=_build_snapshot_tasks(raw_instance_dict.get("tasks")),
+ )
+ )
+
+ raw_recently_closed = config_data.get("recentlyClosed")
+ if isinstance(raw_recently_closed, list):
+ raw_recently_closed_list = cast(list[Any], raw_recently_closed)
+ for raw_snapshot in raw_recently_closed_list:
+ if not isinstance(raw_snapshot, dict):
+ continue
+ raw_snapshot_dict = cast(dict[str, Any], raw_snapshot)
+ instance_id = raw_snapshot_dict.get("id")
+ closed_at = raw_snapshot_dict.get("closedAt")
+ if not isinstance(instance_id, str):
+ continue
+ snapshots.append(
+ Snapshot(
+ instance_id=instance_id,
+ source="recently_closed",
+ closed_at=closed_at if isinstance(closed_at, int) else None,
+ tasks=_build_snapshot_tasks(raw_snapshot_dict.get("tasks")),
+ )
+ )
+
+ return tuple(snapshots)
+
+
+def _parse_tauri_batches(lines: list[str]) -> tuple[Batch, ...]:
+ """从 mxu-tauri.log 构造权威批次列表。"""
+
+ batches: list[Batch] = []
+ current_instance_id = ""
+ current_batch_id = 0
+ open_started_at: datetime | None = None
+ open_instance_id = ""
+ open_entries: list[str] = []
+ open_task_ids: list[str] = []
+ open_posted_ats: list[datetime | None] = []
+
+ def flush_batch() -> None:
+ if not open_entries and not open_task_ids:
+ return
+
+ tasks: list[BatchTask] = []
+ max_length = max(len(open_entries), len(open_task_ids))
+ for index in range(max_length):
+ entry = open_entries[index] if index < len(open_entries) else ""
+ task_id = open_task_ids[index] if index < len(open_task_ids) else None
+ posted_at = open_posted_ats[index] if index < len(open_posted_ats) else None
+ tasks.append(
+ BatchTask(
+ task_index=index,
+ entry=entry,
+ task_id=task_id,
+ posted_at=posted_at,
+ )
+ )
+
+ batches.append(
+ Batch(
+ batch_id=current_batch_id,
+ instance_id=open_instance_id or current_instance_id,
+ started_at=open_started_at,
+ tasks=tuple(tasks),
+ )
+ )
+
+ for line in lines:
+ line_timestamp = _parse_tauri_timestamp(line)
+
+ instance_match = TAURI_INSTANCE_RE.search(line)
+ if instance_match is not None:
+ current_instance_id = (
+ instance_match.group(1) or instance_match.group(2) or ""
+ ).strip()
+ if not open_instance_id:
+ open_instance_id = current_instance_id
+
+ if TAURI_START_TASKS_RE.search(line):
+ flush_batch()
+ current_batch_id += 1
+ open_started_at = line_timestamp
+ open_instance_id = current_instance_id
+ open_entries = []
+ open_task_ids = []
+ open_posted_ats = []
+ continue
+
+ if current_batch_id == 0:
+ if "tasks:" not in line:
+ continue
+
+ if "tasks:" in line:
+ if current_batch_id == 0:
+ current_batch_id += 1
+ open_started_at = line_timestamp
+ open_instance_id = current_instance_id
+ open_entries = []
+ open_task_ids = []
+ open_posted_ats = []
+ open_entries = TAURI_TASK_ENTRY_RE.findall(line)
+ if not open_instance_id:
+ open_instance_id = current_instance_id
+ continue
+
+ post_task_match = TAURI_POST_TASK_ID_RE.search(line)
+ if post_task_match is not None:
+ open_task_ids.append(post_task_match.group(1).strip())
+ open_posted_ats.append(line_timestamp)
+
+ flush_batch()
+ return tuple(batches)
+
+
+def _load_go_service_mapping(lines: list[str]) -> dict[str, str]:
+ """从 go-service.log 中补充 task_id -> entry 显式映射。"""
+
+ mapping: dict[str, str] = {}
+ for line in lines:
+ data = _safe_load_json_dict(line)
+ if data is None:
+ continue
+ task_id_value = data.get("task_id")
+ entry_value = data.get("entry")
+ if isinstance(task_id_value, (int, str)) and isinstance(entry_value, str):
+ mapping[str(task_id_value).strip()] = entry_value.strip()
+ return mapping
+
+
+def _build_auxiliary_data(root_dir: Path) -> AuxiliaryData:
+ """读取固定文件,并缓存批次/快照等不会频繁变化的上下文。"""
+
+ global _aux_cache_key, _aux_cache_value
+
+ config_path = root_dir / "config" / "mxu-MaaEnd.json"
+ tauri_path = root_dir / "debug" / "mxu-tauri.log"
+ go_service_path = root_dir / "debug" / "go-service.log"
+
+ cache_key_parts: list[tuple[str, int, int]] = []
+ for path in (config_path, tauri_path, go_service_path):
+ if path.is_file():
+ stat = path.stat()
+ cache_key_parts.append(
+ (str(path.resolve()), stat.st_mtime_ns, stat.st_size)
+ )
+ else:
+ cache_key_parts.append((str(path.resolve()), -1, -1))
+ cache_key = tuple(cache_key_parts)
+
+ if _aux_cache_key == cache_key and _aux_cache_value is not None:
+ return _aux_cache_value
+
+ config_data: dict[str, Any] | None = None
+ if config_path.is_file():
+ try:
+ config_data = _safe_load_json_dict(config_path.read_text(encoding="utf-8"))
+ except Exception:
+ config_data = None
+ if config_data is None:
+ config_data = {}
+
+ auxiliary_data = AuxiliaryData(
+ snapshots=_load_snapshots(config_data),
+ batches=_parse_tauri_batches(_read_lines(tauri_path)),
+ task_id_to_entry=_load_go_service_mapping(_read_lines(go_service_path)),
+ )
+
+ _aux_cache_key = cache_key
+ _aux_cache_value = auxiliary_data
+ return auxiliary_data
+
+
+def _collect_run_context(lines: list[str]) -> RunContext:
+ """从本次输入日志中收集时间范围、task_id 和 entry。"""
+
+ first_timestamp: datetime | None = None
+ last_timestamp: datetime | None = None
+ task_ids: set[str] = set()
+ entries: set[str] = set()
+
+ for raw_line in lines:
+ line = raw_line.rstrip("\r\n")
+ _extract_thread_id(line)
+
+ wrapped_agent_match = WRAPPED_AGENT_RE.match(line)
+ if wrapped_agent_match is not None:
+ line = _decode_wrapped_line(wrapped_agent_match)
+
+ timestamp = _extract_timestamp(line)
+ if timestamp is not None:
+ if first_timestamp is None or timestamp < first_timestamp:
+ first_timestamp = timestamp
+ if last_timestamp is None or timestamp > last_timestamp:
+ last_timestamp = timestamp
+
+ task_id_match = TEXT_TASK_ID_RE.search(line)
+ if task_id_match is not None:
+ task_ids.add(task_id_match.group(1).strip())
+
+ task_id_alt_match = TEXT_TASK_ID_ALT_RE.search(line)
+ if task_id_alt_match is not None:
+ task_ids.add(task_id_alt_match.group(1).strip())
+
+ simple_json_match = SIMPLE_JSON_RE.match(line)
+ if simple_json_match is not None:
+ details = _safe_load_json_dict(simple_json_match.group(2))
+ if details is not None:
+ entry = _extract_entry(details, simple_json_match.group(2))
+ if entry:
+ entries.add(entry)
+ continue
+
+ if line.startswith("{") and '"time"' in line and '"message"' in line:
+ data = _safe_load_json_dict(line)
+ if data is not None:
+ entry = _extract_entry(data, line)
+ if entry:
+ entries.add(entry)
+ continue
+
+ event_match = EVENT_RE.match(line)
+ if event_match is not None:
+ details = _safe_load_json_dict(event_match.group(3))
+ if details is not None:
+ entry = _extract_entry(details, event_match.group(3))
+ if entry:
+ entries.add(entry)
+
+ return RunContext(
+ first_timestamp=first_timestamp,
+ last_timestamp=last_timestamp,
+ task_ids=frozenset(task_ids),
+ entries=frozenset(entries),
+ )
+
+
+def _collect_runtime_task_starts(lines: list[str]) -> tuple[RuntimeTaskStart, ...]:
+ """从运行期日志中抽取顶层任务启动顺序。"""
+
+ task_starts: list[RuntimeTaskStart] = []
+
+ for raw_line in lines:
+ line = raw_line.rstrip("\r\n")
+ match = EVENT_RE.match(line)
+ if match is None or match.group(2) != "Tasker.Task.Starting":
+ continue
+
+ details = _safe_load_json_dict(match.group(3))
+ if details is None:
+ continue
+
+ entry = _extract_entry(details, match.group(3))
+ task_id = _extract_task_id(details, match.group(3))
+ if not entry or not task_id:
+ continue
+
+ try:
+ timestamp = datetime.strptime(match.group(1), "%Y-%m-%d %H:%M:%S.%f")
+ except ValueError:
+ continue
+
+ task_starts.append(
+ RuntimeTaskStart(timestamp=timestamp, entry=entry, task_id=task_id)
+ )
+
+ return tuple(task_starts)
+
+
+def _score_batch(
+ batch: Batch, run_context: RunContext, task_id_to_entry: dict[str, str]
+) -> float:
+ """综合 task_id、entry 和时间,找出最像本次运行的批次。"""
+
+ batch_task_ids = {task.task_id for task in batch.tasks if task.task_id}
+ batch_entries = {task.entry for task in batch.tasks if task.entry}
+
+ overlap_task_ids = len(run_context.task_ids & batch_task_ids)
+ overlap_entries = len(run_context.entries & batch_entries)
+
+ score = float(overlap_task_ids * 1000 + overlap_entries * 100)
+
+ if overlap_task_ids == 0 and run_context.task_ids:
+ for task_id in run_context.task_ids:
+ entry = task_id_to_entry.get(task_id)
+ if entry and entry in batch_entries:
+ score += 60.0
+
+ if run_context.first_timestamp is not None and batch.started_at is not None:
+ delta_seconds = abs(
+ (batch.started_at - run_context.first_timestamp).total_seconds()
+ )
+ score -= min(delta_seconds / 60.0, 500.0)
+ if batch.started_at <= run_context.first_timestamp:
+ score += 3.0
+
+ score += batch.batch_id / 1000.0
+ return score
+
+
+def _select_best_batch(
+ batches: tuple[Batch, ...],
+ run_context: RunContext,
+ task_id_to_entry: dict[str, str],
+) -> Batch | None:
+ """选择与当前输入日志最匹配的一次 start_tasks 批次。"""
+
+ best_batch: Batch | None = None
+ best_score: float | None = None
+
+ for batch in batches:
+ score = _score_batch(batch, run_context, task_id_to_entry)
+ if best_score is None or score > best_score:
+ best_batch = batch
+ best_score = score
+
+ return best_batch
+
+
+def _score_snapshot(snapshot: Snapshot, batch: Batch) -> tuple[int, int, int]:
+ """根据任务顺序匹配度给配置快照打分。"""
+
+ enabled_tasks = [task for task in snapshot.tasks if task.enabled]
+ if not enabled_tasks:
+ return (-10_000, -10_000, -10_000)
+
+ batch_entries = [task.entry for task in batch.tasks if task.entry]
+ matched_positions = 0
+ limit = min(len(enabled_tasks), len(batch_entries))
+ for index in range(limit):
+ if _task_name_matches(enabled_tasks[index].task_name, batch_entries[index]):
+ matched_positions += 1
+
+ score = matched_positions * 100
+ if len(enabled_tasks) == len(batch_entries):
+ score += 25
+ score -= abs(len(enabled_tasks) - len(batch_entries)) * 20
+
+ if batch.instance_id and snapshot.instance_id == batch.instance_id:
+ score += 18
+ if snapshot.source == "instance":
+ score += 8
+
+ recency = snapshot.closed_at if snapshot.closed_at is not None else -1
+ source_rank = 1 if snapshot.source == "instance" else 0
+ return (score, source_rank, recency)
+
+
+def _select_best_snapshot(
+ batch: Batch, snapshots: tuple[Snapshot, ...]
+) -> Snapshot | None:
+ """选择最能解释本批次 entry 顺序的配置快照。"""
+
+ best_snapshot: Snapshot | None = None
+ best_score: tuple[int, int, int] | None = None
+
+ for snapshot in snapshots:
+ score = _score_snapshot(snapshot, batch)
+ if best_score is None or score > best_score:
+ best_snapshot = snapshot
+ best_score = score
+
+ return best_snapshot
+
+
+def _build_selected_task_mapping(
+ batch: Batch | None,
+ snapshot: Snapshot | None,
+ task_id_to_entry: dict[str, str],
+) -> tuple[dict[str, str], dict[str, str]]:
+ """构建 task_id -> selected_task_id 和 entry -> selected_task_id 映射。"""
+
+ task_id_to_selected_task_id: dict[str, str] = {}
+ entry_to_selected_candidates: dict[str, list[str]] = {}
+
+ if batch is None or snapshot is None:
+ return task_id_to_selected_task_id, {}
+
+ enabled_tasks = [task for task in snapshot.tasks if task.enabled]
+ limit = min(len(enabled_tasks), len(batch.tasks))
+
+ for index in range(limit):
+ selected_task = enabled_tasks[index]
+ selected_task_id = selected_task.id
+ batch_task = batch.tasks[index]
+ if batch_task.entry and _task_name_matches(
+ selected_task.task_name, batch_task.entry
+ ):
+ entry_to_selected_candidates.setdefault(batch_task.entry, []).append(
+ selected_task_id
+ )
+ if (
+ batch_task.task_id
+ and batch_task.entry
+ and _task_name_matches(selected_task.task_name, batch_task.entry)
+ ):
+ task_id_to_selected_task_id[batch_task.task_id] = selected_task_id
+
+ entry_to_selected_unique = {
+ entry: selected_ids[0]
+ for entry, selected_ids in entry_to_selected_candidates.items()
+ if len(selected_ids) == 1
+ }
+
+ for task_id, entry in task_id_to_entry.items():
+ if task_id in task_id_to_selected_task_id:
+ continue
+ selected_task_id = entry_to_selected_unique.get(entry)
+ if selected_task_id:
+ task_id_to_selected_task_id[task_id] = selected_task_id
+
+ return task_id_to_selected_task_id, entry_to_selected_unique
+
+
+def _build_global_task_id_mapping(
+ batches: tuple[Batch, ...],
+ snapshots: tuple[Snapshot, ...],
+ task_id_to_entry: dict[str, str],
+) -> dict[str, str]:
+ """为所有批次建立全局 task_id -> selected_task_id 映射。"""
+
+ task_id_to_selected_task_id: dict[str, str] = {}
+
+ for batch in batches:
+ snapshot = _select_best_snapshot(batch, snapshots)
+ batch_mapping, _ = _build_selected_task_mapping(
+ batch, snapshot, task_id_to_entry
+ )
+ for task_id, selected_task_id in batch_mapping.items():
+ task_id_to_selected_task_id.setdefault(task_id, selected_task_id)
+
+ return task_id_to_selected_task_id
+
+
+def _build_selected_task_name_mapping(
+ snapshots: tuple[Snapshot, ...],
+) -> dict[str, str]:
+ """构建 selected_task_id -> taskName 映射,优先使用当前实例快照。"""
+
+ selected_task_name_mapping: dict[str, str] = {}
+
+ for snapshot in snapshots:
+ if snapshot.source != "instance":
+ continue
+ for task in snapshot.tasks:
+ selected_task_name_mapping[task.id] = task.task_name
+
+ for snapshot in snapshots:
+ if snapshot.source == "instance":
+ continue
+ for task in snapshot.tasks:
+ selected_task_name_mapping.setdefault(task.id, task.task_name)
+
+ return selected_task_name_mapping
+
+
+def _build_runtime_start_fallback_mapping(
+ runtime_task_starts: tuple[RuntimeTaskStart, ...],
+ snapshots: tuple[Snapshot, ...],
+) -> dict[str, str]:
+ """当 tauri 批次日志不完整时,用顶层任务启动顺序回填映射。"""
+
+ if not runtime_task_starts:
+ return {}
+
+ instance_snapshots = [
+ snapshot for snapshot in snapshots if snapshot.source == "instance"
+ ]
+ candidate_snapshots = instance_snapshots if instance_snapshots else list(snapshots)
+
+ best_snapshot: Snapshot | None = None
+ best_score: tuple[int, int, int] | None = None
+
+ for snapshot in candidate_snapshots:
+ enabled_tasks = [task for task in snapshot.tasks if task.enabled]
+ if not enabled_tasks:
+ continue
+
+ start_index = 0
+ matched = 0
+ for task in enabled_tasks:
+ while start_index < len(runtime_task_starts):
+ start = runtime_task_starts[start_index]
+ if _task_name_matches(task.task_name, start.entry):
+ matched += 1
+ start_index += 1
+ break
+ start_index += 1
+
+ if matched == 0:
+ continue
+
+ score = (
+ matched * 100 - abs(len(enabled_tasks) - matched) * 20,
+ 1 if snapshot.source == "instance" else 0,
+ snapshot.closed_at if snapshot.closed_at is not None else -1,
+ )
+ if best_score is None or score > best_score:
+ best_snapshot = snapshot
+ best_score = score
+
+ if best_snapshot is None:
+ return {}
+
+ mapping: dict[str, str] = {}
+ enabled_tasks = [task for task in best_snapshot.tasks if task.enabled]
+ start_index = 0
+ for task in enabled_tasks:
+ while start_index < len(runtime_task_starts):
+ start = runtime_task_starts[start_index]
+ start_index += 1
+ if _task_name_matches(task.task_name, start.entry):
+ mapping[start.task_id] = task.id
+ break
+
+ return mapping
+
+
+def _resolve_selected_task_id(
+ task_id: str,
+ current_selected_task_id: str,
+ task_id_to_selected_task_id: dict[str, str],
+ entry: str = "",
+ entry_to_selected_task_id: dict[str, str] | None = None,
+ active_snapshot: Snapshot | None = None,
+) -> str:
+ """优先用 task_id 还原,失败后允许按 entry 做稳定兜底。"""
+
+ if task_id:
+ selected_task_id = task_id_to_selected_task_id.get(task_id)
+ if selected_task_id:
+ return selected_task_id
+
+ if entry and entry_to_selected_task_id is not None:
+ selected_task_id = entry_to_selected_task_id.get(entry)
+ if selected_task_id:
+ return selected_task_id
+
+ if entry and active_snapshot is not None:
+ matched_selected_task_ids = [
+ task.id
+ for task in active_snapshot.tasks
+ if task.enabled and _task_name_matches(task.task_name, entry)
+ ]
+ if len(matched_selected_task_ids) == 1:
+ return matched_selected_task_ids[0]
+
+ return current_selected_task_id
+
+
+def _timestamp_text_to_datetime(timestamp_text: str) -> datetime | None:
+ """把标准展示时间转回 datetime,失败时返回空。"""
+
+ try:
+ return datetime.strptime(timestamp_text, "%Y-%m-%d %H:%M:%S.%f")
+ except ValueError:
+ return None
+
+
+def _extract_thread_id(line: str) -> str:
+ """提取 MaaFramework 日志中的线程 ID。"""
+
+ match = THREAD_ID_RE.search(line)
+ return match.group(1) if match is not None else ""
+
+
+def _inherit_task_mapping_from_thread(
+ task_id: str,
+ thread_id: str,
+ current_selected_task_id: str,
+ task_id_to_selected_task_id: dict[str, str],
+ thread_selected_task_id: dict[str, str],
+) -> str:
+ """当子任务 ID 未出现在固定批次映射中时,尝试继承同线程父任务归属。"""
+
+ if not task_id:
+ return current_selected_task_id
+
+ selected_task_id = task_id_to_selected_task_id.get(task_id, "")
+ if selected_task_id:
+ if thread_id:
+ thread_selected_task_id[thread_id] = selected_task_id
+ return selected_task_id
+
+ inherited_selected_task_id = ""
+ if thread_id:
+ inherited_selected_task_id = thread_selected_task_id.get(thread_id, "")
+ if not inherited_selected_task_id:
+ inherited_selected_task_id = current_selected_task_id
+
+ if inherited_selected_task_id:
+ task_id_to_selected_task_id[task_id] = inherited_selected_task_id
+ if thread_id:
+ thread_selected_task_id[thread_id] = inherited_selected_task_id
+ return inherited_selected_task_id
+
+ return current_selected_task_id
+
+
+def _append_unresolved_diagnosis(
+ message: str,
+ timestamp_text: str,
+ task_id: str,
+ current_selected_task_id: str,
+ known_task_ids: set[str],
+ earliest_post_task_time: datetime | None,
+ should_diagnose: bool,
+) -> str:
+ """为未解析到任务 ID 的日志追加诊断信息。"""
+
+ if current_selected_task_id or not should_diagnose:
+ return message
+
+ reason = ""
+ if task_id:
+ if task_id not in known_task_ids:
+ reason = f"未解析原因: task_id={task_id} 未出现在固定批次映射中"
+ else:
+ reason = f"未解析原因: task_id={task_id} 已出现但未成功落到前端任务"
+ else:
+ current_time = _timestamp_text_to_datetime(timestamp_text)
+ if (
+ current_time is not None
+ and earliest_post_task_time is not None
+ and current_time <= earliest_post_task_time
+ ):
+ reason = "未解析原因: 当前日志早于或紧邻 post_task,尚未建立 task_id 映射"
+ else:
+ reason = "未解析原因: 当前日志未携带 task_id"
+
+ return f"{message} [{reason}]"
+
+
+def parse_log(root_dir: Path, lines: list[str]) -> list[str]:
+ """解析 MaaEnd 日志,并尽量稳定还原前端任务 ID。"""
+
+ auxiliary_data = _build_auxiliary_data(root_dir)
+ run_context = _collect_run_context(lines)
+ runtime_task_starts = _collect_runtime_task_starts(lines)
+ best_batch = _select_best_batch(
+ auxiliary_data.batches,
+ run_context,
+ auxiliary_data.task_id_to_entry,
+ )
+ best_snapshot = (
+ _select_best_snapshot(best_batch, auxiliary_data.snapshots)
+ if best_batch is not None
+ else None
+ )
+ task_id_to_selected_task_id, entry_to_selected_task_id = (
+ _build_selected_task_mapping(
+ best_batch,
+ best_snapshot,
+ auxiliary_data.task_id_to_entry,
+ )
+ )
+ if not task_id_to_selected_task_id:
+ task_id_to_selected_task_id = _build_global_task_id_mapping(
+ auxiliary_data.batches,
+ auxiliary_data.snapshots,
+ auxiliary_data.task_id_to_entry,
+ )
+
+ selected_task_name_mapping = _build_selected_task_name_mapping(
+ auxiliary_data.snapshots
+ )
+
+ for task_id, selected_task_id in _build_runtime_start_fallback_mapping(
+ runtime_task_starts,
+ auxiliary_data.snapshots,
+ ).items():
+ task_id_to_selected_task_id[task_id] = selected_task_id
+
+ known_task_ids = {
+ task.task_id
+ for task in (best_batch.tasks if best_batch is not None else ())
+ if task.task_id
+ }
+ known_task_ids.update(task_id_to_selected_task_id)
+ earliest_post_task_time = min(
+ (
+ task.posted_at
+ for batch in auxiliary_data.batches
+ for task in batch.tasks
+ if task.posted_at is not None
+ ),
+ default=None,
+ )
+ result: list[str] = []
+ undated_indices: list[int] = []
+ current_date = (
+ run_context.first_timestamp.strftime("%Y-%m-%d")
+ if run_context.first_timestamp is not None
+ else ""
+ )
+ current_selected_task_id = ""
+ current_maa_task_id: str = ""
+ thread_selected_task_id: dict[str, str] = {}
+ pending_panel_time = ""
+ pending_panel_lines: list[str] = []
+ last_emit_key = ""
+ last_emit_time: datetime | None = None
+
+ def flush_panel() -> None:
+ nonlocal pending_panel_time, pending_panel_lines
+ if not pending_panel_time or not pending_panel_lines:
+ pending_panel_time = ""
+ pending_panel_lines = []
+ return
+
+ panel_line = (
+ f"[{current_date} {pending_panel_time}.000] {current_selected_task_id} - "
+ + "\n".join(pending_panel_lines)
+ + "\n"
+ )
+ if not current_date:
+ undated_indices.append(len(result))
+ panel_line = panel_line.replace(f"[{current_date} ", "[ ", 1)
+ result.append(panel_line)
+ pending_panel_time = ""
+ pending_panel_lines = []
+
+ for raw_line in lines:
+ line = raw_line.rstrip("\r\n")
+ thread_id = _extract_thread_id(line)
+
+ wrapped_agent_match = WRAPPED_AGENT_RE.match(line)
+ if wrapped_agent_match is not None:
+ line = _decode_wrapped_line(wrapped_agent_match)
+ elif INLINE_INSTANCE_RE.search(line) is not None and not (
+ FULL_TS_RE.match(line)
+ or ISO_TS_RE.match(line)
+ or SIMPLE_JSON_RE.match(line)
+ or EVENT_RE.match(line)
+ or PANEL_TS_RE.match(line)
+ ):
+ # 纯实例包装行不直接参与展示,但不要误伤带业务内容的行。
+ continue
+
+ timestamp = _extract_timestamp(line)
+ if timestamp is not None:
+ current_date = timestamp.strftime("%Y-%m-%d")
+ if undated_indices:
+ for index in undated_indices:
+ result[index] = result[index].replace("[ ", f"[{current_date} ", 1)
+ undated_indices = []
+
+ panel_ts_match = PANEL_TS_RE.match(line)
+ if panel_ts_match is not None:
+ flush_panel()
+ pending_panel_time = panel_ts_match.group(1)
+ pending_panel_lines = []
+ continue
+
+ if pending_panel_time:
+ if NEXT_FULL_TS_RE.match(line) or NEXT_PANEL_TS_RE.match(line):
+ flush_panel()
+ else:
+ pending_panel_lines.append(line)
+ continue
+
+ agent_log_match = AGENT_LOG_RE.match(line)
+ if agent_log_match is not None:
+ timestamp_text = (
+ f"{agent_log_match.group(1)} {agent_log_match.group(2)}.000"
+ )
+ message = f"{agent_log_match.group(3)} {agent_log_match.group(4)}"
+ task_id_match = TEXT_TASK_ID_RE.search(message)
+ task_id_alt_match = TEXT_TASK_ID_ALT_RE.search(message)
+ task_id = ""
+ if task_id_match is not None:
+ task_id = cast(str, task_id_match.group(1).strip())
+ elif task_id_alt_match is not None:
+ task_id = cast(str, task_id_alt_match.group(1).strip())
+
+ if task_id:
+ current_maa_task_id = task_id
+ current_maa_task_id_str: str = current_maa_task_id
+ current_selected_task_id = _inherit_task_mapping_from_thread(
+ task_id=current_maa_task_id_str,
+ thread_id=thread_id,
+ current_selected_task_id=_resolve_selected_task_id(
+ task_id=current_maa_task_id_str,
+ current_selected_task_id=current_selected_task_id,
+ task_id_to_selected_task_id=task_id_to_selected_task_id,
+ entry_to_selected_task_id=entry_to_selected_task_id,
+ active_snapshot=best_snapshot,
+ ),
+ task_id_to_selected_task_id=task_id_to_selected_task_id,
+ thread_selected_task_id=thread_selected_task_id,
+ )
+ message = _append_unresolved_diagnosis(
+ message=message,
+ timestamp_text=timestamp_text,
+ task_id=current_maa_task_id_str,
+ current_selected_task_id=current_selected_task_id,
+ known_task_ids=known_task_ids,
+ earliest_post_task_time=earliest_post_task_time,
+ should_diagnose=bool(current_maa_task_id_str),
+ )
+ last_emit_key, last_emit_time = _emit_log_line(
+ result,
+ timestamp_text,
+ current_selected_task_id,
+ message,
+ last_emit_key,
+ last_emit_time,
+ )
+ continue
+
+ simple_json_match = SIMPLE_JSON_RE.match(line)
+ if simple_json_match is not None:
+ timestamp_text = simple_json_match.group(1)
+ details = _safe_load_json_dict(simple_json_match.group(2))
+ if details is None:
+ continue
+
+ event_name = (
+ details.get("event") if isinstance(details.get("event"), str) else ""
+ )
+ task_id = _extract_task_id(details, simple_json_match.group(2))
+ entry = _extract_entry(details, simple_json_match.group(2))
+ if task_id:
+ current_maa_task_id = task_id
+ current_maa_task_id_str = current_maa_task_id
+ current_selected_task_id = _inherit_task_mapping_from_thread(
+ task_id=current_maa_task_id_str,
+ thread_id=thread_id,
+ current_selected_task_id=_resolve_selected_task_id(
+ task_id=current_maa_task_id_str,
+ current_selected_task_id=current_selected_task_id,
+ task_id_to_selected_task_id=task_id_to_selected_task_id,
+ entry=entry,
+ entry_to_selected_task_id=entry_to_selected_task_id,
+ active_snapshot=best_snapshot,
+ ),
+ task_id_to_selected_task_id=task_id_to_selected_task_id,
+ thread_selected_task_id=thread_selected_task_id,
+ )
+
+ if event_name in TASK_EVENT_TEXT:
+ message = TASK_EVENT_TEXT[event_name]
+ display_task_name = selected_task_name_mapping.get(
+ current_selected_task_id, ""
+ )
+ if display_task_name:
+ message += f": {display_task_name}"
+ elif entry:
+ message += f": {entry}"
+ message = _append_unresolved_diagnosis(
+ message=message,
+ timestamp_text=timestamp_text,
+ task_id=current_maa_task_id_str,
+ current_selected_task_id=current_selected_task_id,
+ known_task_ids=known_task_ids,
+ earliest_post_task_time=earliest_post_task_time,
+ should_diagnose=True,
+ )
+ last_emit_key, last_emit_time = _emit_log_line(
+ result,
+ timestamp_text,
+ current_selected_task_id,
+ message,
+ last_emit_key,
+ last_emit_time,
+ )
+ continue
+
+ if line.startswith("{") and '"time"' in line and '"message"' in line:
+ json_line = _safe_load_json_dict(line)
+ if json_line is None:
+ continue
+
+ json_time = json_line.get("time")
+ json_message = json_line.get("message")
+ if not isinstance(json_time, str) or not isinstance(json_message, str):
+ continue
+
+ json_time_match = ISO_TS_RE.match(json_time)
+ if json_time_match is None:
+ continue
+
+ timestamp_text = (
+ f"{json_time_match.group(1)} {json_time_match.group(2)}.000"
+ )
+ task_id = _extract_task_id(json_line, line)
+ entry = _extract_entry(json_line, line)
+ if task_id:
+ current_maa_task_id = task_id
+ current_maa_task_id_str = current_maa_task_id
+ current_selected_task_id = _inherit_task_mapping_from_thread(
+ task_id=current_maa_task_id_str,
+ thread_id=thread_id,
+ current_selected_task_id=_resolve_selected_task_id(
+ task_id=current_maa_task_id_str,
+ current_selected_task_id=current_selected_task_id,
+ task_id_to_selected_task_id=task_id_to_selected_task_id,
+ entry=entry,
+ entry_to_selected_task_id=entry_to_selected_task_id,
+ active_snapshot=best_snapshot,
+ ),
+ task_id_to_selected_task_id=task_id_to_selected_task_id,
+ thread_selected_task_id=thread_selected_task_id,
+ )
+
+ level_text = (
+ json_line["level"].upper()
+ if isinstance(json_line.get("level"), str)
+ else ""
+ )
+ message = f"{level_text} {json_message}" if level_text else json_message
+ message = _append_unresolved_diagnosis(
+ message=message,
+ timestamp_text=timestamp_text,
+ task_id=current_maa_task_id_str,
+ current_selected_task_id=current_selected_task_id,
+ known_task_ids=known_task_ids,
+ earliest_post_task_time=earliest_post_task_time,
+ should_diagnose=bool(task_id or entry),
+ )
+ last_emit_key, last_emit_time = _emit_log_line(
+ result,
+ timestamp_text,
+ current_selected_task_id,
+ message,
+ last_emit_key,
+ last_emit_time,
+ )
+ continue
+
+ event_match = EVENT_RE.match(line)
+ if event_match is None:
+ continue
+
+ timestamp_text = event_match.group(1)
+ event_name = event_match.group(2)
+ details = _safe_load_json_dict(event_match.group(3))
+ if details is None:
+ continue
+
+ task_id = _extract_task_id(details, event_match.group(3))
+ entry = _extract_entry(details, event_match.group(3))
+ if task_id:
+ current_maa_task_id = task_id
+ current_maa_task_id_str = current_maa_task_id
+ current_selected_task_id = _inherit_task_mapping_from_thread(
+ task_id=current_maa_task_id_str,
+ thread_id=thread_id,
+ current_selected_task_id=_resolve_selected_task_id(
+ task_id=current_maa_task_id_str,
+ current_selected_task_id=current_selected_task_id,
+ task_id_to_selected_task_id=task_id_to_selected_task_id,
+ entry=entry,
+ entry_to_selected_task_id=entry_to_selected_task_id,
+ active_snapshot=best_snapshot,
+ ),
+ task_id_to_selected_task_id=task_id_to_selected_task_id,
+ thread_selected_task_id=thread_selected_task_id,
+ )
+
+ message = ""
+ if event_name in TASK_EVENT_TEXT:
+ message = TASK_EVENT_TEXT[event_name]
+ display_task_name = selected_task_name_mapping.get(
+ current_selected_task_id, ""
+ )
+ if display_task_name:
+ message += f": {display_task_name}"
+ elif entry:
+ message += f": {entry}"
+ elif event_name in RESOURCE_EVENT_TEXT:
+ if event_name == "Resource.Loading.Starting":
+ current_selected_task_id = ""
+ path_text = (
+ details.get("path") if isinstance(details.get("path"), str) else ""
+ )
+ message = RESOURCE_EVENT_TEXT[event_name]
+ if path_text:
+ message += f": {path_text}"
+ elif event_name in CONTROLLER_EVENT_TEXT:
+ action_text = (
+ details.get("action") if isinstance(details.get("action"), str) else ""
+ )
+ param_action_text = ""
+ if isinstance(details.get("param"), dict):
+ param_dict = cast(dict[str, Any], details["param"])
+ param_action = param_dict.get("action")
+ if isinstance(param_action, str):
+ param_action_text = param_action
+ if (
+ str(action_text).lower() != "connect"
+ and param_action_text.lower() != "connect"
+ ):
+ continue
+ message = CONTROLLER_EVENT_TEXT[event_name]
+ target_text = (
+ details.get("target") if isinstance(details.get("target"), str) else ""
+ )
+ if target_text:
+ message += f": {target_text}"
+ else:
+ focus = details.get("focus")
+ if not isinstance(focus, dict):
+ continue
+ focus_dict = cast(dict[str, Any], focus)
+ focus_entry = focus_dict.get(event_name)
+ if isinstance(focus_entry, str):
+ message = focus_entry
+ elif isinstance(focus_entry, dict):
+ focus_entry_dict = cast(dict[str, Any], focus_entry)
+ content = focus_entry_dict.get("content")
+ if not isinstance(content, str) or not content:
+ continue
+ display = focus_entry_dict.get("display")
+ display_list: list[str] = []
+ if isinstance(display, str):
+ display_list = [display]
+ elif isinstance(display, list):
+ display_items = cast(list[Any], display)
+ display_list = [
+ item for item in display_items if isinstance(item, str)
+ ]
+ if display_list and not any(
+ item in ("log", "dialog", "modal") for item in display_list
+ ):
+ continue
+ message = content
+ else:
+ continue
+
+ for key, value in details.items():
+ if key == "focus" or value is None:
+ continue
+ if isinstance(value, (str, int, float, bool)):
+ message = message.replace("{" + key + "}", str(value))
+ message = message.replace("{image}", "").strip()
+ if not message:
+ continue
+
+ message = _append_unresolved_diagnosis(
+ message=message,
+ timestamp_text=timestamp_text,
+ task_id=current_maa_task_id_str,
+ current_selected_task_id=current_selected_task_id,
+ known_task_ids=known_task_ids,
+ earliest_post_task_time=earliest_post_task_time,
+ should_diagnose=event_name not in RESOURCE_EVENT_TEXT
+ and event_name not in CONTROLLER_EVENT_TEXT,
+ )
+
+ last_emit_key, last_emit_time = _emit_log_line(
+ result,
+ timestamp_text,
+ current_selected_task_id,
+ message,
+ last_emit_key,
+ last_emit_time,
+ )
+
+ flush_panel()
+
+ if current_date and undated_indices:
+ for index in undated_indices:
+ result[index] = result[index].replace("[ ", f"[{current_date} ", 1)
+
+ return result
diff --git a/app/task/SRC/AutoProxy.py b/app/task/SRC/AutoProxy.py
index a4ceb3fd..37b7f7c6 100644
--- a/app/task/SRC/AutoProxy.py
+++ b/app/task/SRC/AutoProxy.py
@@ -30,8 +30,8 @@
from app.core import Config
from app.models.task import TaskExecuteBase, ScriptItem, LogRecord
-from app.models.ConfigBase import MultipleConfig
-from app.models.config import SrcConfig, SrcUserConfig
+from app.core.config.base import MultipleConfig
+from app.models import SrcConfig, SrcUserConfig
from app.models.emulator import DeviceBase, DeviceInfo
from app.services import Notify, System
from app.utils import get_logger, LogMonitor, ProcessManager, strptime
@@ -67,14 +67,11 @@ def __init__(
self.check_result = "-"
async def check(self) -> str:
-
if self.script_config.get(
"Run", "ProxyTimesLimit"
) != 0 and self.cur_user_config.get(
"Data", "ProxyTimes"
- ) >= self.script_config.get(
- "Run", "ProxyTimesLimit"
- ):
+ ) >= self.script_config.get("Run", "ProxyTimesLimit"):
self.cur_user_item.status = "跳过"
return "今日代理次数已达上限, 跳过该用户"
@@ -90,7 +87,6 @@ async def check(self) -> str:
return "Pass"
async def prepare(self):
-
self.src_process_manager = ProcessManager()
self.src_log_monitor = LogMonitor(
(0, 23), "%Y-%m-%d %H:%M:%S.%f", self.check_log
@@ -193,7 +189,6 @@ async def main_task(self):
self.script_info.log = "正在启动模拟器...\n模拟器启动成功\n正在登录「崩坏·星穹铁道」\n「崩坏·星穹铁道」登录成功\n正在等待 SRC 日志文件生成"
if_get_file = False
while datetime.now() - t < timedelta(minutes=1):
-
for log_file in self.src_log_path.parent.iterdir():
if log_file.is_file():
with suppress(ValueError):
@@ -260,7 +255,6 @@ async def main_task(self):
async def handle_pre_src_error(
self, error_message: str, e: Exception | None = None
):
-
if e is None:
logger.error(f"用户: {self.cur_user_uid} - {error_message}")
await Config.send_websocket_message(
@@ -430,7 +424,7 @@ async def set_src(self, emulator_info: DeviceInfo) -> None:
else None
),
)
- logger.info(f"脚本运行参数配置完成: 自动代理")
+ logger.info("脚本运行参数配置完成: 自动代理")
async def check_log(self, log_content: list[str], latest_time: datetime) -> None:
"""日志回调"""
@@ -462,7 +456,6 @@ async def check_log(self, log_content: list[str], latest_time: datetime) -> None
self.wait_event.set()
async def final_task(self):
-
if self.check_result != "Pass":
return
@@ -482,9 +475,8 @@ async def final_task(self):
del self.src_process_manager
del self.src_log_monitor
- user_logs_list = []
+ user_logs_list: list[Path] = []
for t, log_item in self.cur_user_item.log_record.items():
-
dt = t.replace(tzinfo=datetime.now().astimezone().tzinfo).astimezone(UTC4)
log_path = (
Path.cwd()
diff --git a/app/task/SRC/ManualReview.py b/app/task/SRC/ManualReview.py
index b8e013d7..86ace2ce 100644
--- a/app/task/SRC/ManualReview.py
+++ b/app/task/SRC/ManualReview.py
@@ -24,11 +24,12 @@
import asyncio
from pathlib import Path
from datetime import datetime
+from typing import Any, cast
from app.core import Config, Broadcast
from app.models.task import TaskExecuteBase, ScriptItem
-from app.models.ConfigBase import MultipleConfig
-from app.models.config import SrcConfig, SrcUserConfig
+from app.core.config.base import MultipleConfig
+from app.models import SrcConfig, SrcUserConfig
from app.models.emulator import DeviceBase
from app.utils import get_logger
from app.utils.constants import STARRAIL_PACKAGE_NAME, UTC4
@@ -63,7 +64,6 @@ def __init__(
self.check_result = "-"
async def check(self) -> str:
-
if (
self.cur_user_config.get("Info", "Mode") == "详细"
and not (
@@ -76,8 +76,7 @@ async def check(self) -> str:
return "Pass"
async def prepare(self):
-
- self.message_queue = asyncio.Queue()
+ self.message_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
await Broadcast.subscribe(self.message_queue)
self.wait_event = asyncio.Event()
@@ -110,7 +109,6 @@ async def main_task(self):
self.cur_user_item.status = "运行"
while True:
-
try:
self.script_info.log = "正在启动模拟器"
emulator_info = await self.emulator_manager.open(
@@ -118,7 +116,6 @@ async def main_task(self):
STARRAIL_PACKAGE_NAME[self.cur_user_config.get("Info", "Server")],
)
except Exception as e:
-
logger.exception(
f"用户: {self.cur_user_item.user_id} - 模拟器启动失败: {e}"
)
@@ -144,8 +141,13 @@ async def main_task(self):
"options": ["是", "否"],
},
)
- result = await self._wait_for_user_response(uid)
- if not result.get("data", {}).get("choice", False):
+ result: dict[str, Any] = await self._wait_for_user_response(uid)
+ data = result.get("data")
+ choice = False
+ if isinstance(data, dict):
+ data_dict = cast(dict[str, Any], data)
+ choice = bool(data_dict.get("choice"))
+ if not choice:
break
continue
@@ -185,12 +187,16 @@ async def main_task(self):
"options": ["是", "否"],
},
)
- result = await self._wait_for_user_response(uid)
- if not result.get("data", {}).get("choice", False):
+ result: dict[str, Any] = await self._wait_for_user_response(uid)
+ data = result.get("data")
+ choice = False
+ if isinstance(data, dict):
+ data_dict = cast(dict[str, Any], data)
+ choice = bool(data_dict.get("choice"))
+ if not choice:
break
if self.run_book["SignIn"]:
-
try:
await self.emulator_manager.setVisible(
self.script_config.get("Emulator", "Index"), True
@@ -209,15 +215,20 @@ async def main_task(self):
"options": ["是", "否"],
},
)
- result = await self._wait_for_user_response(uid)
- if result.get("data", {}).get("choice", False):
+ result: dict[str, Any] = await self._wait_for_user_response(uid)
+ data = result.get("data")
+ choice = False
+ if isinstance(data, dict):
+ data_dict = cast(dict[str, Any], data)
+ choice = bool(data_dict.get("choice"))
+ if choice:
self.run_book["PassCheck"] = True
- async def _wait_for_user_response(self, message_id: str):
+ async def _wait_for_user_response(self, message_id: str) -> dict[str, Any]:
"""等待用户交互响应"""
logger.info(f"等待客户端回应消息: {message_id}")
while True:
- message = await self.message_queue.get()
+ message: dict[str, Any] = await self.message_queue.get()
if message.get("id") == message_id and message.get("type") == "Response":
self.message_queue.task_done()
logger.success(f"收到客户端回应消息: {message_id}")
@@ -226,7 +237,6 @@ async def _wait_for_user_response(self, message_id: str):
self.message_queue.task_done()
async def final_task(self):
-
if self.check_result != "Pass":
return
diff --git a/app/task/SRC/ScriptConfig.py b/app/task/SRC/ScriptConfig.py
index 3f37e68b..8303f56e 100644
--- a/app/task/SRC/ScriptConfig.py
+++ b/app/task/SRC/ScriptConfig.py
@@ -26,8 +26,8 @@
from app.core import Config
from app.models.task import TaskExecuteBase, ScriptItem
-from app.models.ConfigBase import MultipleConfig
-from app.models.config import SrcConfig, SrcUserConfig
+from app.core.config.base import MultipleConfig
+from app.models import SrcConfig, SrcUserConfig
from app.models.emulator import DeviceBase
from app.services import System
from app.utils import get_logger, ProcessManager
@@ -58,7 +58,6 @@ def __init__(
self.cur_user_item = self.script_info.user_list[self.script_info.current_index]
async def prepare(self):
-
self.src_process_manager = ProcessManager()
self.wait_event = asyncio.Event()
@@ -67,7 +66,6 @@ async def prepare(self):
self.src_exe_path = self.src_root_path / "src.exe"
async def main_task(self):
-
await self.prepare()
await self.set_src()
@@ -144,7 +142,6 @@ async def set_src(self):
logger.success(f"SRC运行参数配置完成: 设置脚本 {self.cur_user_item.user_id}")
async def final_task(self):
-
await self.src_process_manager.kill()
await System.kill_process(self.src_exe_path)
diff --git a/app/task/SRC/manager.py b/app/task/SRC/manager.py
index 8b686628..821a3f30 100644
--- a/app/task/SRC/manager.py
+++ b/app/task/SRC/manager.py
@@ -27,8 +27,8 @@
from app.core import Config, EmulatorManager
from app.models.task import TaskExecuteBase, ScriptItem, UserItem
-from app.models.ConfigBase import MultipleConfig
-from app.models.config import SrcConfig, SrcUserConfig
+from app.core.config.base import MultipleConfig
+from app.models import SrcConfig, SrcUserConfig
from app.services import Notify
from app.utils import get_logger
from app.utils.constants import TASK_MODE_ZH
@@ -161,7 +161,6 @@ async def prepare(self):
)
async def main_task(self):
-
self.check_result = await self.check()
if self.check_result != "Pass":
logger.error(f"未通过配置检查: {self.check_result}")
@@ -192,14 +191,13 @@ async def final_task(self):
if self.check_result != "Pass":
self.script_info.status = "异常"
- return self.check_result
+ return
logger.info("SRC 主任务已结束, 开始执行后续操作")
await Config.ScriptConfig[uuid.UUID(self.script_info.script_id)].unlock()
logger.success(f"已解锁脚本配置 {self.script_info.script_id}")
if self.task_info.mode in ["AutoProxy", "ManualReview"]:
-
await self.emulator_manager.close(
self.script_config.get("Emulator", "Index")
)
@@ -253,7 +251,6 @@ async def final_task(self):
self.script_info.status = "完成"
async def on_crash(self, e: Exception):
-
self.script_info.status = "异常"
logger.exception(f"SRC任务出现异常: {e}")
await Config.send_websocket_message(
diff --git a/app/task/SRC/tools/login.py b/app/task/SRC/tools/login.py
index 573299ec..01e70312 100644
--- a/app/task/SRC/tools/login.py
+++ b/app/task/SRC/tools/login.py
@@ -21,6 +21,7 @@
import asyncio
from contextlib import suppress
+from typing import Any
from app.models.emulator import DeviceInfo, DeviceStatus
from app.utils import get_logger
@@ -54,7 +55,7 @@ async def login(
if (package_name == "com.miHoYo.hkrpg" and "*" in id) or password == "":
logger.info("账号密码不完整,禁用通过输入账号密码登录")
- pipeline_override = {
+ pipeline_override: dict[str, Any] = {
"切换账号[StarRailEmulator]": {"action": {"param": {"package": package_name}}},
"启动游戏[StarRailEmulator]": {"action": {"param": {"package": package_name}}},
"Bilibili隐私政策[StarRailEmulator]": {"enabled": Config.get("Function", "IfAgreeBilibili")},
diff --git a/app/task/SRC/tools/notify.py b/app/task/SRC/tools/notify.py
index 313ee2ce..df7acf69 100644
--- a/app/task/SRC/tools/notify.py
+++ b/app/task/SRC/tools/notify.py
@@ -21,13 +21,16 @@
from app.core import Config
from app.services import Notify
from app.utils import get_logger
-from app.models.config import SrcUserConfig
+from typing import Any
logger = get_logger("SRC通知工具")
async def push_notification(
- mode: str, title: str, message: dict, user_config: SrcUserConfig | None
+ mode: str,
+ title: str,
+ message: dict[str, Any],
+ user_config: Any | None,
) -> None:
"""通过所有渠道推送通知"""
@@ -119,25 +122,26 @@ async def push_notification(
and user_config.get("Notify", "Enabled")
and user_config.get("Notify", "IfSendStatistic")
):
+ user_config_any = user_config
# 发送邮件通知
- if user_config.get("Notify", "IfSendMail"):
- if user_config.get("Notify", "ToAddress"):
+ if user_config_any.get("Notify", "IfSendMail"):
+ if user_config_any.get("Notify", "ToAddress"):
await Notify.send_mail(
"网页",
title,
message_html,
- user_config.get("Notify", "ToAddress"),
+ user_config_any.get("Notify", "ToAddress"),
)
else:
logger.error("用户邮箱地址为空, 无法发送用户单独的邮件通知")
# 发送ServerChan通知
- if user_config.get("Notify", "IfServerChan"):
- if user_config.get("Notify", "ServerChanKey"):
+ if user_config_any.get("Notify", "IfServerChan"):
+ if user_config_any.get("Notify", "ServerChanKey"):
await Notify.ServerChanPush(
title,
f"{serverchan_message}\n\nAUTO-MAS 敬上",
- user_config.get("Notify", "ServerChanKey"),
+ user_config_any.get("Notify", "ServerChanKey"),
)
else:
logger.error(
@@ -145,7 +149,7 @@ async def push_notification(
)
# 推送CompanyWebHookBot通知
- for webhook in user_config.Notify_CustomWebhooks.values():
+ for webhook in user_config_any.Notify_CustomWebhooks.values():
await Notify.WebhookPush(
title, f"{message_text}\n\nAUTO-MAS 敬上", webhook
)
diff --git a/app/task/SRC/tools/poor_yaml.py b/app/task/SRC/tools/poor_yaml.py
index a716d26f..dc98805a 100644
--- a/app/task/SRC/tools/poor_yaml.py
+++ b/app/task/SRC/tools/poor_yaml.py
@@ -25,65 +25,43 @@
# Contact: DLmaster_361@163.com
-import re
from pathlib import Path
+from typing import Any, cast
-from app.utils import decode_bytes
-
-
-def poor_yaml_read(file: Path) -> dict:
- """
- Poor implementation to load yaml without pyyaml dependency, but with re
-
- Args:
- file (Path): yaml file path
-
- Returns:
- dict:
- """
- content = decode_bytes(file.read_bytes())
- data = {}
- regex = re.compile(r"^(.*?):(.*?)$")
- for line in content.splitlines():
- line = line.strip("\n\r\t ").replace("\\", "/")
- if line.startswith("#"):
- continue
- result = re.match(regex, line)
- if result:
- k, v = result.group(1), result.group(2).strip("\n\r\t' ")
- if v:
- if v.lower() == "null":
- v = None
- elif v.lower() == "false":
- v = False
- elif v.lower() == "true":
- v = True
- elif v.isdigit():
- v = int(v)
- data[k] = v
-
- return data
-
-
-def poor_yaml_write(data: dict, file: Path, template_file: Path | None = None):
- """
- Args:
- data (dict):
- file (Path): yaml file path
- template_file (Path | None): template file path
- """
- if template_file is None:
- template_file = file
- text = decode_bytes(template_file.read_bytes())
- text = text.replace("\\", "/")
-
- for key, value in data.items():
- if value is None:
- value = "null"
- elif value is True:
- value = "true"
- elif value is False:
- value = "false"
- text = re.sub(f"{key}:.*?\n", f"{key}: {value}\n", text)
-
- file.write_text(text, encoding="utf-8")
+from ruamel.yaml import YAML
+
+
+_YAML = YAML(typ="safe")
+_YAML.default_flow_style = False
+
+
+def _load_yaml_mapping(path: Path) -> dict[str, Any]:
+ loaded: Any = cast(Any, _YAML).load(path.read_text(encoding="utf-8"))
+ if loaded is None:
+ return {}
+ if not isinstance(loaded, dict):
+ raise ValueError(f"YAML 根节点必须是字典: {path}")
+ mapping = cast(dict[object, Any], loaded)
+ return {str(key): value for key, value in mapping.items()}
+
+
+def poor_yaml_read(file: Path) -> dict[str, Any]:
+ """读取 YAML 文件并返回字典对象。"""
+
+ return _load_yaml_mapping(file)
+
+
+def poor_yaml_write(
+ data: dict[str, Any], file: Path, template_file: Path | None = None
+) -> None:
+ """写入 YAML 文件;若提供模板则先加载模板并合并。"""
+
+ merged: dict[str, Any] = {}
+ if template_file is not None and template_file.exists():
+ merged = _load_yaml_mapping(template_file)
+
+ merged.update(data)
+
+ file.parent.mkdir(parents=True, exist_ok=True)
+ with file.open("w", encoding="utf-8") as output:
+ cast(Any, _YAML).dump(merged, output)
diff --git a/app/task/general/AutoProxy.py b/app/task/general/AutoProxy.py
index 4f36dbe4..326624e3 100644
--- a/app/task/general/AutoProxy.py
+++ b/app/task/general/AutoProxy.py
@@ -30,8 +30,8 @@
from app.core import Config
from app.models.task import TaskExecuteBase, ScriptItem, LogRecord
-from app.models.ConfigBase import MultipleConfig
-from app.models.config import GeneralConfig, GeneralUserConfig
+from app.core.config.base import MultipleConfig
+from app.models import GeneralConfig, GeneralUserConfig
from app.models.emulator import DeviceBase
from app.services import Notify, System
from app.utils import get_logger, LogMonitor, ProcessManager, ProcessInfo, strptime
@@ -67,14 +67,11 @@ def __init__(
self.check_result = "-"
async def check(self) -> str:
-
if self.script_config.get(
"Run", "ProxyTimesLimit"
) != 0 and self.cur_user_config.get(
"Data", "ProxyTimes"
- ) >= self.script_config.get(
- "Run", "ProxyTimesLimit"
- ):
+ ) >= self.script_config.get("Run", "ProxyTimesLimit"):
self.cur_user_item.status = "跳过"
return "今日代理次数已达上限, 跳过该用户"
@@ -89,7 +86,6 @@ async def check(self) -> str:
return "Pass"
async def prepare(self):
-
self.general_process_manager = ProcessManager()
self.wait_event = asyncio.Event()
self.user_start_time = datetime.now()
@@ -98,8 +94,8 @@ async def prepare(self):
self.script_root_path = Path(self.script_config.get("Info", "RootPath"))
self.script_path = Path(self.script_config.get("Script", "ScriptPath"))
- arguments_list = []
- path_list = []
+ arguments_list: list[list[str]] = []
+ path_list: list[Path] = []
for argument in [
part.strip()
@@ -119,11 +115,13 @@ async def prepare(self):
)
arguments_list.append(shlex.split(arg_parts[-1]))
- self.script_exe_path = path_list[0] if len(path_list) > 0 else self.script_path
- self.script_arguments = arguments_list[0] if len(arguments_list) > 0 else []
- self.script_set_arguments = arguments_list[1] if len(arguments_list) > 1 else []
+ self.script_exe_path: Path = path_list[0] if path_list else self.script_path
+ self.script_arguments: list[str] = arguments_list[0] if arguments_list else []
+ self.script_set_arguments: list[str] = (
+ arguments_list[1] if len(arguments_list) > 1 else []
+ )
- self.script_target_process_info = (
+ self.script_target_process_info: ProcessInfo | None = (
ProcessInfo(
name=self.script_config.get("Script", "TrackProcessName") or None,
exe=self.script_config.get("Script", "TrackProcessExe") or None,
@@ -136,10 +134,12 @@ async def prepare(self):
else None
)
- self.script_config_path = Path(self.script_config.get("Script", "ConfigPath"))
+ self.script_config_path: Path = Path(
+ self.script_config.get("Script", "ConfigPath")
+ )
- self.script_log_path = Path(self.script_config.get("Script", "LogPath"))
- self.log_format = self.script_config.get("Script", "LogPathFormat")
+ self.script_log_path: Path = Path(self.script_config.get("Script", "LogPath"))
+ self.log_format: str = self.script_config.get("Script", "LogPathFormat")
if self.log_format:
with suppress(ValueError):
datetime.strptime(self.script_log_path.stem, self.log_format)
@@ -154,7 +154,7 @@ async def prepare(self):
self.script_config.get("Script", "LogTimeStart") - 1,
self.script_config.get("Script", "LogTimeEnd"),
)
- self.success_log = (
+ self.success_log: list[str] = (
[
_.strip()
for _ in self.script_config.get("Script", "SuccessLog").split("|")
@@ -162,7 +162,7 @@ async def prepare(self):
if self.script_config.get("Script", "SuccessLog")
else []
)
- self.error_log = [
+ self.error_log: list[str] = [
_.strip() for _ in self.script_config.get("Script", "ErrorLog").split("|")
]
self.general_log_monitor = LogMonitor(
@@ -217,12 +217,11 @@ async def main_task(self):
"脚本前任务",
)
- self.script_info.log = f"正在启动游戏 / 模拟器"
+ self.script_info.log = "正在启动游戏 / 模拟器"
# 启动游戏/模拟器
if self.game_manager is not None:
try:
if isinstance(self.game_manager, ProcessManager):
-
if self.script_config.get("Game", "Type") == "URL":
logger.info(
f"启动游戏: {self.game_process_name}, 参数{self.game_url}"
@@ -245,7 +244,7 @@ async def main_task(self):
await asyncio.sleep(
self.script_config.get("Game", "WaitTime")
)
- elif isinstance(self.game_manager, DeviceBase):
+ else:
logger.info(
f"启动模拟器: {self.script_config.get('Game', 'EmulatorIndex')}"
)
@@ -273,7 +272,6 @@ async def main_task(self):
self.script_info.log = "正在等待脚本日志文件生成"
if_get_file = False
while datetime.now() - t < timedelta(minutes=1):
-
for log_file in self.script_log_path.parent.iterdir():
if log_file.is_file():
with suppress(ValueError):
@@ -351,7 +349,6 @@ async def main_task(self):
async def handle_pre_script_error(
self, error_message: str, e: Exception | None = None
):
-
if e is None:
logger.error(f"用户: {self.cur_user_uid} - {error_message}")
await Config.send_websocket_message(
@@ -379,7 +376,6 @@ async def handle_pre_script_error(
)
async def update_config(self):
-
if self.script_config.get("Script", "ConfigPathMode") == "Folder":
shutil.copytree(
self.script_config_path,
@@ -414,7 +410,7 @@ async def kill_managed_process(self):
"Game", "Type"
) == "Client" and self.script_config.get("Game", "IfForceClose"):
await System.kill_process(self.game_path)
- elif isinstance(self.game_manager, DeviceBase):
+ else:
await self.game_manager.close(
self.script_config.get("Game", "EmulatorIndex"),
)
@@ -423,7 +419,7 @@ async def kill_managed_process(self):
async def set_general(self) -> None:
"""配置通用脚本运行参数"""
- logger.info(f"开始配置脚本运行参数: 自动代理")
+ logger.info("开始配置脚本运行参数: 自动代理")
# 配置前关闭可能未正常退出的脚本进程
await System.kill_process(self.script_exe_path)
@@ -444,7 +440,7 @@ async def set_general(self) -> None:
self.script_config_path,
)
- logger.info(f"脚本运行参数配置完成: 自动代理")
+ logger.info("脚本运行参数配置完成: 自动代理")
async def check_log(self, log_content: list[str], latest_time: datetime) -> None:
"""日志回调"""
@@ -481,7 +477,6 @@ async def check_log(self, log_content: list[str], latest_time: datetime) -> None
self.wait_event.set()
async def final_task(self):
-
if self.check_result != "Pass":
return
@@ -491,9 +486,8 @@ async def final_task(self):
del self.general_process_manager
del self.general_log_monitor
- user_logs_list = []
+ user_logs_list: list[Path] = []
for t, log_item in self.cur_user_item.log_record.items():
-
dt = t.replace(tzinfo=datetime.now().astimezone().tzinfo).astimezone(UTC4)
log_path = (
Path.cwd()
diff --git a/app/task/general/ScriptConfig.py b/app/task/general/ScriptConfig.py
index 1a6d5938..48a6c446 100644
--- a/app/task/general/ScriptConfig.py
+++ b/app/task/general/ScriptConfig.py
@@ -27,8 +27,8 @@
from app.core import Config
from app.models.task import TaskExecuteBase, ScriptItem
-from app.models.ConfigBase import MultipleConfig
-from app.models.config import GeneralConfig, GeneralUserConfig
+from app.core.config.base import MultipleConfig
+from app.models import GeneralConfig, GeneralUserConfig
from app.models.emulator import DeviceBase
from app.services import System
from app.utils import get_logger, ProcessManager
@@ -59,15 +59,14 @@ def __init__(
self.cur_user_item = self.script_info.user_list[self.script_info.current_index]
async def prepare(self):
-
self.general_process_manager = ProcessManager()
self.wait_event = asyncio.Event()
self.script_root_path = Path(self.script_config.get("Info", "RootPath"))
self.script_path = Path(self.script_config.get("Script", "ScriptPath"))
- arguments_list = []
- path_list = []
+ arguments_list: list[list[str]] = []
+ path_list: list[Path] = []
for argument in [
part.strip()
@@ -87,15 +86,18 @@ async def prepare(self):
)
arguments_list.append(shlex.split(arg_parts[-1]))
- self.script_arguments = arguments_list[0] if len(arguments_list) > 0 else []
- self.script_set_exe_path = (
+ self.script_arguments: list[str] = arguments_list[0] if arguments_list else []
+ self.script_set_exe_path: Path = (
path_list[1] if len(path_list) > 1 else self.script_path
)
- self.script_set_arguments = arguments_list[1] if len(arguments_list) > 1 else []
- self.script_config_path = Path(self.script_config.get("Script", "ConfigPath"))
+ self.script_set_arguments: list[str] = (
+ arguments_list[1] if len(arguments_list) > 1 else []
+ )
+ self.script_config_path: Path = Path(
+ self.script_config.get("Script", "ConfigPath")
+ )
async def main_task(self):
-
await self.prepare()
await self.set_general()
@@ -149,7 +151,6 @@ async def set_general(self) -> None:
logger.success(f"MAA运行参数配置完成: 设置脚本 {self.cur_user_item.user_id}")
async def final_task(self):
-
await self.general_process_manager.kill()
await System.kill_process(self.script_set_exe_path)
del self.general_process_manager
diff --git a/app/task/general/manager.py b/app/task/general/manager.py
index d02fa93a..1df1622e 100644
--- a/app/task/general/manager.py
+++ b/app/task/general/manager.py
@@ -27,8 +27,8 @@
from app.core import Config, EmulatorManager
from app.models.task import TaskExecuteBase, ScriptItem, UserItem
-from app.models.ConfigBase import MultipleConfig
-from app.models.config import GeneralConfig, GeneralUserConfig
+from app.core.config.base import MultipleConfig
+from app.models import GeneralConfig, GeneralUserConfig
from app.services import Notify
from app.utils import get_logger, ProcessManager
from app.utils.constants import TASK_MODE_ZH
@@ -191,7 +191,6 @@ async def prepare(self):
)
async def main_task(self):
-
self.check_result = await self.check()
if self.check_result != "Pass":
logger.error(f"未通过配置检查: {self.check_result}")
@@ -231,14 +230,13 @@ async def final_task(self):
if self.check_result != "Pass":
self.script_info.status = "异常"
- return self.check_result
+ return
logger.info("通用脚本任务已结束, 开始执行后续操作")
await Config.ScriptConfig[uuid.UUID(self.script_info.script_id)].unlock()
logger.success(f"已解锁脚本配置 {self.script_info.script_id}")
if self.task_info.mode == "AutoProxy":
-
await Config.ScriptConfig[
uuid.UUID(self.script_info.script_id)
].UserData.load(await self.user_config.toDict())
@@ -300,7 +298,6 @@ async def final_task(self):
self.script_info.status = "完成"
async def on_crash(self, e: Exception):
-
self.script_info.status = "异常"
logger.exception(f"通用脚本任务出现异常: {e}")
await Config.send_websocket_message(
diff --git a/app/task/general/tools/notify.py b/app/task/general/tools/notify.py
index 53ee4695..51bc47e4 100644
--- a/app/task/general/tools/notify.py
+++ b/app/task/general/tools/notify.py
@@ -22,13 +22,16 @@
from app.core import Config
from app.services import Notify
from app.utils import get_logger
-from app.models.config import GeneralUserConfig
+from typing import Any
logger = get_logger("通用通知工具")
async def push_notification(
- mode: str, title: str, message: dict, user_config: GeneralUserConfig | None
+ mode: str,
+ title: str,
+ message: dict[str, Any],
+ user_config: Any | None,
) -> None:
"""通过所有渠道推送通知"""
diff --git a/app/tools/skland.py b/app/tools/skland.py
index a1968284..8428deef 100644
--- a/app/tools/skland.py
+++ b/app/tools/skland.py
@@ -45,15 +45,23 @@
from Crypto.Cipher import PKCS1_v1_5, AES, DES
from Crypto.Util.Padding import pad
-from typing import Dict, Any
+from typing import Dict, Any, cast
-from app.core import Config
from app.utils.constants import SKLAND_SM_CONFIG, BROWSER_ENV, DES_RULE
from app.utils.logger import get_logger
logger = get_logger("森空岛签到任务")
+def get_proxy(proxy: str | None = None) -> Any:
+ if proxy is not None:
+ return proxy
+
+ from app.core import Config
+
+ return Config.proxy
+
+
def md5_hash(data: str) -> str:
"""MD5哈希"""
return hashlib.md5(data.encode()).hexdigest()
@@ -73,14 +81,14 @@ def get_sm_id() -> str:
def get_tn(obj: Dict[str, Any]) -> str:
"""计算tn值"""
sorted_keys = sorted(obj.keys())
- result_list = []
+ result_list: list[str] = []
for key in sorted_keys:
v = obj[key]
if isinstance(v, (int, float)):
v = str(int(v * 10000))
elif isinstance(v, dict):
- v = get_tn(v)
+ v = get_tn(cast(Dict[str, Any], v))
else:
v = str(v)
result_list.append(v)
@@ -111,7 +119,7 @@ def encrypt_des(message: str, key: str) -> str:
message_bytes = str(message).encode()
while len(message_bytes) % 8 != 0:
message_bytes += b"\0"
- cipher = DES.new(key_bytes, DES.MODE_ECB)
+ cipher = cast(Any, DES).new(key_bytes, DES.MODE_ECB)
encrypted = cipher.encrypt(message_bytes)
return base64.b64encode(encrypted).decode()
@@ -130,7 +138,7 @@ def encrypt_aes(message: str, key: str) -> str:
"""AES CBC加密"""
iv = b"0102030405060708"
key_bytes = key.encode()[:16].ljust(16, b"\0")
- cipher = AES.new(key_bytes, AES.MODE_CBC, iv)
+ cipher = cast(Any, AES).new(key_bytes, AES.MODE_CBC, iv)
padded_data = pad(message.encode(), AES.block_size)
encrypted = cipher.encrypt(padded_data)
return encrypted.hex()
@@ -140,7 +148,7 @@ def encrypt_object_by_des_rules(
obj: Dict[str, Any], rules: Dict[str, Dict[str, Any]]
) -> Dict[str, Any]:
"""根据DES规则加密对象"""
- result = {}
+ result: Dict[str, Any] = {}
for key, value in obj.items():
if key in rules:
@@ -204,7 +212,7 @@ async def get_device_id(proxy: str | None = None) -> str:
devices_info_url = f"{SKLAND_SM_CONFIG['protocol']}://{SKLAND_SM_CONFIG['apiHost']}{SKLAND_SM_CONFIG['apiPath']}"
- async with httpx.AsyncClient(proxy=Config.proxy) as client:
+ async with httpx.AsyncClient(proxy=get_proxy(proxy)) as client:
response = await client.post(
devices_info_url,
json=body,
@@ -238,7 +246,7 @@ async def skland_sign_in(
token: str,
app_code: str = "arknights",
proxy: str | None = None,
-) -> dict:
+) -> dict[str, Any]:
"""森空岛签到"""
grant_code_url = "https://as.hypergryph.com/user/oauth2/v2/grant"
@@ -263,8 +271,11 @@ async def skland_sign_in(
}
def generate_signature(
- token_for_sign: str, path, body_or_query, custom_header=None
- ):
+ token_for_sign: str,
+ path: str,
+ body_or_query: str,
+ custom_header: dict[str, str] | None = None,
+ ) -> tuple[str, dict[str, str]]:
"""生成请求签名"""
t = str(int(time.time() * 1000 - 2000))[:-3]
token_bytes = token_for_sign.encode("utf-8")
@@ -276,7 +287,13 @@ def generate_signature(
md5_hash_value = hashlib.md5(hex_s.encode("utf-8")).hexdigest()
return md5_hash_value, header_ca
- async def get_sign_header(url: str, method, body, old_header, sign_token):
+ async def get_sign_header(
+ url: str,
+ method: str,
+ body: dict[str, Any] | None,
+ old_header: dict[str, str],
+ sign_token: str,
+ ) -> dict[str, str]:
"""获取带签名的请求头"""
h = json.loads(json.dumps(old_header))
p = parse.urlparse(url)
@@ -305,7 +322,7 @@ async def get_sign_header(url: str, method, body, old_header, sign_token):
return h
- def copy_header(cred, token=None):
+ def copy_header(cred: str, token: str | None = None) -> dict[str, str]:
"""复制请求头并添加cred和token"""
v = json.loads(json.dumps(header))
v["cred"] = cred
@@ -313,7 +330,7 @@ def copy_header(cred, token=None):
v["token"] = token
return v
- async def login_by_token(token_code):
+ async def login_by_token(token_code: str) -> tuple[str, str]:
"""使用token一步步拿到cred和sign_token"""
try:
t = json.loads(token_code)
@@ -323,7 +340,7 @@ async def login_by_token(token_code):
grant_code = await get_grant_code(token_code)
return await get_cred(grant_code)
- async def get_cred(grant):
+ async def get_cred(grant: str) -> tuple[str, str]:
"""通过grant code获取cred和sign_token"""
device_id = await get_cached_device_id(proxy)
@@ -338,7 +355,7 @@ async def get_cred(grant):
"vName": "1.0.0",
}
- async with httpx.AsyncClient(proxy=Config.proxy) as client:
+ async with httpx.AsyncClient(proxy=get_proxy(proxy)) as client:
response = await client.post(
cred_code_url,
json={"code": grant, "kind": 1},
@@ -351,9 +368,9 @@ async def get_cred(grant):
cred = rsp["data"]["cred"]
return cred, sign_token
- async def get_grant_code(token_value):
+ async def get_grant_code(token_value: str) -> str:
"""通过token获取grant code"""
- async with httpx.AsyncClient(proxy=Config.proxy) as client:
+ async with httpx.AsyncClient(proxy=get_proxy(proxy)) as client:
response = await client.post(
grant_code_url,
json={"appCode": "4ca99fa6b56cc2ba", "token": token_value, "type": 0},
@@ -366,10 +383,10 @@ async def get_grant_code(token_value):
)
return rsp["data"]["code"]
- async def get_binding_list(cred, sign_token):
+ async def get_binding_list(cred: str, sign_token: str) -> list[dict[str, Any]]:
"""查询已绑定的角色列表"""
- v = []
- async with httpx.AsyncClient(proxy=Config.proxy) as client:
+ v: list[dict[str, Any]] = []
+ async with httpx.AsyncClient(proxy=get_proxy(proxy)) as client:
response = await client.get(
binding_url,
headers=await get_sign_header(
@@ -392,12 +409,17 @@ async def get_binding_list(cred, sign_token):
v.extend(item.get("bindingList"))
return v
- async def check_attendance_today(cred, sign_token, uid, game_id) -> bool:
+ async def check_attendance_today(
+ cred: str,
+ sign_token: str,
+ uid: str,
+ game_id: str | int,
+ ) -> bool:
"""检查今天是否已经签到"""
query_url = f"{arknights_sign_url}?uid={uid}&gameId={game_id}"
try:
- async with httpx.AsyncClient(proxy=Config.proxy) as client:
+ async with httpx.AsyncClient(proxy=get_proxy(proxy)) as client:
response = await client.get(
query_url,
headers=await get_sign_header(
@@ -427,10 +449,12 @@ async def check_attendance_today(cred, sign_token, uid, game_id) -> bool:
logger.warning(f"检查签到状态异常: {e}")
return False
- async def sign_for_arknights(cred, sign_token) -> dict:
+ async def sign_for_arknights(cred: str, sign_token: str) -> dict[str, Any]:
"""方舟签到"""
characters = await get_binding_list(cred, sign_token)
- result = {"成功": [], "重复": [], "失败": [], "总计": len(characters)}
+ success_list: list[str] = []
+ duplicate_list: list[str] = []
+ failed_list: list[str] = []
for character in characters:
character_name = (
@@ -439,8 +463,14 @@ async def sign_for_arknights(cred, sign_token) -> dict:
uid = character.get("uid")
game_id = character.get("channelMasterId")
+ if not isinstance(uid, str) or not isinstance(game_id, (str, int)):
+ failed_list.append(character_name)
+ logger.error(f"{character_name} 缺少有效 uid 或 gameId,跳过签到")
+ await asyncio.sleep(1)
+ continue
+
if await check_attendance_today(cred, sign_token, uid, game_id):
- result["重复"].append(character_name)
+ duplicate_list.append(character_name)
logger.info(f"{character_name} 今天已经签到过了")
await asyncio.sleep(1)
continue
@@ -451,7 +481,7 @@ async def sign_for_arknights(cred, sign_token) -> dict:
}
try:
- async with httpx.AsyncClient(proxy=Config.proxy) as client:
+ async with httpx.AsyncClient(proxy=get_proxy(proxy)) as client:
sign_headers = await get_sign_header(
arknights_sign_url,
"post",
@@ -468,24 +498,31 @@ async def sign_for_arknights(cred, sign_token) -> dict:
if rsp["code"] != 0:
if rsp.get("message") == "请勿重复签到!":
- result["重复"].append(character_name)
+ duplicate_list.append(character_name)
logger.info(f"{character_name} 重复签到")
else:
- result["失败"].append(character_name)
+ failed_list.append(character_name)
logger.error(f"{character_name} 签到失败: {rsp.get('message')}")
else:
- result["成功"].append(character_name)
+ success_list.append(character_name)
logger.info(f"{character_name} 签到成功")
except Exception as e:
- result["失败"].append(character_name)
+ failed_list.append(character_name)
logger.error(f"{character_name} 签到异常: {e}")
await asyncio.sleep(3)
- return result
+ return {
+ "成功": success_list,
+ "重复": duplicate_list,
+ "失败": failed_list,
+ "总计": len(characters),
+ }
- async def do_sign_for_endfield(cred, sign_token, role: dict):
+ async def do_sign_for_endfield(
+ cred: str, sign_token: str, role: dict[str, Any]
+ ) -> dict[str, Any]:
headers = await get_sign_header(
endfield_sign_url,
"post",
@@ -502,20 +539,26 @@ async def do_sign_for_endfield(cred, sign_token, role: dict):
}
)
- async with httpx.AsyncClient(proxy=Config.proxy) as client:
+ async with httpx.AsyncClient(proxy=get_proxy(proxy)) as client:
response = await client.post(endfield_sign_url, headers=headers)
return response.json()
- async def sign_for_endfield(cred, sign_token) -> dict:
+ async def sign_for_endfield(cred: str, sign_token: str) -> dict[str, Any]:
"""终末地签到"""
characters = await get_binding_list(cred, sign_token)
- result = {"成功": [], "重复": [], "失败": [], "总计": 0}
+ success_list: list[str] = []
+ duplicate_list: list[str] = []
+ failed_list: list[str] = []
+ total_count = 0
for character in characters:
- roles = character.get("roles") or []
+ roles_value = character.get("roles")
game_name = character.get("gameName")
channel_name = character.get("channelName")
- result["总计"] += len(roles)
+ if not isinstance(roles_value, list):
+ continue
+ roles = cast(list[dict[str, Any]], roles_value)
+ total_count += len(roles)
for role in roles:
nickname = str(role.get("nickname") or "").strip()
@@ -529,15 +572,29 @@ async def sign_for_endfield(cred, sign_token) -> dict:
"请勿重复签到" in message
or "Please do not sign in again!" in message
):
- result["重复"].append(character_name)
+ duplicate_list.append(character_name)
logger.info(f"{character_name} 重复签到")
else:
- result["失败"].append(character_name)
+ failed_list.append(character_name)
logger.error(f"{character_name} 签到失败: {message}")
else:
- award_ids = rsp.get("data", {}).get("awardIds", [])
- resource_map = rsp.get("data", {}).get("resourceInfoMap", {})
- awards = []
+ data = rsp.get("data")
+ data_dict = (
+ cast(dict[str, Any], data) if isinstance(data, dict) else {}
+ )
+ award_ids_value = data_dict.get("awardIds")
+ resource_map_value = data_dict.get("resourceInfoMap")
+ award_ids = (
+ cast(list[dict[str, Any]], award_ids_value)
+ if isinstance(award_ids_value, list)
+ else []
+ )
+ resource_map = (
+ cast(dict[str, dict[str, Any]], resource_map_value)
+ if isinstance(resource_map_value, dict)
+ else {}
+ )
+ awards: list[str] = []
for award in award_ids:
award_id = award.get("id")
if award_id and award_id in resource_map:
@@ -549,15 +606,20 @@ async def sign_for_endfield(cred, sign_token) -> dict:
logger.info(
f"[{game_name}] {character_name} 签到成功: {'、'.join(awards)}"
)
- result["成功"].append(character_name)
+ success_list.append(character_name)
logger.info(f"{character_name} 签到成功")
except Exception as e:
- result["失败"].append(character_name)
+ failed_list.append(character_name)
logger.error(f"{character_name} 签到异常: {e}")
await asyncio.sleep(3)
- return result
+ return {
+ "成功": success_list,
+ "重复": duplicate_list,
+ "失败": failed_list,
+ "总计": total_count,
+ }
try:
cred, sign_token = await login_by_token(token)
diff --git a/app/utils/ImageUtils.py b/app/utils/ImageUtils.py
index 4778b9b9..5d0ecc21 100644
--- a/app/utils/ImageUtils.py
+++ b/app/utils/ImageUtils.py
@@ -23,31 +23,32 @@
import base64
import hashlib
from pathlib import Path
+from typing import Union
from PIL import Image
class ImageUtils:
@staticmethod
- def get_base64_from_file(image_path):
+ def get_base64_from_file(image_path: Union[str, Path]) -> str:
"""从本地文件读取并返回base64编码字符串"""
with open(image_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
@staticmethod
- def calculate_md5_from_file(image_path):
+ def calculate_md5_from_file(image_path: Union[str, Path]) -> str:
"""从本地文件读取并返回md5值(hex字符串)"""
with open(image_path, "rb") as f:
return hashlib.md5(f.read()).hexdigest()
@staticmethod
- def calculate_md5_from_base64(base64_content):
+ def calculate_md5_from_base64(base64_content: str) -> str:
"""从base64字符串计算md5"""
image_data = base64.b64decode(base64_content)
return hashlib.md5(image_data).hexdigest()
@staticmethod
- def compress_image_if_needed(image_path: Path, max_size_mb=2) -> Path:
+ def compress_image_if_needed(image_path: Path, max_size_mb: float = 2) -> Path:
"""
如果图片大于max_size_mb, 则压缩并覆盖原文件, 返回原始路径(Path对象)
"""
diff --git a/app/utils/LogMonitor.py b/app/utils/LogMonitor.py
index 72ac774f..683f9064 100644
--- a/app/utils/LogMonitor.py
+++ b/app/utils/LogMonitor.py
@@ -21,11 +21,12 @@
import asyncio
import aiofiles
+import os
from contextlib import suppress
from datetime import datetime, timedelta, date
from copy import copy
from pathlib import Path
-from typing import Callable, Literal, Awaitable
+from typing import Callable, Optional, Awaitable, Any, cast
from .constants import TIME_FIELDS, ANSI_ESCAPE_RE
from .logger import get_logger
@@ -40,14 +41,14 @@ def strptime(date_string: str, format: str, default_date: datetime) -> datetime:
date = datetime.strptime(date_string, format)
# 构建参数字典
- datetime_kwargs = {}
+ datetime_kwargs: dict[str, int] = {}
for format_code, field_name in TIME_FIELDS.items():
if format_code in format:
- datetime_kwargs[field_name] = getattr(date, field_name)
+ datetime_kwargs[field_name] = int(getattr(date, field_name))
else:
- datetime_kwargs[field_name] = getattr(default_date, field_name)
+ datetime_kwargs[field_name] = int(getattr(default_date, field_name))
- return datetime(**datetime_kwargs)
+ return datetime(**cast(dict[str, Any], datetime_kwargs))
class LogMonitor:
@@ -68,7 +69,8 @@ def __init__(
self.last_callback_time: datetime = datetime.now()
self.log_contents: list[str] = []
self.latest_time = datetime.now()
- self.task: asyncio.Task | None = None
+ self.last_log: str = ""
+ self.task: Optional[asyncio.Task[None]] = None
async def monitor_file(
self,
@@ -86,10 +88,10 @@ async def monitor_file(
warned_mtime_date: date | None = None
if_log_start = False
offset = 0
- log_contents = []
+ log_contents: list[str] = []
+ log_stat: os.stat_result | None = None
while True:
-
# 检查文件是否仍然存在
if not log_file_path.exists():
logger.warning(f"日志文件不存在: {log_file_path}")
@@ -112,9 +114,8 @@ async def monitor_file(
# 尝试读取文件
try:
-
# 发生日志轮转或文件被替换,重置监控状态并加载被轮换的旧日志
- if (
+ if log_stat is not None and (
log_stat.st_ino != log_file_path.stat().st_ino
or log_stat.st_size > log_file_path.stat().st_size
):
@@ -141,7 +142,6 @@ async def monitor_file(
log_stat = log_file_path.stat()
if log_stat.st_size <= offset:
-
# 日志无变化超时调用回调
if datetime.now() - self.last_callback_time > timedelta(minutes=1):
await self.do_callback()
@@ -181,38 +181,27 @@ async def monitor_file(
await asyncio.sleep(1)
- async def monitor_process(
- self, process: asyncio.subprocess.Process, stream: Literal["stdout", "stderr"]
- ):
+ async def monitor_process(self, process: asyncio.subprocess.Process):
"""监控进程日志"""
logger.info(f"开始监控进程日志: {process.pid}")
await self.update_latest_timestamp("", if_init=True)
- if hasattr(process, stream):
- process_stream = getattr(process, stream)
- if not isinstance(process_stream, asyncio.StreamReader):
- raise ValueError(f"进程没有可用的{stream}流")
- else:
- raise ValueError(f"无效的流类型: {stream}")
+ if process.stdout is None:
+ raise ValueError("进程没有标准输出")
self.log_contents = []
while True:
-
try:
- bline = await asyncio.wait_for(process_stream.readline(), timeout=60)
+ bline = await asyncio.wait_for(process.stdout.readline(), timeout=60)
except asyncio.TimeoutError:
# 超时后调用回调函数
await self.do_callback()
continue
line = ANSI_ESCAPE_RE.sub("", decode_bytes(bline))
-
- if not line.strip():
- continue
-
self.log_contents.append(line)
await self.update_latest_timestamp(line)
@@ -236,7 +225,6 @@ async def do_callback(self):
logger.error(f"回调函数执行失败: {e}")
async def update_latest_timestamp(self, log: str, if_init: bool = False) -> None:
-
if if_init:
self.last_log = log
self.latest_time = datetime.now()
@@ -253,7 +241,6 @@ async def update_latest_timestamp(self, log: str, if_init: bool = False) -> None
self.time_format,
self.last_callback_time,
)
- logger.debug(f"日志时间戳更新: {self.latest_time}")
self.last_log = log_text
async def start_monitor_file(
@@ -281,23 +268,18 @@ async def start_monitor_file(
)
logger.info(f"日志文件监控已启动: {log_file_path}")
- async def start_monitor_process(
- self,
- process: asyncio.subprocess.Process,
- stream: Literal["stdout", "stderr"] = "stdout",
- ) -> None:
+ async def start_monitor_process(self, process: asyncio.subprocess.Process) -> None:
"""
开始监控进程日志
Args:
process (asyncio.subprocess.Process): 进程对象
- stream (Literal["stdout", "stderr"]): 流对象
"""
if self.task is not None and not self.task.done():
await self.stop()
- self.task = asyncio.create_task(self.monitor_process(process, stream))
+ self.task = asyncio.create_task(self.monitor_process(process))
logger.info(f"进程日志监控已启动: {process.pid}")
async def stop(self):
diff --git a/app/utils/OCR/OCRtool.py b/app/utils/OCR/OCRtool.py
index 8d461858..e0d78c28 100644
--- a/app/utils/OCR/OCRtool.py
+++ b/app/utils/OCR/OCRtool.py
@@ -5,23 +5,17 @@
from PIL import Image
import win32con
import win32gui
-from rapidocr_onnxruntime import RapidOCR
+from importlib import import_module
from mss import mss
import subprocess
from pathlib import Path
+from typing import Any, cast
from app.utils import get_logger
from app.utils.exception import (
WindowsNotFoundException,
WindowsNotFocusException,
OCRNotFoundTitleException,
- ADBFileNotFoundException,
- ADBCommandFailedException,
- ADBDeviceNotFoundException,
- ADBConnectionFailedException,
- ADBTimeoutException,
- ADBScreenshotException,
- ImageProcessException
)
# OCR入门指南!
@@ -44,6 +38,9 @@
# 你现在已经学会了OCR识别的基础知识了!快来试试吧!
logger = get_logger("OCR模块")
+RapidOCR = cast(Any, import_module("rapidocr_onnxruntime")).RapidOCR
+
+
class OCRTool:
# 默认宽高比 16:9,用于图像预处理
aspect_ratio_width = 16
@@ -61,7 +58,7 @@ class OCRTool:
# 全局窗口标题,用于避免在方法调用时反复传入
title: str | None = None
- def __init__(self, width=16, height=9, title: str | None = None):
+ def __init__(self, width: int = 16, height: int = 9, title: str | None = None):
"""
初始化 OCR 引擎
@@ -105,13 +102,16 @@ def get_system_dpi_scaling(self) -> float:
try:
# 启用高 DPI 感知,避免始终返回 96
try:
- ctypes.windll.shcore.SetProcessDpiAwareness(2) # type: ignore[attr-defined]
+ shcore = getattr(ctypes.windll, "shcore", None)
+ set_dpi_awareness = getattr(shcore, "SetProcessDpiAwareness", None)
+ if callable(set_dpi_awareness):
+ set_dpi_awareness(2)
except (AttributeError, OSError):
# Windows 8.1 以下系统没有该函数
pass
# 获取主显示器 DC
- hdc = win32gui.GetDC(0)
+ hdc = cast(int, cast(Any, win32gui).GetDC(0))
try:
# 使用 ctypes 直接调用 GetDeviceCaps
# LOGPIXELSX = 88
@@ -127,10 +127,11 @@ def get_system_dpi_scaling(self) -> float:
logger.warning(f"获取系统 DPI 失败: {e},使用默认缩放比例 1.5")
return 1.5
-
# ========== 截图部分 ==========
@classmethod
- def get_screenshot_region(cls, title: str, should_preprocess: bool = True) -> tuple[int, int, int, int]:
+ def get_screenshot_region(
+ cls, title: str, should_preprocess: bool = True
+ ) -> tuple[int, int, int, int]:
"""
根据给定的窗口标题获取截图区域。
@@ -199,12 +200,18 @@ def get_screenshot_region(cls, title: str, should_preprocess: bool = True) -> tu
# 扣除边框后的实际可用宽高
available_width = width - left_offset * 2 # 左右两侧都要扣除
- available_height = height - top_offset - int(8 * cls.zoom) # 顶部标题栏 + 底部边框
+ available_height = (
+ height - top_offset - int(8 * cls.zoom)
+ ) # 顶部标题栏 + 底部边框
# 计算截图区域的宽度,将宽度调整为 相应 的倍数
- cls.area_width = available_width // cls.aspect_ratio_width * cls.aspect_ratio_width
+ cls.area_width = (
+ available_width // cls.aspect_ratio_width * cls.aspect_ratio_width
+ )
# 计算截图区域的高度,将高度调整为 相应 的倍数
- cls.area_height = available_height // cls.aspect_ratio_height * cls.aspect_ratio_height
+ cls.area_height = (
+ available_height // cls.aspect_ratio_height * cls.aspect_ratio_height
+ )
# 根据缩放比例调整顶部坐标,如果顶部坐标不为 0 则加上偏移量
cls.area_top = top + top_offset
# 根据缩放比例调整左侧坐标,如果左侧坐标不为 0 则加上偏移量
@@ -232,7 +239,7 @@ def _verify_window_activated(cls, hwnd: int, title: str) -> None:
return
# 重试一次
- logger.warning(f"窗口未激活,再次尝试强制激活...")
+ logger.warning("窗口未激活,再次尝试强制激活...")
cls._force_activate_window(hwnd)
time.sleep(0.2)
@@ -268,7 +275,9 @@ def _force_activate_window(hwnd: int) -> None:
# 获取前台窗口的线程ID和进程ID
if foreground_hwnd:
- foreground_thread_id, _ = win32process.GetWindowThreadProcessId(foreground_hwnd)
+ foreground_thread_id, _ = win32process.GetWindowThreadProcessId(
+ foreground_hwnd
+ )
else:
foreground_thread_id = 0
@@ -280,9 +289,13 @@ def _force_activate_window(hwnd: int) -> None:
if foreground_thread_id != target_thread_id and foreground_thread_id != 0:
try:
# 附着到前台窗口的输入线程
- win32process.AttachThreadInput(foreground_thread_id, target_thread_id, True)
+ cast(Any, win32process).AttachThreadInput(
+ foreground_thread_id, target_thread_id, True
+ )
attached = True
- logger.debug(f"已附着输入线程: {foreground_thread_id} -> {target_thread_id}")
+ logger.debug(
+ f"已附着输入线程: {foreground_thread_id} -> {target_thread_id}"
+ )
except Exception as e:
logger.warning(f"附着输入线程失败: {e}")
@@ -311,7 +324,9 @@ def _force_activate_window(hwnd: int) -> None:
# 分离输入线程(必须在 finally 中执行,确保一定会分离)
if attached:
try:
- win32process.AttachThreadInput(foreground_thread_id, target_thread_id, False)
+ cast(Any, win32process).AttachThreadInput(
+ foreground_thread_id, target_thread_id, False
+ )
logger.debug("已分离输入线程")
except Exception as e:
logger.warning(f"分离输入线程失败: {e}")
@@ -320,7 +335,6 @@ def _force_activate_window(hwnd: int) -> None:
logger.error(f"强制激活窗口出错: {e}")
# 即使出错也不抛出异常,因为可能已经部分成功
-
@staticmethod
def _find_window_with_win32gui(title: str) -> int | None:
"""
@@ -332,9 +346,9 @@ def _find_window_with_win32gui(title: str) -> int | None:
Returns:
int | None: 找到的窗口句柄,未找到返回 None
"""
- found_windows = []
+ found_windows: list[tuple[int, str]] = []
- def enum_callback(hwnd, results):
+ def enum_callback(hwnd: int, results: list[tuple[int, str]]) -> None:
if win32gui.IsWindowVisible(hwnd):
window_title = win32gui.GetWindowText(hwnd)
if title.lower() in window_title.lower():
@@ -355,7 +369,12 @@ def enum_callback(hwnd, results):
return None
@classmethod
- def get_screenshot_with_pc(cls, title: str, should_preprocess: bool = True, region: tuple[int, int, int, int] | None = None) -> Image.Image:
+ def get_screenshot_with_pc(
+ cls,
+ title: str,
+ should_preprocess: bool = True,
+ region: tuple[int, int, int, int] | None = None,
+ ) -> Image.Image:
"""
根据指定的窗口标题和区域获取截图,并对截图进行尺寸调整。
@@ -379,7 +398,9 @@ def get_screenshot_with_pc(cls, title: str, should_preprocess: bool = True, regi
return cls._image_resize(pillow_img)
@classmethod
- def get_screenshot_with_adb(cls, adb_path: str, serial: str, use_screencap: bool = True) -> Image.Image:
+ def get_screenshot_with_adb(
+ cls, adb_path: str, serial: str, use_screencap: bool = True
+ ) -> Image.Image:
"""
实验性,未测试
通过 ADB 端口获取设备截图。
@@ -434,23 +455,18 @@ def _ensure_adb_device_connected(adb_path: str, serial: str) -> None:
# 检查设备是否已连接
try:
cmd = [adb_path, "devices"]
- result = subprocess.run(
- cmd,
- capture_output=True,
- timeout=10,
- check=False
- )
+ result = subprocess.run(cmd, capture_output=True, timeout=10, check=False)
if result.returncode != 0:
- raise RuntimeError(f"无法执行 adb devices 命令")
+ raise RuntimeError("无法执行 adb devices 命令")
- devices_output = result.stdout.decode('utf-8', errors='ignore')
+ devices_output = result.stdout.decode("utf-8", errors="ignore")
logger.debug(f"ADB devices 输出:\n{devices_output}")
# 检查设备是否在列表中且状态为 device
is_connected = False
- for line in devices_output.split('\n'):
- if serial in line and 'device' in line and 'offline' not in line:
+ for line in devices_output.split("\n"):
+ if serial in line and "device" in line and "offline" not in line:
is_connected = True
break
@@ -459,35 +475,37 @@ def _ensure_adb_device_connected(adb_path: str, serial: str) -> None:
return
# 如果是网络设备格式(IP:Port),尝试连接
- if ':' in serial:
+ if ":" in serial:
logger.info(f"设备 {serial} 未连接,尝试执行 adb connect...")
connect_cmd = [adb_path, "connect", serial]
connect_result = subprocess.run(
- connect_cmd,
- capture_output=True,
- timeout=10,
- check=False
+ connect_cmd, capture_output=True, timeout=10, check=False
)
if connect_result.returncode != 0:
- error_msg = connect_result.stderr.decode('utf-8', errors='ignore')
+ error_msg = connect_result.stderr.decode("utf-8", errors="ignore")
raise RuntimeError(f"adb connect 失败: {error_msg}")
- connect_output = connect_result.stdout.decode('utf-8', errors='ignore')
+ connect_output = connect_result.stdout.decode("utf-8", errors="ignore")
logger.info(f"adb connect 输出: {connect_output}")
# 再次检查是否连接成功
- if 'connected' in connect_output.lower() or 'already connected' in connect_output.lower():
+ if (
+ "connected" in connect_output.lower()
+ or "already connected" in connect_output.lower()
+ ):
logger.info(f"设备 {serial} 连接成功")
return
else:
raise RuntimeError(f"连接设备失败: {connect_output}")
else:
# USB 设备但未找到
- raise RuntimeError(f"设备 {serial} 未找到,请确保设备已连接并启用 USB 调试")
+ raise RuntimeError(
+ f"设备 {serial} 未找到,请确保设备已连接并启用 USB 调试"
+ )
except subprocess.TimeoutExpired:
- raise RuntimeError(f"ADB 命令超时")
+ raise RuntimeError("ADB 命令超时")
except Exception as e:
logger.error(f"检查/连接设备失败: {e}")
raise
@@ -510,16 +528,17 @@ def _adb_screencap_png(adb_path: str, serial: str) -> Image.Image:
try:
# 执行 adb shell screencap -p 并直接捕获输出
cmd = [adb_path, "-s", serial, "shell", "screencap", "-p"]
- result = subprocess.run(
- cmd,
- capture_output=True,
- timeout=30,
- check=False
- )
+ result = subprocess.run(cmd, capture_output=True, timeout=30, check=False)
if result.returncode != 0:
- error_msg = result.stderr.decode('utf-8', errors='ignore') if result.stderr else "未知错误"
- raise RuntimeError(f"ADB screencap 命令失败 (返回码: {result.returncode}): {error_msg}")
+ error_msg = (
+ result.stderr.decode("utf-8", errors="ignore")
+ if result.stderr
+ else "未知错误"
+ )
+ raise RuntimeError(
+ f"ADB screencap 命令失败 (返回码: {result.returncode}): {error_msg}"
+ )
# 从二进制数据创建图像
image_data = result.stdout
@@ -529,15 +548,18 @@ def _adb_screencap_png(adb_path: str, serial: str) -> Image.Image:
# Windows 环境下需要处理换行符问题
# screencap -p 在 Windows 上会将 \n (0x0A) 转换为 \r\n (0x0D 0x0A)
# 这会破坏 PNG 文件格式,需要将 \r\n 替换回 \n
- image_data = image_data.replace(b'\r\n', b'\n')
+ image_data = image_data.replace(b"\r\n", b"\n")
logger.debug(f"ADB screencap 返回数据大小: {len(image_data)} 字节")
# 使用 PIL 从字节流加载图像
from io import BytesIO
+
try:
pillow_img = Image.open(BytesIO(image_data))
- logger.info(f"成功通过 ADB screencap 获取截图 (设备: {serial}, 尺寸: {pillow_img.size})")
+ logger.info(
+ f"成功通过 ADB screencap 获取截图 (设备: {serial}, 尺寸: {pillow_img.size})"
+ )
return pillow_img
except Exception as img_error:
# 如果 PNG 方法失败,记录详细信息并尝试降级到 raw 方法
@@ -576,16 +598,17 @@ def _adb_screencap_raw(adb_path: str, serial: str) -> Image.Image:
try:
# 执行 adb shell screencap(原始格式)
cmd = [adb_path, "-s", serial, "shell", "screencap"]
- result = subprocess.run(
- cmd,
- capture_output=True,
- timeout=30,
- check=False
- )
+ result = subprocess.run(cmd, capture_output=True, timeout=30, check=False)
if result.returncode != 0:
- error_msg = result.stderr.decode('utf-8', errors='ignore') if result.stderr else "未知错误"
- raise RuntimeError(f"ADB screencap raw 命令失败 (返回码: {result.returncode}): {error_msg}")
+ error_msg = (
+ result.stderr.decode("utf-8", errors="ignore")
+ if result.stderr
+ else "未知错误"
+ )
+ raise RuntimeError(
+ f"ADB screencap raw 命令失败 (返回码: {result.returncode}): {error_msg}"
+ )
raw_data = result.stdout
if len(raw_data) < 12:
@@ -593,9 +616,11 @@ def _adb_screencap_raw(adb_path: str, serial: str) -> Image.Image:
# 解析头部信息(前 12 字节)
# 格式: width (4 bytes), height (4 bytes), format (4 bytes)
- width, height, pixel_format = struct.unpack(' Image.Image:
# 计算预期的数据大小(RGBA 每像素 4 字节)
expected_size = width * height * 4 + 12
if len(raw_data) < expected_size:
- raise RuntimeError(f"ADB screencap raw 数据不完整 (预期: {expected_size}, 实际: {len(raw_data)})")
+ raise RuntimeError(
+ f"ADB screencap raw 数据不完整 (预期: {expected_size}, 实际: {len(raw_data)})"
+ )
# 提取像素数据(跳过前 12 字节的头部)
- pixel_data = raw_data[12:12 + width * height * 4]
+ pixel_data = raw_data[12 : 12 + width * height * 4]
# 创建 PIL 图像(RGBA 格式)
- pillow_img = Image.frombytes('RGBA', (width, height), pixel_data)
+ pillow_img = Image.frombytes("RGBA", (width, height), pixel_data)
# 转换为 RGB(去除 Alpha 通道)
- pillow_img = pillow_img.convert('RGB')
+ pillow_img = pillow_img.convert("RGB")
- logger.info(f"成功通过 ADB screencap raw 获取截图 (设备: {serial}, 尺寸: {pillow_img.size})")
+ logger.info(
+ f"成功通过 ADB screencap raw 获取截图 (设备: {serial}, 尺寸: {pillow_img.size})"
+ )
return pillow_img
except subprocess.TimeoutExpired:
@@ -641,12 +670,7 @@ def _capture_screenshot_mss(region: tuple[int, int, int, int]) -> Image.Image:
left, top, width, height = region
# MSS 使用 (left, top, right, bottom) 格式的 monitor 字典
- monitor = {
- "left": left,
- "top": top,
- "width": width,
- "height": height
- }
+ monitor = {"left": left, "top": top, "width": width, "height": height}
try:
with mss() as sct:
@@ -654,7 +678,9 @@ def _capture_screenshot_mss(region: tuple[int, int, int, int]) -> Image.Image:
screenshot = sct.grab(monitor)
# 转换为 Pillow 图像
pillow_img = Image.frombytes("RGB", screenshot.size, screenshot.rgb)
- logger.debug(f"使用 MSS 成功截图: 区域={region}, 尺寸={pillow_img.size}")
+ logger.debug(
+ f"使用 MSS 成功截图: 区域={region}, 尺寸={pillow_img.size}"
+ )
return pillow_img
except Exception as e:
logger.error(f"MSS 截图失败: {e},尝试使用 pyautogui 作为备用方案")
@@ -665,7 +691,6 @@ def _capture_screenshot_mss(region: tuple[int, int, int, int]) -> Image.Image:
logger.error(f"pyautogui 备用截图也失败: {fallback_error}")
raise
-
@classmethod
def _image_resize(cls, pillow_image: Image.Image) -> Image.Image:
"""
@@ -678,18 +703,23 @@ def _image_resize(cls, pillow_image: Image.Image) -> Image.Image:
Returns:
Image.Image: 调整尺寸后的 Pillow 图像对象。
"""
- if pillow_image.width % cls.area_width==0 and pillow_image.height % cls.area_height==0:
+ if (
+ pillow_image.width % cls.area_width == 0
+ and pillow_image.height % cls.area_height == 0
+ ):
return pillow_image
cls.screenshot_proportion = 1920 / pillow_image.width
resized_image = pillow_image.resize(
- (int(pillow_image.width * cls.screenshot_proportion),
- int(pillow_image.height * cls.screenshot_proportion)),
- Image.Resampling.BICUBIC)
+ (
+ int(pillow_image.width * cls.screenshot_proportion),
+ int(pillow_image.height * cls.screenshot_proportion),
+ ),
+ Image.Resampling.BICUBIC,
+ )
return resized_image
-
@classmethod
- def _location_calculator(cls, x, y):
+ def _location_calculator(cls, x: float, y: float) -> tuple[float, float]:
"""
根据截图缩放比例和截图区域偏移量,计算实际屏幕坐标。
@@ -701,11 +731,16 @@ def _location_calculator(cls, x, y):
tuple[int, int]: 实际屏幕上的坐标 (x, y)。
"""
cls.location_proportion = 1 / cls.screenshot_proportion
- return x * cls.location_proportion + cls.area_left, y * cls.location_proportion + cls.area_top
+ return (
+ x * cls.location_proportion + cls.area_left,
+ y * cls.location_proportion + cls.area_top,
+ )
# ========== 图像匹配与查找部分 ==========
@classmethod
- def _find_template_in_screenshot(cls, screenshot: Image.Image, template_path: str, threshold: float = 0.8) -> tuple[bool, tuple[int, int] | None]:
+ def _find_template_in_screenshot(
+ cls, screenshot: Image.Image, template_path: str, threshold: float = 0.8
+ ) -> tuple[bool, tuple[int, int] | None]:
"""
在截图中查找模板图像。
@@ -720,18 +755,19 @@ def _find_template_in_screenshot(cls, screenshot: Image.Image, template_path: st
"""
try:
# 读取模板图像
- template = cv2.imread(template_path)
+ template = cast(Any, cv2.imread(template_path))
if template is None:
logger.error(f"无法读取模板图像: {template_path}")
return False, None
# 将 Pillow 图像转换为 OpenCV 格式
import numpy as np
+
screenshot_cv = cv2.cvtColor(np.array(screenshot), cv2.COLOR_RGB2BGR)
# 执行模板匹配
result = cv2.matchTemplate(screenshot_cv, template, cv2.TM_CCOEFF_NORMED)
- min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
+ _, max_val, _, max_loc = cv2.minMaxLoc(result)
# 判断是否匹配成功
if max_val >= threshold:
@@ -739,10 +775,14 @@ def _find_template_in_screenshot(cls, screenshot: Image.Image, template_path: st
template_h, template_w = template.shape[:2]
center_x = max_loc[0] + template_w // 2
center_y = max_loc[1] + template_h // 2
- logger.debug(f"找到模板图像 {template_path},匹配度: {max_val:.2f},位置: ({center_x}, {center_y})")
+ logger.debug(
+ f"找到模板图像 {template_path},匹配度: {max_val:.2f},位置: ({center_x}, {center_y})"
+ )
return True, (center_x, center_y)
else:
- logger.debug(f"未找到模板图像 {template_path},最高匹配度: {max_val:.2f}")
+ logger.debug(
+ f"未找到模板图像 {template_path},最高匹配度: {max_val:.2f}"
+ )
return False, None
except Exception as e:
@@ -750,7 +790,14 @@ def _find_template_in_screenshot(cls, screenshot: Image.Image, template_path: st
return False, None
@classmethod
- def check(cls, image_path: str, title: str | None = None, interval: float = 0, retry_times: int = 1, threshold: float = 0.8) -> bool:
+ def check(
+ cls,
+ image_path: str,
+ title: str | None = None,
+ interval: float = 0,
+ retry_times: int = 1,
+ threshold: float = 0.8,
+ ) -> bool:
"""
截图并查找是否存在图片内的内容。
@@ -772,7 +819,9 @@ def check(cls, image_path: str, title: str | None = None, interval: float = 0, r
# 使用传入的 title 或类的全局 title
window_title = title or cls.title
if not window_title:
- raise OCRNotFoundTitleException("必须提供 title 参数或通过 set_title() 设置全局 title")
+ raise OCRNotFoundTitleException(
+ "必须提供 title 参数或通过 set_title() 设置全局 title"
+ )
for attempt in range(retry_times):
try:
@@ -780,7 +829,9 @@ def check(cls, image_path: str, title: str | None = None, interval: float = 0, r
screenshot = cls.get_screenshot_with_pc(window_title)
# 查找模板
- found, _ = cls._find_template_in_screenshot(screenshot, image_path, threshold)
+ found, _ = cls._find_template_in_screenshot(
+ screenshot, image_path, threshold
+ )
if found:
logger.info(f"在第 {attempt + 1} 次尝试中找到图像: {image_path}")
@@ -791,7 +842,9 @@ def check(cls, image_path: str, title: str | None = None, interval: float = 0, r
time.sleep(interval)
except Exception as e:
- logger.error(f"check 方法执行失败 (尝试 {attempt + 1}/{retry_times}): {e}")
+ logger.error(
+ f"check 方法执行失败 (尝试 {attempt + 1}/{retry_times}): {e}"
+ )
if attempt < retry_times - 1 and interval > 0:
time.sleep(interval)
@@ -799,7 +852,14 @@ def check(cls, image_path: str, title: str | None = None, interval: float = 0, r
return False
@classmethod
- def check_any(cls, image_paths: list[str], title: str | None = None, interval: float = 0, retry_times: int = 1, threshold: float = 0.8) -> bool:
+ def check_any(
+ cls,
+ image_paths: list[str],
+ title: str | None = None,
+ interval: float = 0,
+ retry_times: int = 1,
+ threshold: float = 0.8,
+ ) -> bool:
"""
截图并查找是否存在列表中任意一张图片的内容。
@@ -821,7 +881,9 @@ def check_any(cls, image_paths: list[str], title: str | None = None, interval: f
# 使用传入的 title 或类的全局 title
window_title = title or cls.title
if not window_title:
- raise OCRNotFoundTitleException("必须提供 title 参数或通过 set_title() 设置全局 title")
+ raise OCRNotFoundTitleException(
+ "必须提供 title 参数或通过 set_title() 设置全局 title"
+ )
for attempt in range(retry_times):
try:
@@ -830,9 +892,13 @@ def check_any(cls, image_paths: list[str], title: str | None = None, interval: f
# 遍历所有模板图像
for image_path in image_paths:
- found, _ = cls._find_template_in_screenshot(screenshot, image_path, threshold)
+ found, _ = cls._find_template_in_screenshot(
+ screenshot, image_path, threshold
+ )
if found:
- logger.info(f"在第 {attempt + 1} 次尝试中找到图像: {image_path}")
+ logger.info(
+ f"在第 {attempt + 1} 次尝试中找到图像: {image_path}"
+ )
return True
# 如果不是最后一次尝试,等待间隔时间
@@ -840,7 +906,9 @@ def check_any(cls, image_paths: list[str], title: str | None = None, interval: f
time.sleep(interval)
except Exception as e:
- logger.error(f"check_any 方法执行失败 (尝试 {attempt + 1}/{retry_times}): {e}")
+ logger.error(
+ f"check_any 方法执行失败 (尝试 {attempt + 1}/{retry_times}): {e}"
+ )
if attempt < retry_times - 1 and interval > 0:
time.sleep(interval)
@@ -848,7 +916,14 @@ def check_any(cls, image_paths: list[str], title: str | None = None, interval: f
return False
@classmethod
- def check_all(cls, image_paths: list[str], title: str | None = None, interval: float = 0, retry_times: int = 1, threshold: float = 0.8) -> bool:
+ def check_all(
+ cls,
+ image_paths: list[str],
+ title: str | None = None,
+ interval: float = 0,
+ retry_times: int = 1,
+ threshold: float = 0.8,
+ ) -> bool:
"""
截图并查找是否存在列表中所有图片的内容。
@@ -870,7 +945,9 @@ def check_all(cls, image_paths: list[str], title: str | None = None, interval: f
# 使用传入的 title 或类的全局 title
window_title = title or cls.title
if not window_title:
- raise OCRNotFoundTitleException("必须提供 title 参数或通过 set_title() 设置全局 title")
+ raise OCRNotFoundTitleException(
+ "必须提供 title 参数或通过 set_title() 设置全局 title"
+ )
for attempt in range(retry_times):
try:
@@ -880,10 +957,14 @@ def check_all(cls, image_paths: list[str], title: str | None = None, interval: f
# 检查所有模板图像
found_all = True
for image_path in image_paths:
- found, _ = cls._find_template_in_screenshot(screenshot, image_path, threshold)
+ found, _ = cls._find_template_in_screenshot(
+ screenshot, image_path, threshold
+ )
if not found:
found_all = False
- logger.debug(f"第 {attempt + 1} 次尝试中未找到图像: {image_path}")
+ logger.debug(
+ f"第 {attempt + 1} 次尝试中未找到图像: {image_path}"
+ )
break
if found_all:
@@ -895,7 +976,9 @@ def check_all(cls, image_paths: list[str], title: str | None = None, interval: f
time.sleep(interval)
except Exception as e:
- logger.error(f"check_all 方法执行失败 (尝试 {attempt + 1}/{retry_times}): {e}")
+ logger.error(
+ f"check_all 方法执行失败 (尝试 {attempt + 1}/{retry_times}): {e}"
+ )
if attempt < retry_times - 1 and interval > 0:
time.sleep(interval)
@@ -904,7 +987,14 @@ def check_all(cls, image_paths: list[str], title: str | None = None, interval: f
# ========== 点击操作部分 ==========
@classmethod
- def click_img(cls, image_path: str, title: str | None = None, interval: float = 0, retry_times: int = 1, threshold: float = 0.8) -> bool:
+ def click_img(
+ cls,
+ image_path: str,
+ title: str | None = None,
+ interval: float = 0,
+ retry_times: int = 1,
+ threshold: float = 0.8,
+ ) -> bool:
"""
点击与图像一致的位置的坐标。
@@ -926,7 +1016,9 @@ def click_img(cls, image_path: str, title: str | None = None, interval: float =
# 使用传入的 title 或类的全局 title
window_title = title or cls.title
if not window_title:
- raise OCRNotFoundTitleException("必须提供 title 参数或通过 set_title() 设置全局 title")
+ raise OCRNotFoundTitleException(
+ "必须提供 title 参数或通过 set_title() 设置全局 title"
+ )
for attempt in range(retry_times):
try:
@@ -934,15 +1026,21 @@ def click_img(cls, image_path: str, title: str | None = None, interval: float =
screenshot = cls.get_screenshot_with_pc(window_title)
# 查找模板
- found, position = cls._find_template_in_screenshot(screenshot, image_path, threshold)
+ found, position = cls._find_template_in_screenshot(
+ screenshot, image_path, threshold
+ )
if found and position:
# 将截图坐标转换为实际屏幕坐标
- screen_x, screen_y = cls._location_calculator(position[0], position[1])
+ screen_x, screen_y = cls._location_calculator(
+ position[0], position[1]
+ )
# 执行点击
- pyautogui.click(screen_x, screen_y)
- logger.info(f"成功点击图像 {image_path} 的位置: ({screen_x}, {screen_y})")
+ pyautogui.click(int(screen_x), int(screen_y))
+ logger.info(
+ f"成功点击图像 {image_path} 的位置: ({screen_x}, {screen_y})"
+ )
return True
# 如果不是最后一次尝试,等待间隔时间
@@ -950,7 +1048,9 @@ def click_img(cls, image_path: str, title: str | None = None, interval: float =
time.sleep(interval)
except Exception as e:
- logger.error(f"click_img 方法执行失败 (尝试 {attempt + 1}/{retry_times}): {e}")
+ logger.error(
+ f"click_img 方法执行失败 (尝试 {attempt + 1}/{retry_times}): {e}"
+ )
if attempt < retry_times - 1 and interval > 0:
time.sleep(interval)
@@ -958,7 +1058,13 @@ def click_img(cls, image_path: str, title: str | None = None, interval: float =
return False
@classmethod
- def click_txt(cls, text: str, title: str | None = None, interval: float = 0, retry_times: int = 1) -> bool:
+ def click_txt(
+ cls,
+ text: str,
+ title: str | None = None,
+ interval: float = 0,
+ retry_times: int = 1,
+ ) -> bool:
"""
点击与文字一致的位置。
@@ -980,7 +1086,9 @@ def click_txt(cls, text: str, title: str | None = None, interval: float = 0, ret
# 使用传入的 title 或类的全局 title
window_title = title or cls.title
if not window_title:
- raise OCRNotFoundTitleException("必须提供 title 参数或通过 set_title() 设置全局 title")
+ raise OCRNotFoundTitleException(
+ "必须提供 title 参数或通过 set_title() 设置全局 title"
+ )
for attempt in range(retry_times):
try:
@@ -991,7 +1099,7 @@ def click_txt(cls, text: str, title: str | None = None, interval: float = 0, ret
screenshot_np = np.array(screenshot)
# 使用 OCR 识别文字
- result, elapse = cls().ocr_engine(screenshot_np)
+ result, _elapsed = cls().ocr_engine(screenshot_np)
if result is None:
logger.debug(f"第 {attempt + 1} 次尝试中未识别到任何文字")
@@ -1000,22 +1108,28 @@ def click_txt(cls, text: str, title: str | None = None, interval: float = 0, ret
continue
# 遍历识别结果,查找匹配的文字
- for line in result:
- detected_text = line[1] # OCR 识别的文字
+ for line in cast(list[list[Any]], result):
+ detected_text = str(line[1]) # OCR 识别的文字
if text in detected_text:
# 获取文字区域的边界框坐标
- box = line[0] # [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
+ box = cast(
+ list[list[float]], line[0]
+ ) # [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
# 计算中心点
center_x = int((box[0][0] + box[2][0]) / 2)
center_y = int((box[0][1] + box[2][1]) / 2)
# 将截图坐标转换为实际屏幕坐标
- screen_x, screen_y = cls._location_calculator(center_x, center_y)
+ screen_x, screen_y = cls._location_calculator(
+ center_x, center_y
+ )
# 执行点击
- pyautogui.click(screen_x, screen_y)
- logger.info(f"成功点击文字 '{text}' 的位置: ({screen_x}, {screen_y})")
+ pyautogui.click(int(screen_x), int(screen_y))
+ logger.info(
+ f"成功点击文字 '{text}' 的位置: ({screen_x}, {screen_y})"
+ )
return True
logger.debug(f"第 {attempt + 1} 次尝试中未找到文字: {text}")
@@ -1025,10 +1139,11 @@ def click_txt(cls, text: str, title: str | None = None, interval: float = 0, ret
time.sleep(interval)
except Exception as e:
- logger.error(f"click_txt 方法执行失败 (尝试 {attempt + 1}/{retry_times}): {e}")
+ logger.error(
+ f"click_txt 方法执行失败 (尝试 {attempt + 1}/{retry_times}): {e}"
+ )
if attempt < retry_times - 1 and interval > 0:
time.sleep(interval)
logger.info(f"在 {retry_times} 次尝试后未能点击文字: {text}")
return False
-
diff --git a/app/utils/ProcessManager.py b/app/utils/ProcessManager.py
index 8c824ba6..ab14c913 100644
--- a/app/utils/ProcessManager.py
+++ b/app/utils/ProcessManager.py
@@ -72,7 +72,7 @@ def match_process(proc: psutil.Process, target: ProcessInfo) -> bool:
def get_window_handles(pid: int) -> list[int]:
"""获取指定进程的所有窗口句柄"""
- window_handles = []
+ window_handles: list[int] = []
def enum_callback(hwnd: int, lparam: int) -> bool:
"""枚举窗口的回调函数"""
diff --git a/app/utils/__init__.py b/app/utils/__init__.py
index 0717b163..0ba0aec9 100644
--- a/app/utils/__init__.py
+++ b/app/utils/__init__.py
@@ -1,41 +1,67 @@
-# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
-# Copyright © 2024-2025 DLmaster361
-# Copyright © 2025 MoeSnowyFox
-# Copyright © 2025-2026 AUTO-MAS Team
+from __future__ import annotations
-# This file is part of AUTO-MAS.
+from importlib import import_module
+from typing import TYPE_CHECKING, Any
-# AUTO-MAS is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as
-# published by the Free Software Foundation, either version 3 of
-# the License, or (at your option) any later version.
-# AUTO-MAS is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty
-# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
-# the GNU Affero General Public License for more details.
+if TYPE_CHECKING:
+ import app.utils.constants as constants
+ from app.utils.ImageUtils import ImageUtils
+ from app.utils.LogMonitor import LogMonitor, strptime
+ from app.utils.ProcessManager import (
+ ProcessInfo,
+ ProcessManager,
+ ProcessResult,
+ ProcessRunner,
+ )
+ from app.utils.emulator import (
+ EMULATOR_TYPE_BOOK,
+ LDManager,
+ MumuManager,
+ search_all_emulators,
+ )
+ from app.utils.logger import get_logger
+ from app.utils.security import (
+ dpapi_decrypt,
+ dpapi_encrypt,
+ sanitize_log_message,
+ )
+ from app.utils.skland import skland_sign_in
+ from app.utils.tools import busy_wait, decode_bytes
+ from app.utils.websocket import WebSocketClient, create_ws_client
-# You should have received a copy of the GNU Affero General Public License
-# along with AUTO-MAS. If not, see .
-# Contact: DLmaster_361@163.com
+_EXPORTS: dict[str, tuple[str, str]] = {
+ "constants": ("app.utils.constants", ""),
+ "get_logger": ("app.utils.logger", "get_logger"),
+ "ImageUtils": ("app.utils.ImageUtils", "ImageUtils"),
+ "LogMonitor": ("app.utils.LogMonitor", "LogMonitor"),
+ "strptime": ("app.utils.LogMonitor", "strptime"),
+ "ProcessManager": ("app.utils.ProcessManager", "ProcessManager"),
+ "ProcessRunner": ("app.utils.ProcessManager", "ProcessRunner"),
+ "ProcessInfo": ("app.utils.ProcessManager", "ProcessInfo"),
+ "ProcessResult": ("app.utils.ProcessManager", "ProcessResult"),
+ "dpapi_encrypt": ("app.utils.security", "dpapi_encrypt"),
+ "dpapi_decrypt": ("app.utils.security", "dpapi_decrypt"),
+ "sanitize_log_message": ("app.utils.security", "sanitize_log_message"),
+ "skland_sign_in": ("app.utils.skland", "skland_sign_in"),
+ "MumuManager": ("app.utils.emulator", "MumuManager"),
+ "LDManager": ("app.utils.emulator", "LDManager"),
+ "search_all_emulators": ("app.utils.emulator", "search_all_emulators"),
+ "EMULATOR_TYPE_BOOK": ("app.utils.emulator", "EMULATOR_TYPE_BOOK"),
+ "decode_bytes": ("app.utils.tools", "decode_bytes"),
+ "busy_wait": ("app.utils.tools", "busy_wait"),
+ "WebSocketClient": ("app.utils.websocket", "WebSocketClient"),
+ "create_ws_client": ("app.utils.websocket", "create_ws_client"),
+}
-from .constants import *
-from .logger import get_logger
-from .ImageUtils import ImageUtils
-from .LogMonitor import LogMonitor, strptime
-from .ProcessManager import ProcessManager, ProcessRunner, ProcessInfo, ProcessResult
-from .security import dpapi_encrypt, dpapi_decrypt, sanitize_log_message
-from .emulator import MumuManager, LDManager, search_all_emulators, EMULATOR_TYPE_BOOK
-from .tools import decode_bytes, busy_wait
-from .websocket import WebSocketClient, create_ws_client
-
-__all__ = [
+__all__ = (
"constants",
"get_logger",
"ImageUtils",
"LogMonitor",
+ "strptime",
"ProcessManager",
"ProcessRunner",
"ProcessInfo",
@@ -43,7 +69,7 @@
"dpapi_encrypt",
"dpapi_decrypt",
"sanitize_log_message",
- "strptime",
+ "skland_sign_in",
"MumuManager",
"LDManager",
"search_all_emulators",
@@ -52,4 +78,15 @@
"busy_wait",
"WebSocketClient",
"create_ws_client",
-]
+)
+
+
+def __getattr__(name: str) -> Any:
+ if name not in _EXPORTS:
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+
+ module_name, attr_name = _EXPORTS[name]
+ module = import_module(module_name)
+ value = module if not attr_name else getattr(module, attr_name)
+ globals()[name] = value
+ return value
diff --git a/app/utils/emulator/general.py b/app/utils/emulator/general.py
index d78a3787..a12777a2 100644
--- a/app/utils/emulator/general.py
+++ b/app/utils/emulator/general.py
@@ -28,11 +28,11 @@
import keyboard
from datetime import datetime, timedelta
from pathlib import Path
-from typing import Dict, Any
+from typing import Dict
+from app.models.common import EmulatorConfig
from app.utils.ProcessManager import ProcessManager
from app.models.emulator import DeviceStatus, DeviceBase, DeviceInfo
-from app.models.config import EmulatorConfig
from app.utils import get_logger
logger = get_logger("通用模拟器管理")
@@ -44,20 +44,26 @@ class GeneralDeviceManager(DeviceBase):
"""
def __init__(self, config: EmulatorConfig) -> None:
+ if not Path(str(config.get("Info", "Path"))).exists():
+ raise FileNotFoundError(
+ f"模拟器文件不存在: {str(config.get('Info', 'Path'))}"
+ )
- if not Path(config.get("Info", "Path")).exists():
- raise FileNotFoundError(f"模拟器文件不存在: {config.get('Info', 'Path')}")
-
- if config.get("Info", "Type") != "general":
+ if str(config.get("Info", "Type")) != "general":
raise ValueError("配置的模拟器类型不是通用类型")
self.config = config
- self.emulator_path = Path(config.get("Info", "Path"))
+ self.emulator_path = Path(str(config.get("Info", "Path")))
self.process_managers: Dict[str, ProcessManager] = {}
- self.device_info: Dict[str, Dict[str, Any]] = {}
+ self.device_info: Dict[str, Dict[str, object]] = {}
- async def open(self, idx: str, package_name: str = "") -> DeviceInfo:
+ def _info_str(self, key: str) -> str:
+ return str(self.config.get("Info", key))
+ def _info_int(self, key: str) -> int:
+ return int(self.config.get("Info", key))
+
+ async def open(self, idx: str, package_name: str = "") -> DeviceInfo:
# 检查是否已经在运行
current_status = await self.getStatus(idx)
if current_status == DeviceStatus.ONLINE:
@@ -74,12 +80,11 @@ async def open(self, idx: str, package_name: str = "") -> DeviceInfo:
await self.process_managers[idx].open_process(self.emulator_path, *args)
# 等待进程启动
- await asyncio.sleep(self.config.get("Info", "MaxWaitTime"))
+ await asyncio.sleep(self._info_int("MaxWaitTime"))
return (await self.getInfo(idx))[idx]
async def close(self, idx: str) -> DeviceStatus:
-
status = await self.getStatus(idx)
if status == DeviceStatus.OFFLINE:
logger.warning(f"设备{idx}未在线,当前状态: {status}")
@@ -90,9 +95,7 @@ async def close(self, idx: str) -> DeviceStatus:
# 等待进程完全停止
t = datetime.now()
- while datetime.now() - t < timedelta(
- seconds=self.config.get("Info", "MaxWaitTime")
- ):
+ while datetime.now() - t < timedelta(seconds=self._info_int("MaxWaitTime")):
if not await self.process_managers[idx].is_running():
return DeviceStatus.OFFLINE
@@ -101,7 +104,6 @@ async def close(self, idx: str) -> DeviceStatus:
raise RuntimeError(f"关闭设备{idx}超时")
async def getStatus(self, idx: str) -> DeviceStatus:
-
if idx not in self.process_managers:
return DeviceStatus.OFFLINE
@@ -111,30 +113,25 @@ async def getStatus(self, idx: str) -> DeviceStatus:
return DeviceStatus.OFFLINE
async def getInfo(self, idx: str | None) -> Dict[str, DeviceInfo]:
-
- data = {}
+ data: Dict[str, DeviceInfo] = {}
for index in self.process_managers:
if idx is not None and index != idx:
continue
data[index] = DeviceInfo(
- title=f"{self.config.get('Info', 'Name')}_{index}",
+ title=f"{self._info_str('Name')}_{index}",
status=await self.getStatus(index),
adb_address=self.parse_index(index)[1],
)
return data
async def setVisible(self, idx: str, is_visible: bool) -> DeviceStatus:
-
status = await self.getStatus(idx)
if status != DeviceStatus.ONLINE:
logger.warning(f"设备{idx}未在线,当前状态码: {status}")
return status
t = datetime.now()
- while datetime.now() - t < timedelta(
- seconds=self.config.get("Info", "MaxWaitTime")
- ):
-
+ while datetime.now() - t < timedelta(seconds=self._info_int("MaxWaitTime")):
# 检查窗口可见性是否符合预期
if self.process_managers[idx].main_pid is not None and (
win32gui.IsWindowVisible(self.process_managers[idx].main_pid)
@@ -145,8 +142,7 @@ async def setVisible(self, idx: str, is_visible: bool) -> DeviceStatus:
try:
keyboard.press_and_release(
"+".join(
- _.strip().lower()
- for _ in json.loads(self.config.get("Info", "BossKey"))
+ _.strip().lower() for _ in json.loads(self._info_str("BossKey"))
)
) # 老板键
except Exception as e:
@@ -158,7 +154,6 @@ async def setVisible(self, idx: str, is_visible: bool) -> DeviceStatus:
raise RuntimeError(f"隐藏设备{idx}窗口超时")
def parse_index(self, idx: str):
-
if "|" not in idx:
raise ValueError("缺少 '|' 分隔符")
diff --git a/app/utils/emulator/ldplayer.py b/app/utils/emulator/ldplayer.py
index ae1b36da..83db08e2 100644
--- a/app/utils/emulator/ldplayer.py
+++ b/app/utils/emulator/ldplayer.py
@@ -29,8 +29,8 @@
from pydantic import BaseModel
from pathlib import Path
+from app.models.common import EmulatorConfig
from app.models.emulator import DeviceStatus, DeviceInfo, DeviceBase
-from app.models.config import EmulatorConfig
from app.utils import ProcessRunner, get_logger
logger = get_logger("雷电模拟器管理")
@@ -55,26 +55,30 @@ class LDManager(DeviceBase):
"""
def __init__(self, config: EmulatorConfig) -> None:
- if not Path(config.get("Info", "Path")).exists():
+ if not Path(str(config.get("Info", "Path"))).exists():
raise FileNotFoundError(
f"LDPlayerManager.exe文件不存在: {config.get('Info', 'Path')}"
)
- if config.get("Info", "Type") != "ldplayer":
+ if str(config.get("Info", "Type")) != "ldplayer":
raise ValueError("配置的模拟器类型不是ldplayer")
self.config = config
- self.emulator_path = Path(config.get("Info", "Path"))
+ self.emulator_path = Path(str(config.get("Info", "Path")))
- async def open(self, idx: str, package_name="") -> DeviceInfo:
+ def _info_str(self, key: str) -> str:
+ return str(self.config.get("Info", key))
+
+ def _info_int(self, key: str) -> int:
+ return int(self.config.get("Info", key))
+
+ async def open(self, idx: str, package_name: str = "") -> DeviceInfo:
logger.info(f"开始启动模拟器 {idx} - {package_name}")
status = DeviceStatus.UNKNOWN # 初始化status变量
t = datetime.now()
- while datetime.now() - t < timedelta(
- seconds=self.config.get("Info", "MaxWaitTime")
- ):
+ while datetime.now() - t < timedelta(seconds=self._info_int("MaxWaitTime")):
status = await self.getStatus(idx)
if status == DeviceStatus.ONLINE:
return (await self.getInfo(idx))[idx]
@@ -91,7 +95,7 @@ async def open(self, idx: str, package_name="") -> DeviceInfo:
"--index",
idx,
*(["--packagename", f'"{package_name}"'] if package_name else []),
- timeout=self.config.get("Info", "MaxWaitTime"),
+ timeout=self._info_int("MaxWaitTime"),
if_merge_std=True,
)
# 参考命令 dnconsole.exe launch --index 0
@@ -100,15 +104,12 @@ async def open(self, idx: str, package_name="") -> DeviceInfo:
raise RuntimeError(f"命令执行失败: {result.stdout}")
t = datetime.now()
- while datetime.now() - t < timedelta(
- seconds=self.config.get("Info", "MaxWaitTime")
- ):
+ while datetime.now() - t < timedelta(seconds=self._info_int("MaxWaitTime")):
status = await self.getStatus(idx)
if status == DeviceStatus.ONLINE:
await asyncio.sleep(
30
- if package_name != ""
- and self.config.get("Info", "MaxWaitTime") > 60
+ if package_name != "" and self._info_int("MaxWaitTime") > 60
else 3
) # 等待模拟器的 ADB 等服务完全启动, 低性能设备额外等待应用启动
return (await self.getInfo(idx))[idx]
@@ -130,7 +131,7 @@ async def close(self, idx: str) -> DeviceStatus:
"quit",
"--index",
idx,
- timeout=self.config.get("Info", "MaxWaitTime"),
+ timeout=self._info_int("MaxWaitTime"),
if_merge_std=True,
)
# 参考命令 dnconsole.exe quit --index 0
@@ -138,9 +139,7 @@ async def close(self, idx: str) -> DeviceStatus:
if result.returncode != 0:
raise RuntimeError(f"命令执行失败: {result.stdout}")
t = datetime.now()
- while datetime.now() - t < timedelta(
- seconds=self.config.get("Info", "MaxWaitTime")
- ):
+ while datetime.now() - t < timedelta(seconds=self._info_int("MaxWaitTime")):
status = await self.getStatus(idx)
if status == DeviceStatus.OFFLINE:
return DeviceStatus.OFFLINE
@@ -203,9 +202,7 @@ async def setVisible(self, idx: str, is_visible: bool) -> DeviceStatus:
result = (await self.get_device_info(idx))[idx]
t = datetime.now()
- while datetime.now() - t < timedelta(
- seconds=self.config.get("Info", "MaxWaitTime")
- ):
+ while datetime.now() - t < timedelta(seconds=self._info_int("MaxWaitTime")):
# 检查窗口可见性是否符合预期
if win32gui.IsWindowVisible(result.top_hwnd) == is_visible:
return status
@@ -213,8 +210,7 @@ async def setVisible(self, idx: str, is_visible: bool) -> DeviceStatus:
try:
keyboard.press_and_release(
"+".join(
- _.strip().lower()
- for _ in json.loads(self.config.get("Info", "BossKey"))
+ _.strip().lower() for _ in json.loads(self._info_str("BossKey"))
)
) # 老板键
except Exception as e:
@@ -231,7 +227,7 @@ async def get_device_info(self, idx: str | None) -> dict[str, LDPlayerDevice]:
result = await ProcessRunner.run_process(
self.emulator_path,
"list2",
- timeout=self.config.get("Info", "MaxWaitTime"),
+ timeout=self._info_int("MaxWaitTime"),
if_merge_std=True,
)
diff --git a/app/utils/emulator/mumu.py b/app/utils/emulator/mumu.py
index 03f6a1b4..24346189 100644
--- a/app/utils/emulator/mumu.py
+++ b/app/utils/emulator/mumu.py
@@ -29,9 +29,10 @@
from contextlib import suppress
from datetime import datetime, timedelta
from pathlib import Path
+from typing import Any, cast
+from app.models.common import EmulatorConfig
from app.models.emulator import DeviceStatus, DeviceInfo, DeviceBase
-from app.models.config import EmulatorConfig
from app.utils import ProcessRunner, get_logger
@@ -44,28 +45,32 @@ class MumuManager(DeviceBase):
"""
def __init__(self, config: EmulatorConfig) -> None:
- if not (Path(config.get("Info", "Path"))).exists():
+ if not (Path(str(config.get("Info", "Path")))).exists():
raise FileNotFoundError(
f"MuMuManager.exe文件不存在: {config.get('Info', 'Path')}"
)
- if config.get("Info", "Type") != "mumu":
+ if str(config.get("Info", "Type")) != "mumu":
raise ValueError("配置的模拟器类型不是mumu")
self.config = config
- self.emulator_path = Path(config.get("Info", "Path"))
+ self.emulator_path = Path(str(config.get("Info", "Path")))
+
+ def _info_str(self, key: str) -> str:
+ return str(self.config.get("Info", key))
+
+ def _info_int(self, key: str) -> int:
+ return int(self.config.get("Info", key))
async def open(self, idx: str, package_name: str = "") -> DeviceInfo:
logger.info(f"开始启动模拟器 {idx} - {package_name}")
- from app.core import Config
+ from app.core.config import Config
status = DeviceStatus.UNKNOWN # 初始化status变量
t = datetime.now()
- while datetime.now() - t < timedelta(
- seconds=self.config.get("Info", "MaxWaitTime")
- ):
+ while datetime.now() - t < timedelta(seconds=self._info_int("MaxWaitTime")):
status = await self.getStatus(idx)
if status == DeviceStatus.ONLINE:
return (await self.getInfo(idx))[idx]
@@ -85,7 +90,7 @@ async def open(self, idx: str, package_name: str = "") -> DeviceInfo:
idx,
"launch",
*(["-pkg", package_name] if package_name else []),
- timeout=self.config.get("Info", "MaxWaitTime"),
+ timeout=self._info_int("MaxWaitTime"),
if_merge_std=True,
)
# 参考命令 MuMuManager.exe control -v 2 launch
@@ -94,9 +99,7 @@ async def open(self, idx: str, package_name: str = "") -> DeviceInfo:
raise RuntimeError(f"命令执行失败: {result.stdout}")
t = datetime.now()
- while datetime.now() - t < timedelta(
- seconds=self.config.get("Info", "MaxWaitTime")
- ):
+ while datetime.now() - t < timedelta(seconds=self._info_int("MaxWaitTime")):
status = await self.getStatus(idx)
if if_close_mumu_nx:
if_close_mumu_nx = not await self.close_mumu_nx_window()
@@ -105,8 +108,7 @@ async def open(self, idx: str, package_name: str = "") -> DeviceInfo:
elif status == DeviceStatus.ONLINE:
await asyncio.sleep(
30
- if package_name != ""
- and self.config.get("Info", "MaxWaitTime") > 60
+ if package_name != "" and self._info_int("MaxWaitTime") > 60
else 3
) # 等待模拟器的 ADB 等服务完全启动, 低性能设备额外等待应用启动
return (await self.getInfo(idx))[idx]
@@ -128,7 +130,7 @@ async def close(self, idx: str) -> DeviceStatus:
"-v",
idx,
"shutdown",
- timeout=self.config.get("Info", "MaxWaitTime"),
+ timeout=self._info_int("MaxWaitTime"),
if_merge_std=True,
)
# 参考命令 MuMuManager.exe control -v 2 shutdown
@@ -137,9 +139,7 @@ async def close(self, idx: str) -> DeviceStatus:
raise RuntimeError(f"命令执行失败: {result.stdout}")
t = datetime.now()
- while datetime.now() - t < timedelta(
- seconds=self.config.get("Info", "MaxWaitTime")
- ):
+ while datetime.now() - t < timedelta(seconds=self._info_int("MaxWaitTime")):
status = await self.getStatus(idx)
if status == DeviceStatus.OFFLINE:
return DeviceStatus.OFFLINE
@@ -158,14 +158,19 @@ async def getStatus(self, idx: str, data: str | None = None) -> DeviceStatus:
logger.error(f"获取模拟器 {idx} 信息失败: {e}")
return DeviceStatus.ERROR
try:
- data_json = json.loads(data)
+ parsed: Any = json.loads(data)
except json.JSONDecodeError as e:
logger.error(f"JSON解析错误: {e}")
return DeviceStatus.UNKNOWN
- if data_json["is_android_started"]:
+ if not isinstance(parsed, dict):
+ return DeviceStatus.UNKNOWN
+
+ data_json = cast(dict[str, Any], parsed)
+
+ if bool(data_json.get("is_android_started", False)):
return DeviceStatus.ONLINE
- elif data_json["is_process_started"]:
+ elif bool(data_json.get("is_process_started", False)):
return DeviceStatus.STARTING
else:
return DeviceStatus.OFFLINE
@@ -173,16 +178,18 @@ async def getStatus(self, idx: str, data: str | None = None) -> DeviceStatus:
async def getInfo(self, idx: str | None) -> dict[str, DeviceInfo]:
data = await self.get_device_info(idx or "all")
- data_json = json.loads(data)
+ parsed: Any = json.loads(data)
result: dict[str, DeviceInfo] = {}
- if not data_json:
+ if not isinstance(parsed, dict) or not parsed:
return result
- if isinstance(data_json, dict) and "index" in data_json and "name" in data_json:
- index = data_json["index"]
- name = data_json["name"]
+ data_json = cast(dict[str, Any], parsed)
+
+ if "index" in data_json and "name" in data_json:
+ index = str(data_json["index"])
+ name = str(data_json["name"])
status = await self.getStatus(index, data)
adb_address = (
f"{data_json.get('adb_host_ip')}:{data_json.get('adb_port')}"
@@ -194,16 +201,23 @@ async def getInfo(self, idx: str | None) -> dict[str, DeviceInfo]:
title=name, status=status, adb_address=adb_address
)
- elif isinstance(data_json, dict):
+ else:
for value in data_json.values():
- if isinstance(value, dict) and "index" in value and "name" in value:
- index = value["index"]
- name = value["name"]
+ value_map = (
+ cast(dict[str, Any], value) if isinstance(value, dict) else None
+ )
+ if (
+ value_map is not None
+ and "index" in value_map
+ and "name" in value_map
+ ):
+ index = str(value_map["index"])
+ name = str(value_map["name"])
status = await self.getStatus(index)
adb_address = (
- f"{value.get('adb_host_ip')}:{value.get('adb_port')}"
- if value.get("adb_host_ip", None)
- and value.get("adb_port", None)
+ f"{value_map.get('adb_host_ip')}:{value_map.get('adb_port')}"
+ if value_map.get("adb_host_ip", None)
+ and value_map.get("adb_port", None)
else "Unknown"
)
result[index] = DeviceInfo(
@@ -224,7 +238,7 @@ async def setVisible(self, idx: str, is_visible: bool) -> DeviceStatus:
"-v",
idx,
"show_window" if is_visible else "hide_window",
- timeout=self.config.get("Info", "MaxWaitTime"),
+ timeout=self._info_int("MaxWaitTime"),
if_merge_std=True,
)
if result.returncode != 0:
@@ -238,7 +252,7 @@ async def get_device_info(self, idx: str) -> str:
"info",
"-v",
idx,
- timeout=self.config.get("Info", "MaxWaitTime"),
+ timeout=self._info_int("MaxWaitTime"),
if_merge_std=True,
)
if result.returncode != 0:
diff --git a/app/utils/emulator/tools.py b/app/utils/emulator/tools.py
index 28480e3f..dec9dd33 100644
--- a/app/utils/emulator/tools.py
+++ b/app/utils/emulator/tools.py
@@ -23,10 +23,11 @@
import os
import winreg
import subprocess
+from collections.abc import Iterable
from maa.toolkit import Toolkit
from contextlib import suppress
from pathlib import Path
-from typing import List, Dict
+from typing import Protocol, TypedDict, cast
from app.utils.constants import EMULATOR_PATH_BOOK
from app.utils import get_logger
@@ -34,15 +35,44 @@
logger = get_logger("模拟器管理工具")
-async def search_all_emulators() -> List[Dict[str, str]]:
+class EmulatorInfo(TypedDict):
+ type: str
+ path: str
+ name: str
+
+
+class EmulatorSearchConfig(TypedDict):
+ name: str
+ executables: list[str]
+ registry_paths: list[str]
+ default_paths: list[str]
+
+
+class SearchCandidate(TypedDict):
+ path: Path
+ exe_path: Path
+ depth: int
+ level: int
+
+
+class AdbDeviceLike(Protocol):
+ adb_path: Path
+
+
+def _candidate_depth(candidate: SearchCandidate) -> int:
+ return candidate["depth"]
+
+
+async def search_all_emulators() -> list[EmulatorInfo]:
"""搜索所有支持的模拟器"""
logger.info("开始搜索所有模拟器")
- found_emulators = []
- found_emulator_paths = set()
+ found_emulators: list[EmulatorInfo] = []
+ found_emulator_paths: set[str] = set()
# 根据可能的模拟器路径搜索
- for emulator_type, config in EMULATOR_PATH_BOOK.items():
+ for emulator_type, raw_config in EMULATOR_PATH_BOOK.items():
+ config = cast(EmulatorSearchConfig, raw_config)
try:
emulator_path = await _search_emulator(config)
if emulator_path:
@@ -63,12 +93,15 @@ async def search_all_emulators() -> List[Dict[str, str]]:
except Exception as e:
logger.warning(f"搜索{config['name']}时出错: {e}")
- for emulator in Toolkit.find_adb_devices():
+ adb_devices = cast(Iterable[AdbDeviceLike], Toolkit.find_adb_devices())
+ for emulator in adb_devices:
+ adb_path_text = emulator.adb_path.as_posix()
+ adb_parent_text = emulator.adb_path.parent.as_posix()
for emulator_type in EMULATOR_PATH_BOOK.keys():
corrected_path = await find_emulator_manager_path(
- emulator.adb_path.as_posix(), emulator_type
+ adb_path_text, emulator_type
)
- if corrected_path != emulator.adb_path.as_posix():
+ if corrected_path != adb_path_text:
if corrected_path not in found_emulator_paths:
found_emulator_paths.add(corrected_path)
found_emulators.append(
@@ -83,12 +116,12 @@ async def search_all_emulators() -> List[Dict[str, str]]:
)
break
else:
- if emulator.adb_path.as_posix() not in found_emulator_paths:
- found_emulator_paths.add(emulator.adb_path.as_posix())
+ if adb_path_text not in found_emulator_paths:
+ found_emulator_paths.add(adb_path_text)
found_emulators.append(
{
"type": "general",
- "path": emulator.adb_path.parent.as_posix(),
+ "path": adb_parent_text,
"name": f"未知模拟器 ({emulator.adb_path.parent.as_posix()})",
}
)
@@ -98,7 +131,7 @@ async def search_all_emulators() -> List[Dict[str, str]]:
return found_emulators
-async def _search_emulator(config: Dict) -> str:
+async def _search_emulator(config: EmulatorSearchConfig) -> str:
"""搜索单类模拟器"""
# 1. 从注册表搜索
@@ -121,7 +154,7 @@ async def _search_emulator(config: Dict) -> str:
return ""
-async def _search_from_registry(registry_paths: List[str]) -> str:
+async def _search_from_registry(registry_paths: list[str]) -> str:
"""从注册表搜索模拟器路径"""
for reg_path in registry_paths:
@@ -140,7 +173,7 @@ async def _search_from_registry(registry_paths: List[str]) -> str:
return ""
-async def _search_from_path(executables: List[str]) -> str:
+async def _search_from_path(executables: list[str]) -> str:
"""从系统PATH搜索模拟器"""
for executable in executables:
@@ -155,7 +188,7 @@ async def _search_from_path(executables: List[str]) -> str:
return ""
-async def _validate_emulator_path(path: str, executables: List[str]) -> bool:
+async def _validate_emulator_path(path: str, executables: list[str]) -> bool:
"""验证模拟器路径是否有效"""
if not path or not os.path.exists(path):
@@ -202,7 +235,7 @@ async def find_emulator_manager_path(
logger.warning(f"不支持的模拟器类型: {emulator_type}")
return input_path
- config = EMULATOR_PATH_BOOK[emulator_type]
+ config = cast(EmulatorSearchConfig, EMULATOR_PATH_BOOK[emulator_type])
executables = config["executables"]
# 第一个可执行文件是主管理器程序(优先级最高)
primary_exe = executables[0]
@@ -226,7 +259,7 @@ async def find_emulator_manager_path(
return result
# 2. 向上搜索父目录,找到直接包含主管理器程序的目录(最多3层)
- candidates = []
+ candidates: list[SearchCandidate] = []
current = path_obj
for level in range(max_levels):
parent = current.parent
@@ -251,7 +284,7 @@ async def find_emulator_manager_path(
# 如果找到了候选目录,选择最优的(深度最小的,即最接近根目录的)
if candidates:
# 排序策略:深度越小越好(越靠近根目录)
- candidates.sort(key=lambda x: x["depth"])
+ candidates.sort(key=_candidate_depth)
best_candidate = candidates[0]
result = str(best_candidate["exe_path"])
diff --git a/app/utils/logger.py b/app/utils/logger.py
index 79347fa9..73bdeac9 100644
--- a/app/utils/logger.py
+++ b/app/utils/logger.py
@@ -20,11 +20,54 @@
# Contact: DLmaster_361@163.com
+from collections.abc import Callable
+from typing import Any, Protocol, TypeVar, overload
from loguru import logger as _logger
import sys
from pathlib import Path
+
+F = TypeVar("F", bound=Callable[..., Any])
+
+
+class LoggerLike(Protocol):
+ def bind(self, *args: Any, **kwargs: Any) -> "LoggerLike": ...
+
+ def debug(self, __message: object, *args: Any, **kwargs: Any) -> None: ...
+
+ def info(self, __message: object, *args: Any, **kwargs: Any) -> None: ...
+
+ def success(self, __message: object, *args: Any, **kwargs: Any) -> None: ...
+
+ def warning(self, __message: object, *args: Any, **kwargs: Any) -> None: ...
+
+ def error(self, __message: object, *args: Any, **kwargs: Any) -> None: ...
+
+ def exception(self, __message: object, *args: Any, **kwargs: Any) -> None: ...
+
+ def level(self, *args: Any, **kwargs: Any) -> Any: ...
+
+ def opt(self, *args: Any, **kwargs: Any) -> "LoggerLike": ...
+
+ def log(self, *args: Any, **kwargs: Any) -> None: ...
+
+ @overload
+ def catch(self, function: F, /) -> F: ...
+
+ @overload
+ def catch(
+ self,
+ exception: type[BaseException] = Exception,
+ *,
+ level: str = "ERROR",
+ reraise: bool = False,
+ onerror: Callable[[BaseException], Any] | None = None,
+ exclude: Any | None = None,
+ default: Any | None = None,
+ message: str = "An error has been caught",
+ ) -> Callable[[F], F]: ...
+
(Path.cwd() / "debug").mkdir(parents=True, exist_ok=True)
@@ -57,7 +100,7 @@
_logger = _logger.patch(lambda record: record["extra"].setdefault("module", "未知模块"))
-def get_logger(module_name: str):
+def get_logger(module_name: str) -> LoggerLike:
"""
获取指定模块名的日志记录器
@@ -70,4 +113,4 @@ def get_logger(module_name: str):
return _logger.bind(module=module_name)
-__all__ = ["get_logger"]
+__all__ = ["LoggerLike", "get_logger"]
diff --git a/app/utils/security.py b/app/utils/security.py
index d96477fe..e66d6b0a 100644
--- a/app/utils/security.py
+++ b/app/utils/security.py
@@ -1,6 +1,6 @@
# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
-# Copyright © 2024-2025 DLmaster361
-# Copyright © 2025-2026 AUTO-MAS Team
+# Copyright ? 2024-2025 DLmaster361
+# Copyright ? 2025-2026 AUTO-MAS Team
# This file is part of AUTO-MAS.
@@ -20,21 +20,55 @@
# Contact: DLmaster_361@163.com
-import re
import base64
+import re
+from typing import Any, Protocol, cast
+
import win32crypt
+class _CryptProtectDataFn(Protocol):
+ def __call__(
+ self,
+ DataIn: bytes,
+ DataDescr: str | None = None,
+ OptionalEntropy: Any | None = None,
+ Reserved: Any | None = None,
+ PromptStruct: Any | None = None,
+ Flags: int = 0,
+ ) -> bytes: ...
+
+
+class _CryptUnprotectDataFn(Protocol):
+ def __call__(
+ self,
+ DataIn: bytes,
+ OptionalEntropy: Any | None = None,
+ Reserved: Any | None = None,
+ PromptStruct: Any | None = None,
+ Flags: int = 0,
+ ) -> tuple[Any, bytes]: ...
+
+
+CRYPT_PROTECT_DATA = cast(
+ _CryptProtectDataFn,
+ getattr(win32crypt, "CryptProtectData"),
+)
+CRYPT_UNPROTECT_DATA = cast(
+ _CryptUnprotectDataFn,
+ getattr(win32crypt, "CryptUnprotectData"),
+)
+
+
def sanitize_log_message(message: str) -> str:
"""
- 从日志消息中移除敏感信息
+ 过滤日志中的敏感信息。
:param message: 原始日志消息
:type message: str
:return: 过滤后的日志消息
:rtype: str
"""
- # 定义需要过滤的敏感参数模式
sensitive_patterns = [
(r"(cdk=)[^&\s]+", r"\1***"), # cdk参数
(r"(password=)[^&\s]+", r"\1***"), # password参数
@@ -71,9 +105,7 @@ def dpapi_encrypt(
if note == "":
return ""
- encrypted = win32crypt.CryptProtectData(
- note.encode("utf-8"), description, entropy, None, None, 0
- )
+ encrypted = CRYPT_PROTECT_DATA(note.encode("utf-8"), description, entropy, None, None, 0)
return base64.b64encode(encrypted).decode("utf-8")
@@ -92,7 +124,5 @@ def dpapi_decrypt(note: str, entropy: None | bytes = None) -> str:
if note == "":
return ""
- decrypted = win32crypt.CryptUnprotectData(
- base64.b64decode(note), entropy, None, None, 0
- )
+ decrypted = CRYPT_UNPROTECT_DATA(base64.b64decode(note), entropy, None, None, 0)
return decrypted[1].decode("utf-8")
diff --git a/app/utils/skland.py b/app/utils/skland.py
new file mode 100644
index 00000000..e8ec0634
--- /dev/null
+++ b/app/utils/skland.py
@@ -0,0 +1,632 @@
+# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
+# Copyright © 2024-2025 DLmaster361
+# Copyright © 2025 ClozyA
+# Copyright © 2025-2026 AUTO-MAS Team
+
+# This file incorporates work covered by the following copyright and
+# permission notice:
+#
+# skland-checkin-ghaction Copyright © 2023 Yanstory
+# https://github.com/Yanstory/skland-checkin-ghaction
+#
+# skland-daily-attendance Copyright © 2023-2025 enpitsuLin
+# https://github.com/enpitsuLin/skland-daily-attendance
+
+# This file is part of AUTO-MAS.
+
+# AUTO-MAS is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of
+# the License, or (at your option) any later version.
+
+# AUTO-MAS is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty
+# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
+# the GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with AUTO-MAS. If not, see .
+
+# Contact: DLmaster_361@163.com
+
+
+import time
+import json
+import uuid
+import hmac
+import gzip
+import httpx
+import base64
+import asyncio
+import hashlib
+from urllib import parse
+from datetime import datetime, timedelta
+from Crypto.PublicKey import RSA
+from Crypto.Cipher import PKCS1_v1_5, AES, DES
+from Crypto.Util.Padding import pad
+
+from typing import Dict, Any, cast
+
+from .constants import SKLAND_SM_CONFIG, BROWSER_ENV, DES_RULE
+from .logger import get_logger
+
+logger = get_logger("森空岛签到任务")
+
+
+def get_proxy(proxy: str | None = None) -> Any:
+ if proxy is not None:
+ return proxy
+
+ from app.core import Config
+
+ return Config.proxy
+
+
+def md5_hash(data: str) -> str:
+ """MD5哈希"""
+ return hashlib.md5(data.encode()).hexdigest()
+
+
+def get_sm_id() -> str:
+ """生成数美ID"""
+ now = time.localtime()
+ _time = time.strftime("%Y%m%d%H%M%S", now)
+ uid = str(uuid.uuid4())
+ uid_md5 = md5_hash(uid)
+ v = f"{_time}{uid_md5}00"
+ smsk_web = md5_hash(f"smsk_web_{v}")[:14]
+ return f"{v}{smsk_web}0"
+
+
+def get_tn(obj: Dict[str, Any]) -> str:
+ """计算tn值"""
+ sorted_keys = sorted(obj.keys())
+ result_list: list[str] = []
+
+ for key in sorted_keys:
+ v = obj[key]
+ if isinstance(v, (int, float)):
+ v = str(int(v * 10000))
+ elif isinstance(v, dict):
+ v = get_tn(cast(Dict[str, Any], v))
+ else:
+ v = str(v)
+ result_list.append(v)
+
+ return "".join(result_list)
+
+
+def encrypt_rsa(message: str, public_key_str: str) -> str:
+ """RSA加密"""
+ try:
+ formatted_key = "\n".join(
+ [public_key_str[i : i + 64] for i in range(0, len(public_key_str), 64)]
+ )
+ public_key_pem = (
+ f"-----BEGIN PUBLIC KEY-----\n{formatted_key}\n-----END PUBLIC KEY-----"
+ )
+ key = RSA.import_key(public_key_pem)
+ cipher = PKCS1_v1_5.new(key)
+ encrypted = cipher.encrypt(message.encode())
+ return base64.b64encode(encrypted).decode()
+ except Exception as e:
+ raise Exception(f"RSA加密失败: {e}")
+
+
+def encrypt_des(message: str, key: str) -> str:
+ """DES ECB 加密"""
+ key_bytes = key.encode()[:8].ljust(8, b"\0")
+ message_bytes = str(message).encode()
+ while len(message_bytes) % 8 != 0:
+ message_bytes += b"\0"
+ cipher = cast(Any, DES).new(key_bytes, DES.MODE_ECB)
+ encrypted = cipher.encrypt(message_bytes)
+ return base64.b64encode(encrypted).decode()
+
+
+def gzip_compress_object(obj: Dict[str, Any]) -> str:
+ """GZIP压缩对象"""
+ json_str = json.dumps(obj, separators=(", ", ": "))
+ compressed = gzip.compress(json_str.encode())
+ compressed_bytes = bytearray(compressed)
+ if len(compressed_bytes) > 9:
+ compressed_bytes[9] = 19
+ return base64.b64encode(compressed_bytes).decode()
+
+
+def encrypt_aes(message: str, key: str) -> str:
+ """AES CBC加密"""
+ iv = b"0102030405060708"
+ key_bytes = key.encode()[:16].ljust(16, b"\0")
+ cipher = cast(Any, AES).new(key_bytes, AES.MODE_CBC, iv)
+ padded_data = pad(message.encode(), AES.block_size)
+ encrypted = cipher.encrypt(padded_data)
+ return encrypted.hex()
+
+
+def encrypt_object_by_des_rules(
+ obj: Dict[str, Any], rules: Dict[str, Dict[str, Any]]
+) -> Dict[str, Any]:
+ """根据DES规则加密对象"""
+ result: Dict[str, Any] = {}
+
+ for key, value in obj.items():
+ if key in rules:
+ rule = rules[key]
+ if rule["is_encrypt"] == 1:
+ encrypted_value = encrypt_des(str(value), rule["key"])
+ result[rule["obfuscated_name"]] = encrypted_value
+ else:
+ result[rule["obfuscated_name"]] = value
+ else:
+ result[key] = value
+
+ return result
+
+
+async def get_device_id(proxy: str | None = None) -> str:
+ """获取设备ID"""
+ uid = str(uuid.uuid4())
+ pri_id = md5_hash(uid)[:16]
+ ep = encrypt_rsa(uid, SKLAND_SM_CONFIG["publicKey"])
+
+ browser = BROWSER_ENV.copy()
+ browser.update(
+ {
+ "vpw": str(uuid.uuid4()),
+ "svm": int(time.time() * 1000),
+ "trees": str(uuid.uuid4()),
+ "pmf": int(time.time() * 1000),
+ }
+ )
+
+ des_target = {
+ **browser,
+ "protocol": 102,
+ "organization": SKLAND_SM_CONFIG["organization"],
+ "appId": SKLAND_SM_CONFIG["appId"],
+ "os": "web",
+ "version": "3.0.0",
+ "sdkver": "3.0.0",
+ "box": "",
+ "rtype": "all",
+ "smid": get_sm_id(),
+ "subVersion": "1.0.0",
+ "time": 0,
+ }
+ des_target["tn"] = md5_hash(get_tn(des_target))
+
+ des_result = encrypt_object_by_des_rules(des_target, DES_RULE)
+ gzip_result = gzip_compress_object(des_result)
+ aes_result = encrypt_aes(gzip_result, pri_id)
+
+ body = {
+ "appId": "default",
+ "compress": 2,
+ "data": aes_result,
+ "encode": 5,
+ "ep": ep,
+ "organization": SKLAND_SM_CONFIG["organization"],
+ "os": "web",
+ }
+
+ devices_info_url = f"{SKLAND_SM_CONFIG['protocol']}://{SKLAND_SM_CONFIG['apiHost']}{SKLAND_SM_CONFIG['apiPath']}"
+
+ async with httpx.AsyncClient(proxy=get_proxy(proxy)) as client:
+ response = await client.post(
+ devices_info_url,
+ json=body,
+ headers={"Content-Type": "application/json"},
+ timeout=30.0,
+ )
+ resp = response.json()
+
+ if resp.get("code") != 1100:
+ raise Exception(f"设备ID计算失败: {resp}")
+
+ return f"B{resp['detail']['deviceId']}"
+
+
+_cached_device_id = None
+_cache_time = datetime.now()
+
+
+async def get_cached_device_id(proxy: str | None = None) -> str:
+ """获取缓存的设备ID"""
+ global _cached_device_id, _cache_time
+
+ if _cached_device_id is None or (datetime.now() - _cache_time) > timedelta(hours=1):
+ _cached_device_id = await get_device_id(proxy)
+ _cache_time = datetime.now()
+
+ return _cached_device_id
+
+
+async def skland_sign_in(
+ token: str,
+ app_code: str = "arknights",
+ proxy: str | None = None,
+) -> dict[str, Any]:
+ """森空岛签到"""
+
+ grant_code_url = "https://as.hypergryph.com/user/oauth2/v2/grant"
+ cred_code_url = "https://zonai.skland.com/web/v1/user/auth/generate_cred_by_code"
+ binding_url = "https://zonai.skland.com/api/v1/game/player/binding"
+ arknights_sign_url = "https://zonai.skland.com/api/v1/game/attendance"
+ endfield_sign_url = "https://zonai.skland.com/web/v1/game/endfield/attendance"
+
+ header = {
+ "cred": "",
+ "User-Agent": "Skland/1.21.0 (com.hypergryph.skland; build:102100065; iOS 17.6.0; ) Alamofire/5.7.1",
+ "Accept-Encoding": "gzip",
+ "Connection": "close",
+ "Content-Type": "application/json",
+ }
+ header_login = header.copy()
+ header_for_sign = {
+ "platform": "1",
+ "timestamp": "",
+ "dId": "",
+ "vName": "1.21.0",
+ }
+
+ def generate_signature(
+ token_for_sign: str,
+ path: str,
+ body_or_query: str,
+ custom_header: dict[str, str] | None = None,
+ ) -> tuple[str, dict[str, str]]:
+ """生成请求签名"""
+ t = str(int(time.time() * 1000 - 2000))[:-3]
+ token_bytes = token_for_sign.encode("utf-8")
+ header_ca = dict(custom_header if custom_header else header_for_sign)
+ header_ca["timestamp"] = t
+ header_ca_str = json.dumps(header_ca, separators=(",", ":"))
+ s = path + body_or_query + t + header_ca_str
+ hex_s = hmac.new(token_bytes, s.encode("utf-8"), hashlib.sha256).hexdigest()
+ md5_hash_value = hashlib.md5(hex_s.encode("utf-8")).hexdigest()
+ return md5_hash_value, header_ca
+
+ async def get_sign_header(
+ url: str,
+ method: str,
+ body: dict[str, Any] | None,
+ old_header: dict[str, str],
+ sign_token: str,
+ ) -> dict[str, str]:
+ """获取带签名的请求头"""
+ h = json.loads(json.dumps(old_header))
+ p = parse.urlparse(url)
+
+ device_id = await get_cached_device_id(proxy)
+ temp_header_for_sign = dict(header_for_sign)
+ temp_header_for_sign["dId"] = device_id
+
+ if method.lower() == "get":
+ query = p.query or ""
+ sign, header_ca = generate_signature(
+ sign_token, p.path, query, temp_header_for_sign
+ )
+ else:
+ body_str = json.dumps(body) if body else ""
+ sign, header_ca = generate_signature(
+ sign_token, p.path, body_str, temp_header_for_sign
+ )
+
+ h["sign"] = sign
+ for key, value in header_ca.items():
+ h[key] = value
+
+ if "token" in h:
+ del h["token"]
+
+ return h
+
+ def copy_header(cred: str, token: str | None = None) -> dict[str, str]:
+ """复制请求头并添加cred和token"""
+ v = json.loads(json.dumps(header))
+ v["cred"] = cred
+ if token:
+ v["token"] = token
+ return v
+
+ async def login_by_token(token_code: str) -> tuple[str, str]:
+ """使用token一步步拿到cred和sign_token"""
+ try:
+ t = json.loads(token_code)
+ token_code = t["data"]["content"]
+ except Exception:
+ pass
+ grant_code = await get_grant_code(token_code)
+ return await get_cred(grant_code)
+
+ async def get_cred(grant: str) -> tuple[str, str]:
+ """通过grant code获取cred和sign_token"""
+ device_id = await get_cached_device_id(proxy)
+
+ web_headers = {
+ "content-type": "application/json",
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
+ "referer": "https://www.skland.com/",
+ "origin": "https://www.skland.com",
+ "dId": device_id,
+ "platform": "3",
+ "timestamp": str(int(time.time())),
+ "vName": "1.0.0",
+ }
+
+ async with httpx.AsyncClient(proxy=get_proxy(proxy)) as client:
+ response = await client.post(
+ cred_code_url,
+ json={"code": grant, "kind": 1},
+ headers=web_headers,
+ )
+ rsp = response.json()
+ if rsp["code"] != 0:
+ raise Exception(f"获得cred失败: {rsp.get('message')}")
+ sign_token = rsp["data"]["token"]
+ cred = rsp["data"]["cred"]
+ return cred, sign_token
+
+ async def get_grant_code(token_value: str) -> str:
+ """通过token获取grant code"""
+ async with httpx.AsyncClient(proxy=get_proxy(proxy)) as client:
+ response = await client.post(
+ grant_code_url,
+ json={"appCode": "4ca99fa6b56cc2ba", "token": token_value, "type": 0},
+ headers=header_login,
+ )
+ rsp = response.json()
+ if rsp["status"] != 0:
+ raise Exception(
+ f"使用token: {token_value[:3]}******{token_value[-3:]} 获得认证代码失败: {rsp.get('msg')}"
+ )
+ return rsp["data"]["code"]
+
+ async def get_binding_list(cred: str, sign_token: str) -> list[dict[str, Any]]:
+ """查询已绑定的角色列表"""
+ v: list[dict[str, Any]] = []
+ async with httpx.AsyncClient(proxy=get_proxy(proxy)) as client:
+ response = await client.get(
+ binding_url,
+ headers=await get_sign_header(
+ binding_url,
+ "get",
+ None,
+ copy_header(cred, sign_token),
+ sign_token,
+ ),
+ )
+ rsp = response.json()
+ if rsp["code"] != 0:
+ logger.error(f"请求角色列表出现问题: {rsp['message']}")
+ if rsp.get("message") == "用户未登录":
+ logger.error("用户登录可能失效了, 请重新登录!")
+ return v
+ for item in rsp["data"]["list"]:
+ if item.get("appCode") != app_code:
+ continue
+ v.extend(item.get("bindingList"))
+ return v
+
+ async def check_attendance_today(
+ cred: str,
+ sign_token: str,
+ uid: str,
+ game_id: str | int,
+ ) -> bool:
+ """检查今天是否已经签到"""
+ query_url = f"{arknights_sign_url}?uid={uid}&gameId={game_id}"
+
+ try:
+ async with httpx.AsyncClient(proxy=get_proxy(proxy)) as client:
+ response = await client.get(
+ query_url,
+ headers=await get_sign_header(
+ query_url,
+ "get",
+ None,
+ copy_header(cred, sign_token),
+ sign_token,
+ ),
+ )
+ rsp = response.json()
+
+ if rsp["code"] != 0:
+ logger.warning(f"检查签到状态失败: {rsp.get('message')}")
+ return False
+
+ records = rsp["data"].get("records", [])
+ today = time.time() // 86400 * 86400
+
+ for record in records:
+ record_time = int(record.get("ts", 0))
+ if record_time >= today:
+ return True
+
+ return False
+ except Exception as e:
+ logger.warning(f"检查签到状态异常: {e}")
+ return False
+
+ async def sign_for_arknights(cred: str, sign_token: str) -> dict[str, Any]:
+ """方舟签到"""
+ characters = await get_binding_list(cred, sign_token)
+ success_list: list[str] = []
+ duplicate_list: list[str] = []
+ failed_list: list[str] = []
+
+ for character in characters:
+ character_name = (
+ f"{character.get('nickName')}({character.get('channelName')})"
+ )
+ uid = character.get("uid")
+ game_id = character.get("channelMasterId")
+
+ if not isinstance(uid, str) or not isinstance(game_id, (str, int)):
+ failed_list.append(character_name)
+ logger.error(f"{character_name} 缺少有效 uid 或 gameId,跳过签到")
+ await asyncio.sleep(1)
+ continue
+
+ if await check_attendance_today(cred, sign_token, uid, game_id):
+ duplicate_list.append(character_name)
+ logger.info(f"{character_name} 今天已经签到过了")
+ await asyncio.sleep(1)
+ continue
+
+ body = {
+ "uid": uid,
+ "gameId": game_id,
+ }
+
+ try:
+ async with httpx.AsyncClient(proxy=get_proxy(proxy)) as client:
+ sign_headers = await get_sign_header(
+ arknights_sign_url,
+ "post",
+ body,
+ copy_header(cred, sign_token),
+ sign_token,
+ )
+ response = await client.post(
+ arknights_sign_url,
+ headers=sign_headers,
+ content=json.dumps(body),
+ )
+ rsp = response.json()
+
+ if rsp["code"] != 0:
+ if rsp.get("message") == "请勿重复签到!":
+ duplicate_list.append(character_name)
+ logger.info(f"{character_name} 重复签到")
+ else:
+ failed_list.append(character_name)
+ logger.error(f"{character_name} 签到失败: {rsp.get('message')}")
+ else:
+ success_list.append(character_name)
+ logger.info(f"{character_name} 签到成功")
+
+ except Exception as e:
+ failed_list.append(character_name)
+ logger.error(f"{character_name} 签到异常: {e}")
+
+ await asyncio.sleep(3)
+
+ return {
+ "成功": success_list,
+ "重复": duplicate_list,
+ "失败": failed_list,
+ "总计": len(characters),
+ }
+
+ async def do_sign_for_endfield(
+ cred: str, sign_token: str, role: dict[str, Any]
+ ) -> dict[str, Any]:
+ headers = await get_sign_header(
+ endfield_sign_url,
+ "post",
+ None,
+ copy_header(cred, sign_token),
+ sign_token,
+ )
+ headers.update(
+ {
+ "Content-Type": "application/json",
+ "sk-game-role": f'3_{role["roleId"]}_{role["serverId"]}',
+ "referer": "https://game.skland.com/",
+ "origin": "https://game.skland.com/",
+ }
+ )
+
+ async with httpx.AsyncClient(proxy=get_proxy(proxy)) as client:
+ response = await client.post(endfield_sign_url, headers=headers)
+ return response.json()
+
+ async def sign_for_endfield(cred: str, sign_token: str) -> dict[str, Any]:
+ """终末地签到"""
+ characters = await get_binding_list(cred, sign_token)
+ success_list: list[str] = []
+ duplicate_list: list[str] = []
+ failed_list: list[str] = []
+ total_count = 0
+
+ for character in characters:
+ roles_value = character.get("roles")
+ game_name = character.get("gameName")
+ channel_name = character.get("channelName")
+ if not isinstance(roles_value, list):
+ continue
+ roles = cast(list[dict[str, Any]], roles_value)
+ total_count += len(roles)
+
+ for role in roles:
+ nickname = str(role.get("nickname") or "").strip()
+ character_name = f"{nickname}({channel_name})"
+
+ try:
+ rsp = await do_sign_for_endfield(cred, sign_token, role)
+ if rsp.get("code") != 0:
+ message = rsp.get("message", "")
+ if (
+ "请勿重复签到" in message
+ or "Please do not sign in again!" in message
+ ):
+ duplicate_list.append(character_name)
+ logger.info(f"{character_name} 重复签到")
+ else:
+ failed_list.append(character_name)
+ logger.error(f"{character_name} 签到失败: {message}")
+ else:
+ data = rsp.get("data")
+ data_dict = (
+ cast(dict[str, Any], data) if isinstance(data, dict) else {}
+ )
+ award_ids_value = data_dict.get("awardIds")
+ resource_map_value = data_dict.get("resourceInfoMap")
+ award_ids = (
+ cast(list[dict[str, Any]], award_ids_value)
+ if isinstance(award_ids_value, list)
+ else []
+ )
+ resource_map = (
+ cast(dict[str, dict[str, Any]], resource_map_value)
+ if isinstance(resource_map_value, dict)
+ else {}
+ )
+ awards: list[str] = []
+ for award in award_ids:
+ award_id = award.get("id")
+ if award_id and award_id in resource_map:
+ resource = resource_map[award_id]
+ awards.append(
+ f'{resource["name"]}x{resource.get("count", 1)}'
+ )
+ if awards:
+ logger.info(
+ f"[{game_name}] {character_name} 签到成功: {'、'.join(awards)}"
+ )
+ success_list.append(character_name)
+ logger.info(f"{character_name} 签到成功")
+ except Exception as e:
+ failed_list.append(character_name)
+ logger.error(f"{character_name} 签到异常: {e}")
+
+ await asyncio.sleep(3)
+
+ return {
+ "成功": success_list,
+ "重复": duplicate_list,
+ "失败": failed_list,
+ "总计": total_count,
+ }
+
+ try:
+ cred, sign_token = await login_by_token(token)
+ await asyncio.sleep(1)
+ if app_code == "endfield":
+ return await sign_for_endfield(cred, sign_token)
+ return await sign_for_arknights(cred, sign_token)
+ except Exception as e:
+ logger.error(f"森空岛签到失败: {e}")
+ return {"成功": [], "重复": [], "失败": [], "总计": 0}
diff --git a/app/utils/websocket.py b/app/utils/websocket.py
index 27257326..000e7da9 100644
--- a/app/utils/websocket.py
+++ b/app/utils/websocket.py
@@ -24,297 +24,35 @@
import time
import asyncio
import json
-import re
-from typing import Optional, Callable, Any, Dict, List
+from collections import deque
+from typing import Optional, Callable, Any, Dict, Deque
from websockets.asyncio.client import connect, ClientConnection
from websockets.exceptions import ConnectionClosed
+from tenacity import (
+ AsyncRetrying,
+ RetryCallState,
+ retry_if_exception_type,
+ stop_after_attempt,
+ stop_never,
+ wait_exponential,
+)
from app.utils.logger import get_logger
-# ============== WebSocket 客户端实例 ==============
+_retry_logger = get_logger("WS重连")
-class ReverseWebSocketSession:
- """反向 WebSocket 会话,封装 FastAPI/Starlette 服务端侧连接。"""
- def __init__(
- self,
- websocket: Any,
- name: str,
- ping_interval: float = 15.0,
- ping_timeout: float = 30.0,
- on_message: Optional[Callable[[Dict[str, Any]], Any]] = None,
- on_connect: Optional[Callable[[], Any]] = None,
- on_disconnect: Optional[Callable[[], Any]] = None,
- auth_token: Optional[str] = None,
- ):
- """初始化反向 WebSocket 会话。"""
- self.websocket = websocket
- self.name = name
- self.logger = get_logger(f"WS反向会话:{self.name}")
+def _log_ws_retry_before_sleep(retry_state: RetryCallState) -> None:
+ """输出 WebSocket 重连重试日志。"""
- self.ping_interval = ping_interval
- self.ping_timeout = ping_timeout
- self.reconnect_interval = 0.0
- self.max_reconnect_attempts = 0
- self.on_message = on_message
- self.on_connect = on_connect
- self.on_disconnect = on_disconnect
- self._auth_token = auth_token
+ attempt = retry_state.attempt_number
+ reason = retry_state.outcome.exception() if retry_state.outcome else None
+ _retry_logger.warning(f"连接失败,准备第 {attempt + 1} 次重试,原因: {reason}")
- self._running = False
- self._last_ping = 0.0
- self._last_pong = 0.0
- self._tasks: list[asyncio.Task] = []
- self._background_tasks: set[asyncio.Task] = set()
- self._closed_event = asyncio.Event()
- self._disconnect_notified = False
-
- @property
- def direction(self) -> str:
- """返回会话方向标识。"""
- return "inbound"
-
- @property
- def url(self) -> str:
- """返回反向连接的伪 URL,便于调试界面展示。"""
- scope = getattr(self.websocket, "scope", {}) or {}
- path = scope.get("path") or ""
- client = getattr(self.websocket, "client", None)
- if client:
- return f"reverse://{client.host}:{client.port}{path}"
- return f"reverse://{path.lstrip('/')}"
- @property
- def is_system(self) -> bool:
- """反向会话默认视为系统会话。"""
- return True
-
- @property
- def is_connected(self) -> bool:
- """检查反向会话是否仍处于连接状态。"""
- client_state = getattr(self.websocket, "client_state", None)
- application_state = getattr(self.websocket, "application_state", None)
- client_state_name = getattr(client_state, "name", str(client_state))
- application_state_name = getattr(application_state, "name", str(application_state))
- return client_state_name == "CONNECTED" and application_state_name != "DISCONNECTED"
-
- async def start(self) -> bool:
- """启动反向会话的收发和心跳任务。"""
- if self._running:
- return True
-
- self._running = True
- self._last_ping = time.monotonic()
- self._last_pong = time.monotonic()
-
- if self.on_connect:
- result = self.on_connect()
- if asyncio.iscoroutine(result):
- await result
-
- if self._auth_token:
- await self.send_auth(self._auth_token)
-
- receive_task = asyncio.create_task(self._receive_loop())
- heartbeat_task = asyncio.create_task(self._heartbeat_loop())
- self._tasks = [receive_task, heartbeat_task]
- return True
-
- async def wait_closed(self):
- """等待反向会话关闭。"""
- await self._closed_event.wait()
-
- async def run(self):
- """启动并阻塞运行反向会话。"""
- await self.start()
- await self.wait_closed()
-
- async def send(self, message: Dict[str, Any]) -> bool:
- """发送 JSON 消息到反向连接的客户端。"""
- if not self.is_connected:
- self.logger.warning("WebSocket 未连接,无法发送消息")
- return False
-
- try:
- await self.websocket.send_json(message)
- return True
- except Exception as e:
- self.logger.error(f"发送消息失败: {type(e).__name__}: {e}")
- return False
-
- async def send_json(self, data: Dict[str, Any]) -> bool:
- """兼容 FastAPI WebSocket 的 send_json 接口。"""
- return await self.send(data)
-
- async def send_auth(
- self,
- token: str,
- auth_type: str = "auth",
- extra_data: Optional[Dict[str, Any]] = None,
- ) -> bool:
- """发送认证消息。"""
- self._auth_token = token
- auth_message = {
- "id": "Client",
- "type": auth_type,
- "data": {"token": token, **(extra_data or {})},
- }
- return await self.send(auth_message)
-
- async def close(self, code: int = 1000, reason: str = "正常关闭"):
- """关闭反向 WebSocket 会话。"""
- self._running = False
- for task in self._tasks:
- if not task.done():
- task.cancel()
- self._tasks.clear()
-
- for task in list(self._background_tasks):
- if not task.done():
- task.cancel()
- self._background_tasks.clear()
-
- try:
- if self.is_connected:
- await self.websocket.close(code=code, reason=reason)
- except Exception as e:
- self.logger.warning(f"关闭反向会话时发生异常: {type(e).__name__}: {e}")
- finally:
- await self._notify_disconnect()
- self._closed_event.set()
-
- async def disconnect(self):
- """兼容统一的断开接口。"""
- await self.close()
-
- async def _notify_disconnect(self):
- """触发断开回调,确保只执行一次。"""
- if self._disconnect_notified:
- return
- self._disconnect_notified = True
- if self.on_disconnect:
- result = self.on_disconnect()
- if asyncio.iscoroutine(result):
- await result
-
- async def _send_ping(self):
- """发送应用层 Ping。"""
- message = {"id": "Client", "type": "Signal", "data": {"Ping": "heartbeat"}}
- if await self.send(message):
- self._last_ping = time.monotonic()
- if self.name != "Main":
- self.logger.debug("已发送 Ping")
-
- async def _send_pong(self):
- """发送应用层 Pong。"""
- message = {"id": "Client", "type": "Signal", "data": {"Pong": "heartbeat"}}
- await self.send(message)
- if self.name != "Main":
- self.logger.debug("已发送 Pong")
-
- async def _handle_message(self, raw_message: Any):
- """处理接收到的消息。"""
- try:
- if isinstance(raw_message, str):
- data = json.loads(raw_message)
- else:
- data = raw_message
-
- if data.get("type") == "Signal":
- signal_data = data.get("data", {})
- if "Pong" in signal_data:
- self._last_pong = time.monotonic()
- if self.name != "Main":
- self.logger.debug("收到 Pong")
- return
- if "Ping" in signal_data:
- if self.name != "Main":
- self.logger.debug("收到 Ping")
- await self._send_pong()
- return
-
- if self.on_message:
- result = self.on_message(data)
- if asyncio.iscoroutine(result):
- self._schedule_background_task(
- result,
- task_name="reverse_on_message",
- )
- except json.JSONDecodeError as e:
- self.logger.warning(f"消息解析失败: {e}")
- except Exception as e:
- self.logger.error(f"处理消息时发生异常: {type(e).__name__}: {e}")
-
- def _schedule_background_task(self, coro: Any, task_name: str) -> None:
- """调度后台异步任务,避免阻塞接收与心跳循环。
-
- Args:
- coro (Any): 待调度的协程对象。
- task_name (str): 任务名称,仅用于日志标识。
-
- Returns:
- None: 无返回值。
-
- Raises:
- TypeError: 当 `coro` 不是协程对象时抛出。
- """
- if not asyncio.iscoroutine(coro):
- raise TypeError("coro 必须是协程对象")
-
- task = asyncio.create_task(coro)
- self._background_tasks.add(task)
-
- def _on_done(done_task: asyncio.Task):
- self._background_tasks.discard(done_task)
- if done_task.cancelled():
- return
- exc = done_task.exception()
- if exc is not None:
- self.logger.error(f"后台任务异常({task_name}): {type(exc).__name__}: {exc}")
-
- task.add_done_callback(_on_done)
-
- async def _receive_loop(self):
- """消息接收循环。"""
- while self._running and self.is_connected:
- try:
- message = await asyncio.wait_for(
- self.websocket.receive_json(), timeout=self.ping_interval
- )
- await self._handle_message(message)
- except asyncio.TimeoutError:
- continue
- except Exception as e:
- self.logger.warning(f"接收消息时连接关闭或异常: {type(e).__name__}: {e}")
- break
-
- self._running = False
- await self._notify_disconnect()
- self._closed_event.set()
-
- async def _heartbeat_loop(self):
- """心跳维护循环。"""
- while self._running and self.is_connected:
- try:
- current_time = time.monotonic()
- if self._last_pong < self._last_ping:
- time_since_ping = current_time - self._last_ping
- if time_since_ping > self.ping_timeout:
- self.logger.warning(f"Pong 超时 ({time_since_ping:.1f}s),断开连接")
- break
-
- if current_time - self._last_ping >= self.ping_interval:
- await self._send_ping()
-
- await asyncio.sleep(1.0)
- except Exception as e:
- self.logger.error(f"心跳循环异常: {type(e).__name__}: {e}")
- break
-
- self._running = False
- await self.close()
+# ============== WebSocket 客户端实例 ==============
class WebSocketClient:
@@ -368,8 +106,7 @@ def __init__(
self._running = False
self._last_ping = 0.0
self._last_pong = 0.0
- self._reconnect_count = 0
- self._tasks: list[asyncio.Task] = []
+ self._tasks: list[asyncio.Task[Any]] = []
self._auth_token: Optional[str] = auth_token
@property
@@ -392,7 +129,6 @@ async def connect(self) -> bool:
)
self._last_ping = time.monotonic()
self._last_pong = time.monotonic()
- self._reconnect_count = 0
self.logger.info(f"WebSocket 连接成功: {self.url}")
@@ -411,7 +147,7 @@ async def connect(self) -> bool:
self.logger.error(f"WebSocket 连接失败: {type(e).__name__}: {e}")
return False
- async def disconnect(self):
+ async def disconnect(self) -> None:
"""断开 WebSocket 连接"""
self._running = False
@@ -436,10 +172,6 @@ async def disconnect(self):
if asyncio.iscoroutine(result):
await result
- async def close(self, code: int = 1000, reason: str = "正常关闭"):
- """兼容统一的关闭接口。"""
- await self.disconnect()
-
async def send(self, message: Dict[str, Any]) -> bool:
"""
发送 JSON 消息
@@ -454,6 +186,10 @@ async def send(self, message: Dict[str, Any]) -> bool:
self.logger.warning("WebSocket 未连接,无法发送消息")
return False
+ if self._connection is None:
+ self.logger.warning("连接对象为空,无法发送消息")
+ return False
+
try:
await self._connection.send(json.dumps(message))
return True
@@ -461,28 +197,6 @@ async def send(self, message: Dict[str, Any]) -> bool:
self.logger.error(f"发送消息失败: {type(e).__name__}: {e}")
return False
- async def send_json(self, data: Dict[str, Any]) -> bool:
- """兼容 FastAPI WebSocket 的 send_json 接口。"""
- return await self.send(data)
-
- async def send_auth(
- self,
- token: str,
- auth_type: str = "auth",
- extra_data: Optional[Dict[str, Any]] = None,
- ) -> bool:
- """发送认证消息。"""
- self._auth_token = token
- auth_message = {
- "id": "Client",
- "type": auth_type,
- "data": {"token": token, **(extra_data or {})},
- }
- success = await self.send(auth_message)
- if success:
- self.logger.info("已发送认证消息")
- return success
-
async def _send_ping(self):
"""发送应用层 Ping"""
message = {"id": "Client", "type": "Signal", "data": {"Ping": "heartbeat"}}
@@ -496,7 +210,7 @@ async def _send_pong(self):
await self.send(message)
self.logger.debug("已发送 Pong")
- async def _handle_message(self, raw_message: str):
+ async def _handle_message(self, raw_message: str) -> None:
"""
处理接收到的消息
@@ -591,10 +305,16 @@ async def _receive_loop(self):
"""消息接收循环"""
while self._running and self.is_connected:
try:
+ conn = self._connection
+ if conn is None:
+ break
message = await asyncio.wait_for(
- self._connection.recv(), timeout=self.ping_interval
+ conn.recv(), timeout=self.ping_interval
)
- await self._handle_message(message)
+ if isinstance(message, bytes):
+ await self._handle_message(message.decode("utf-8", errors="ignore"))
+ else:
+ await self._handle_message(message)
except asyncio.TimeoutError:
# 接收超时,检查心跳状态
@@ -636,47 +356,44 @@ async def _heartbeat_loop(self):
self.logger.error(f"心跳循环异常: {type(e).__name__}: {e}")
break
- def _get_backoff_delay(self) -> float:
- """
- 计算指数退避延迟时间
+ async def _connect_or_raise(self) -> None:
+ """建立连接,失败时抛出异常以触发 tenacity 重试。"""
- Returns:
- float: 延迟时间(秒),最大60秒
- """
- # 指数退避: base_interval * 2^(reconnect_count - 1)
- delay = self.reconnect_interval * (2 ** (self._reconnect_count - 1))
- # 限制最大延迟为60秒
- return min(delay, 60.0)
+ connected = await self.connect()
+ if not connected:
+ raise ConnectionError(f"连接失败: {self.url}")
async def run(self):
"""
- 运行 WebSocket 客户端(包含自动重连,使用指数退避策略)
+ 运行 WebSocket 客户端(包含自动重连,使用 tenacity 退避策略)
"""
self._running = True
while self._running:
- # 尝试连接
- if not await self.connect():
- self._reconnect_count += 1
-
- if (
- self.max_reconnect_attempts != -1
- and self._reconnect_count > self.max_reconnect_attempts
- ):
- self.logger.error(
- f"已达到最大重连次数 ({self.max_reconnect_attempts}),停止重连"
- )
- break
-
- delay = self._get_backoff_delay()
- self.logger.info(
- f"{delay:.1f}秒后尝试重连... (第 {self._reconnect_count} 次)"
- )
- await asyncio.sleep(delay)
- continue
+ retry_stop = (
+ stop_never
+ if self.max_reconnect_attempts == -1
+ else stop_after_attempt(self.max_reconnect_attempts)
+ )
+ connected = False
+ async for attempt in AsyncRetrying(
+ stop=retry_stop,
+ wait=wait_exponential(
+ multiplier=self.reconnect_interval,
+ min=self.reconnect_interval,
+ max=60,
+ ),
+ retry=retry_if_exception_type(ConnectionError),
+ before_sleep=_log_ws_retry_before_sleep,
+ reraise=False,
+ ):
+ with attempt:
+ await self._connect_or_raise()
+ connected = True
- # 连接成功,重置重连计数
- self._reconnect_count = 0
+ if not connected:
+ self.logger.error("连接重试已耗尽,停止客户端")
+ break
# 启动接收和心跳任务
receive_task = asyncio.create_task(self._receive_loop())
@@ -684,7 +401,7 @@ async def run(self):
self._tasks = [receive_task, heartbeat_task]
# 等待任一任务结束
- done, pending = await asyncio.wait(
+ _done, pending = await asyncio.wait(
self._tasks, return_when=asyncio.FIRST_COMPLETED
)
@@ -714,22 +431,7 @@ async def run(self):
# 检查是否需要重连
if not self._running:
break
-
- self._reconnect_count += 1
- if (
- self.max_reconnect_attempts != -1
- and self._reconnect_count > self.max_reconnect_attempts
- ):
- self.logger.error(
- f"已达到最大重连次数 ({self.max_reconnect_attempts}),停止重连"
- )
- break
-
- delay = self._get_backoff_delay()
- self.logger.info(
- f"{delay:.1f}秒后尝试重连... (第 {self._reconnect_count} 次)"
- )
- await asyncio.sleep(delay)
+ self.logger.info("连接中断,将按重试策略重新建立连接")
self.logger.info("WebSocket 客户端已停止")
@@ -748,7 +450,7 @@ async def run_once(self):
self._tasks = [receive_task, heartbeat_task]
# 等待任一任务结束
- done, pending = await asyncio.wait(
+ _done, pending = await asyncio.wait(
self._tasks, return_when=asyncio.FIRST_COMPLETED
)
@@ -798,195 +500,24 @@ class WSClientManager:
"""WebSocket 客户端管理器,用于管理多个 WebSocket 客户端实例"""
# 系统客户端名称常量
- MAIN_CLIENT_NAME = "Main"
KOISHI_CLIENT_NAME = "Koishi"
- _CHANNEL_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
def __init__(self):
self._clients: Dict[str, WebSocketClient] = {}
- self._reverse_sessions: Dict[str, Any] = {}
self._system_clients: set[str] = set() # 系统客户端名称集合
- self._tasks: Dict[str, asyncio.Task] = {}
- self._message_history: Dict[str, List[Dict[str, Any]]] = {}
- self._reverse_channel_registry: Dict[str, Dict[str, Any]] = {}
+ self._tasks: Dict[str, asyncio.Task[Any]] = {}
+ self._message_history: Dict[str, Deque[Dict[str, Any]]] = {}
self._max_history_per_client = 200
- self._debug_connections: List[Any] = [] # WebSocket 连接列表
+ self._debug_connections: list[Any] = [] # WebSocket 连接列表
self._logger = get_logger("WS管理器")
- self._reserved_reverse_channels: set[str] = {
- "client",
- "message",
- "history",
- "commands",
- }
- self._system_clients.add(self.MAIN_CLIENT_NAME)
- self._system_clients.add(self.KOISHI_CLIENT_NAME)
-
- def register_reverse_channel(
- self,
- name: str,
- ping_interval: float = 15.0,
- ping_timeout: float = 30.0,
- auth_token: Optional[str] = None,
- on_message: Optional[Callable[[Dict[str, Any]], Any]] = None,
- on_connect: Optional[Callable[[], Any]] = None,
- on_disconnect: Optional[Callable[[], Any]] = None,
- overwrite: bool = True,
- ):
- """
- 注册反向 WebSocket 通道声明。
-
- Args:
- name: 通道名称,将映射为 `/api/ws/{name}`。
- ping_interval: 应用层心跳发送间隔(秒)。
- ping_timeout: Pong 超时时间(秒)。
- auth_token: 连接建立后自动发送的认证 Token。
- on_message: 收到业务消息时回调。
- on_connect: 连接建立时回调。
- on_disconnect: 连接断开时回调。
- overwrite: 若同名声明已存在,是否覆盖。
-
- Raises:
- ValueError: 当 `name` 为空、格式非法、命中保留名称或且已存在但不允许覆盖时抛出。
- """
- normalized_name = (name or "").strip()
- if not normalized_name:
- raise ValueError("通道名称不能为空")
-
- if not self._CHANNEL_NAME_PATTERN.fullmatch(normalized_name):
- raise ValueError("通道名称仅支持字母、数字、下划线和中划线")
-
- if normalized_name in self._reserved_reverse_channels:
- raise ValueError(f"通道名称 [{normalized_name}] 为保留路径,不能注册")
-
- if not overwrite and normalized_name in self._reverse_channel_registry:
- raise ValueError(f"通道声明 [{normalized_name}] 已存在")
-
- self._reverse_channel_registry[normalized_name] = {
- "name": normalized_name,
- "ping_interval": ping_interval,
- "ping_timeout": ping_timeout,
- "auth_token": auth_token,
- "on_message": on_message,
- "on_connect": on_connect,
- "on_disconnect": on_disconnect,
- "updated_at": time.time(),
- }
-
- def unregister_reverse_channel(self, name: str) -> bool:
- """
- 注销反向 WebSocket 通道声明。
-
- Args:
- name: 目标通道名称。
-
- Returns:
- bool: 当声明存在并成功移除时返回 `True`,否则返回 `False`。
-
- Raises:
- ValueError: 当 `name` 为空时抛出。
- """
- normalized_name = (name or "").strip()
- if not normalized_name:
- raise ValueError("通道名称不能为空")
-
- if normalized_name in self._reverse_channel_registry:
- del self._reverse_channel_registry[normalized_name]
- return True
- return False
-
- def is_reverse_channel_registered(self, name: str) -> bool:
- """
- 检查反向通道是否已声明。
-
- Args:
- name: 目标通道名称。
-
- Returns:
- bool: 已声明返回 `True`,否则返回 `False`。
- """
- return (name or "").strip() in self._reverse_channel_registry
-
- def get_reverse_channel_config(self, name: str) -> Optional[Dict[str, Any]]:
- """
- 获取单个反向通道声明配置。
-
- Args:
- name: 目标通道名称。
-
- Returns:
- Optional[Dict[str, Any]]: 若存在返回配置浅拷贝,否则返回 `None`。
- """
- config = self._reverse_channel_registry.get((name or "").strip())
- if config is None:
- return None
- return config.copy()
-
- def list_reverse_channels(self) -> Dict[str, Dict[str, Any]]:
- """
- 列出全部反向通道声明。
-
- Returns:
- Dict[str, Dict[str, Any]]: 键为通道名,值为配置浅拷贝。
- """
- return {
- channel_name: channel_config.copy()
- for channel_name, channel_config in self._reverse_channel_registry.items()
- }
def get_client(self, name: str) -> Optional[WebSocketClient]:
"""获取客户端实例"""
- client = self._clients.get(name)
- if client is not None:
- return client
- return self._reverse_sessions.get(name)
-
- def get_session(self, name: str) -> Optional[Any]:
- """获取任意方向的会话实例。"""
- return self._clients.get(name) or self._reverse_sessions.get(name)
-
- async def wait_for_reverse_session(self, name: str, timeout: Optional[float] = None):
- """
- 等待指定反向会话实例出现并返回。
-
- 该方法适用于“通道已声明,但连接尚未建立”的场景,
- 会持续轮询 `_reverse_sessions` 直到会话可用或超时。
-
- Args:
- name: 反向会话名称。
- timeout: 最大等待时长(秒)。传 `None` 表示无限等待。
-
- Returns:
- Any: 等待到的反向会话实例。
-
- Raises:
- ValueError: 当 `name` 为空,或 `timeout` 小于等于 0 时抛出。
- TimeoutError: 当在 `timeout` 时间内仍未等到会话实例时抛出。
- """
- normalized_name = (name or "").strip()
- if not normalized_name:
- raise ValueError("会话名称不能为空")
-
- if timeout is not None and timeout <= 0:
- raise ValueError("timeout 必须大于 0")
-
- loop = asyncio.get_running_loop()
- deadline = None if timeout is None else (loop.time() + timeout)
-
- while True:
- session = self._reverse_sessions.get(normalized_name)
- if session is not None:
- return session
-
- if deadline is not None and loop.time() >= deadline:
- raise TimeoutError(
- f"等待反向会话 [{normalized_name}] 超时({timeout}秒)"
- )
-
- await asyncio.sleep(0.5)
+ return self._clients.get(name)
def has_client(self, name: str) -> bool:
"""检查客户端是否存在"""
- return name in self._clients or name in self._reverse_sessions
+ return name in self._clients
def is_system_client(self, name: str) -> bool:
"""检查是否为系统客户端"""
@@ -994,14 +525,13 @@ def is_system_client(self, name: str) -> bool:
def list_clients(self) -> Dict[str, Dict[str, Any]]:
"""列出所有客户端及其状态"""
- result = {}
- for name, client in {**self._clients, **self._reverse_sessions}.items():
+ result: Dict[str, Dict[str, Any]] = {}
+ for name, client in self._clients.items():
result[name] = {
"name": name,
"url": client.url,
"is_connected": client.is_connected,
"is_system": name in self._system_clients,
- "direction": getattr(client, "direction", "outbound"),
"ping_interval": client.ping_interval,
"ping_timeout": client.ping_timeout,
"reconnect_interval": client.reconnect_interval,
@@ -1021,11 +551,8 @@ async def create_client(
) -> WebSocketClient:
"""创建新的 WebSocket 客户端"""
- if name in self._reverse_sessions and name in self._system_clients:
- raise ValueError(f"系统会话 [{name}] 已被占用,不能创建同名正向客户端")
-
# 如果已存在同名客户端,先移除
- if name in self._clients or name in self._reverse_sessions:
+ if name in self._clients:
await self.remove_client(name)
# 创建消息回调
@@ -1067,133 +594,16 @@ async def on_disconnect():
)
self._clients[name] = client
- self._message_history[name] = []
-
- await self._broadcast_event(
- {
- "event": "created",
- "client": name,
- "url": url,
- "timestamp": time.time(),
- }
- )
+ self._message_history[name] = deque(maxlen=self._max_history_per_client)
self._logger.info(f"已创建 WebSocket 客户端: {name} -> {url}")
return client
- async def openws(
- self,
- name: str,
- url: str,
- ping_interval: float = 15.0,
- ping_timeout: float = 30.0,
- reconnect_interval: float = 5.0,
- max_reconnect_attempts: int = -1,
- ) -> WebSocketClient:
- """正式的正向 WebSocket 打开接口。"""
- client = await self.create_client(
- name=name,
- url=url,
- ping_interval=ping_interval,
- ping_timeout=ping_timeout,
- reconnect_interval=reconnect_interval,
- max_reconnect_attempts=max_reconnect_attempts,
- )
- await self.connect_client(name)
- return client
-
- async def openwsr(
- self,
- name: str,
- websocket: Any,
- ping_interval: float = 15.0,
- ping_timeout: float = 30.0,
- auth_token: Optional[str] = None,
- on_message: Optional[Callable[[Dict[str, Any]], Any]] = None,
- on_connect: Optional[Callable[[], Any]] = None,
- on_disconnect: Optional[Callable[[], Any]] = None,
- ) -> ReverseWebSocketSession:
- """正式的反向 WebSocket 打开接口。"""
- if name in self._clients and name in self._system_clients:
- raise ValueError(f"系统会话 [{name}] 已被正向客户端占用,不能创建同名反向会话")
-
- if name in self._clients:
- await self.remove_client(name)
-
- if name in self._reverse_sessions:
- await self.disconnect_client(name)
-
- async def wrapped_on_message(data: Dict[str, Any]):
- await self._record_message(name, "received", data)
- if on_message:
- result = on_message(data)
- if asyncio.iscoroutine(result):
- await result
-
- async def wrapped_on_connect():
- self._logger.info(f"反向会话 [{name}] 已连接")
- await self._broadcast_event(
- {
- "event": "connected",
- "client": name,
- "url": f"reverse://{name}",
- "direction": "inbound",
- "timestamp": time.time(),
- }
- )
- if on_connect:
- result = on_connect()
- if asyncio.iscoroutine(result):
- await result
-
- async def wrapped_on_disconnect():
- self._logger.info(f"反向会话 [{name}] 已断开")
- await self._broadcast_event(
- {
- "event": "disconnected",
- "client": name,
- "direction": "inbound",
- "timestamp": time.time(),
- }
- )
- if on_disconnect:
- result = on_disconnect()
- if asyncio.iscoroutine(result):
- await result
-
- session = ReverseWebSocketSession(
- websocket=websocket,
- name=name,
- ping_interval=ping_interval,
- ping_timeout=ping_timeout,
- on_message=wrapped_on_message,
- on_connect=wrapped_on_connect,
- on_disconnect=wrapped_on_disconnect,
- auth_token=auth_token,
- )
- self._reverse_sessions[name] = session
- self._message_history[name] = []
-
- await self._broadcast_event(
- {
- "event": "created",
- "client": name,
- "url": session.url,
- "direction": session.direction,
- "timestamp": time.time(),
- }
- )
-
- self._logger.info(f"已创建反向 WebSocket 会话: {name} -> {session.url}")
- await session.start()
- return session
-
async def connect_client(self, name: str) -> bool:
"""连接客户端(非阻塞方式启动)"""
client = self._clients.get(name)
if not client:
- session = self._reverse_sessions.get(name)
- return session.is_connected if session else False
+ return False
if client.is_connected:
return True
@@ -1233,14 +643,10 @@ async def _run_client_with_reconnect(self, name: str, client: WebSocketClient):
async def disconnect_client(self, name: str) -> bool:
"""断开客户端连接"""
client = self._clients.get(name)
- session = self._reverse_sessions.get(name)
- if not client and not session:
+ if not client:
return False
- if client:
- await client.disconnect()
- if session:
- await session.disconnect()
+ await client.disconnect()
# 取消任务
if name in self._tasks:
@@ -1253,14 +659,11 @@ async def disconnect_client(self, name: str) -> bool:
pass
del self._tasks[name]
- if name in self._reverse_sessions and name not in self._system_clients:
- del self._reverse_sessions[name]
-
return True
async def remove_client(self, name: str) -> bool:
"""删除客户端(系统客户端不可删除)"""
- if name not in self._clients and name not in self._reverse_sessions:
+ if name not in self._clients:
return False
# 系统客户端不可删除
@@ -1272,29 +675,18 @@ async def remove_client(self, name: str) -> bool:
await self.disconnect_client(name)
# 删除客户端
- if name in self._clients:
- del self._clients[name]
- if name in self._reverse_sessions:
- del self._reverse_sessions[name]
+ del self._clients[name]
# 清理消息历史
if name in self._message_history:
del self._message_history[name]
- await self._broadcast_event(
- {
- "event": "removed",
- "client": name,
- "timestamp": time.time(),
- }
- )
-
self._logger.info(f"已删除 WebSocket 客户端: {name}")
return True
async def send_message(self, name: str, message: Dict[str, Any]) -> bool:
"""发送消息"""
- client = self.get_session(name)
+ client = self._clients.get(name)
if not client or not client.is_connected:
return False
@@ -1311,18 +703,6 @@ async def send_auth(
extra_data: Optional[Dict[str, Any]] = None,
) -> bool:
"""发送认证消息"""
- client = self.get_session(name)
- if not client or not client.is_connected:
- return False
-
- if hasattr(client, "send_auth"):
- try:
- return await client.send_auth(
- token=token, auth_type=auth_type, extra_data=extra_data
- )
- except TypeError:
- pass
-
auth_message = {
"id": "Client",
"type": auth_type,
@@ -1333,16 +713,12 @@ async def send_auth(
async def _record_message(self, name: str, direction: str, data: Dict[str, Any]):
"""记录消息"""
if name not in self._message_history:
- self._message_history[name] = []
+ self._message_history[name] = deque(maxlen=self._max_history_per_client)
record = {"direction": direction, "timestamp": time.time(), "data": data}
self._message_history[name].append(record)
- # 限制历史记录数量
- if len(self._message_history[name]) > self._max_history_per_client:
- self._message_history[name].pop(0)
-
# 广播给调试前端
await self._broadcast_message(name, record)
@@ -1352,17 +728,13 @@ async def _broadcast_message(self, client_name: str, record: Dict[str, Any]):
await self._broadcast(message)
async def _broadcast_event(self, event: Dict[str, Any]):
- """广播事件给调试前端,并附带最新客户端列表快照。"""
- message = {
- "type": "event",
- "clients": list(self.list_clients().values()),
- **event,
- }
+ """广播事件给调试前端"""
+ message = {"type": "event", **event}
await self._broadcast(message)
async def _broadcast(self, data: Dict[str, Any]):
"""广播数据给所有调试前端"""
- disconnected = []
+ disconnected: list[Any] = []
for ws in self._debug_connections:
try:
await ws.send_json(data)
@@ -1393,37 +765,30 @@ async def _auto_auth_koishi(self):
def get_message_history(
self, name: Optional[str] = None
- ) -> Dict[str, List[Dict[str, Any]]]:
+ ) -> Dict[str, list[Dict[str, Any]]]:
"""获取消息历史"""
if name:
- return {name: self._message_history.get(name, [])}
- return self._message_history.copy()
+ return {name: list(self._message_history.get(name, deque()))}
+ return {
+ client_name: list(records)
+ for client_name, records in self._message_history.items()
+ }
def clear_message_history(self, name: Optional[str] = None):
"""清空消息历史"""
if name:
if name in self._message_history:
- self._message_history[name] = []
+ self._message_history[name].clear()
else:
for key in self._message_history:
- self._message_history[key] = []
+ self._message_history[key].clear()
def add_debug_connection(self, ws: Any):
- """
- 添加调试前端连接。
-
- Args:
- ws: 调试前端连接对象。
- """
+ """添加调试前端连接"""
self._debug_connections.append(ws)
def remove_debug_connection(self, ws: Any):
- """
- 移除调试前端连接。
-
- Args:
- ws: 调试前端连接对象。
- """
+ """移除调试前端连接"""
if ws in self._debug_connections:
self._debug_connections.remove(ws)
@@ -1463,7 +828,7 @@ async def init_system_client_koishi(self) -> bool:
return False
ws_url = self.http_to_ws_url(http_url)
- token = Config.get("Notify", "KoishiToken")
+ Config.get("Notify", "KoishiToken")
self._logger.info(f"正在初始化 Koishi 系统客户端: {ws_url}")
@@ -1489,7 +854,7 @@ async def init_system_client_koishi(self) -> bool:
# 认证已在 on_connect 回调中自动处理
return True
else:
- self._logger.warning(f"Koishi 系统客户端连接失败,将在后台持续重连")
+ self._logger.warning("Koishi 系统客户端连接失败,将在后台持续重连")
return False
except Exception as e:
@@ -1505,7 +870,6 @@ async def update_system_client_koishi(self) -> bool:
Returns:
bool: 是否成功更新
"""
- from app.core import Config
# 如果客户端存在,先断开
if self.has_client(self.KOISHI_CLIENT_NAME):
@@ -1530,7 +894,15 @@ async def create_ws_client(
port: int = 5140,
path: str = "/ws",
use_ssl: bool = False,
- **kwargs,
+ ping_interval: float = 15.0,
+ ping_timeout: float = 30.0,
+ reconnect_interval: float = 5.0,
+ max_reconnect_attempts: int = -1,
+ on_message: Optional[Callable[[Dict[str, Any]], Any]] = None,
+ on_connect: Optional[Callable[[], Any]] = None,
+ on_disconnect: Optional[Callable[[], Any]] = None,
+ name: Optional[str] = None,
+ auth_token: Optional[str] = None,
) -> WebSocketClient:
"""
创建 WebSocket 客户端实例
@@ -1540,14 +912,33 @@ async def create_ws_client(
port: 服务器端口
path: WebSocket 路径
use_ssl: 是否使用 SSL
- **kwargs: 传递给 WebSocketClient 的其他参数
+ ping_interval: 心跳间隔
+ ping_timeout: 心跳超时
+ reconnect_interval: 重连间隔
+ max_reconnect_attempts: 最大重连次数
+ on_message: 收到消息回调
+ on_connect: 连接成功回调
+ on_disconnect: 断开连接回调
+ name: 客户端名称
+ auth_token: 认证 token
Returns:
WebSocketClient: 客户端实例
"""
protocol = "wss" if use_ssl else "ws"
url = f"{protocol}://{host}:{port}{path}"
- return WebSocketClient(url=url, **kwargs)
+ return WebSocketClient(
+ url=url,
+ ping_interval=ping_interval,
+ ping_timeout=ping_timeout,
+ reconnect_interval=reconnect_interval,
+ max_reconnect_attempts=max_reconnect_attempts,
+ on_message=on_message,
+ on_connect=on_connect,
+ on_disconnect=on_disconnect,
+ name=name,
+ auth_token=auth_token,
+ )
# 使用示例
@@ -1576,7 +967,9 @@ async def send_messages():
# 等待客户端连接成功
while not client1.is_connected:
await asyncio.sleep(0.1)
- await client1._authenticate(token="123456")
+ await client1.send(
+ {"id": "Client", "type": "auth", "data": {"token": "123456"}}
+ )
# 发送测试消息
for i in range(5):
@@ -1590,7 +983,7 @@ async def send_messages():
if success:
print(f"[发送成功] -> Server1: {message}")
else:
- print(f"[发送失败] -> Server1")
+ print("[发送失败] -> Server1")
await asyncio.sleep(3) # 每3秒发送一次
diff --git a/export_openapi.py b/export_openapi.py
new file mode 100644
index 00000000..bc8a3417
--- /dev/null
+++ b/export_openapi.py
@@ -0,0 +1,120 @@
+# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software
+# Copyright © 2024-2025 DLmaster361
+# Copyright © 2025-2026 AUTO-MAS Team
+
+# This file is part of AUTO-MAS.
+
+# AUTO-MAS is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of
+# the License, or (at your option) any later version.
+
+# AUTO-MAS is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty
+# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
+# the GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with AUTO-MAS. If not, see .
+
+# Contact: DLmaster_361@163.com
+
+"""
+导出 OpenAPI schema 到文件,不启动 HTTP 服务器。
+用于前端代码生成:npm run openapi
+"""
+
+import json
+import os
+import sys
+import types
+from pathlib import Path
+
+current_dir = Path(__file__).resolve().parent
+if str(current_dir) not in sys.path:
+ sys.path.insert(0, str(current_dir))
+
+# 先解析输出路径(必须在 chdir 之前,否则相对路径会基于错误的目录)
+if __name__ == "__main__":
+ _output_path = (
+ Path(sys.argv[1]).resolve() if len(sys.argv) > 1
+ else current_dir / "frontend" / "openapi.json"
+ )
+else:
+ _output_path = current_dir / "frontend" / "openapi.json"
+
+# 确保工作目录为项目根目录(Config 初始化依赖 Path.cwd() 是 git 仓库根目录)
+os.chdir(current_dir)
+
+try:
+ import pyautogui # noqa: F401
+except ModuleNotFoundError:
+ stub = types.ModuleType("pyautogui")
+ stub.KEYBOARD_KEYS = []
+ sys.modules["pyautogui"] = stub
+
+
+def export_schema(output_path: Path) -> None:
+ # 必须在函数内部导入,否则触发循环导入(同 main.py 的设计)
+ from contextlib import asynccontextmanager
+ from fastapi import FastAPI
+ from fastapi.middleware.cors import CORSMiddleware
+ from app.api import (
+ core_router,
+ info_router,
+ scripts_router,
+ plan_router,
+ emulator_router,
+ queue_router,
+ dispatch_router,
+ history_router,
+ tools_router,
+ plugins_router,
+ setting_router,
+ update_router,
+ ocr_router,
+ ws_debug_router,
+ )
+
+ @asynccontextmanager
+ async def dummy_lifespan(app: FastAPI):
+ yield
+
+ app = FastAPI(
+ title="AUTO-MAS",
+ description="API for managing automation scripts, plans, and tasks",
+ version="1.0.0",
+ lifespan=dummy_lifespan,
+ )
+
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+
+ app.include_router(core_router)
+ app.include_router(info_router)
+ app.include_router(scripts_router)
+ app.include_router(plan_router)
+ app.include_router(emulator_router)
+ app.include_router(queue_router)
+ app.include_router(dispatch_router)
+ app.include_router(history_router)
+ app.include_router(tools_router)
+ app.include_router(plugins_router)
+ app.include_router(setting_router)
+ app.include_router(update_router)
+ app.include_router(ocr_router)
+ app.include_router(ws_debug_router)
+
+ schema = app.openapi()
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text(json.dumps(schema, ensure_ascii=False, indent=2), encoding="utf-8")
+ print(f"OpenAPI schema 已导出到: {output_path}")
+
+
+if __name__ == "__main__":
+ export_schema(_output_path)
diff --git a/frontend/electron/services/backendService.ts b/frontend/electron/services/backendService.ts
index e1e0867c..7d8c7e0c 100644
--- a/frontend/electron/services/backendService.ts
+++ b/frontend/electron/services/backendService.ts
@@ -89,11 +89,20 @@ export class BackendService {
this.isCapturingStartupLogs = true
+ const backendEnv: NodeJS.ProcessEnv = {
+ ...process.env,
+ PYTHONIOENCODING: 'utf-8',
+ }
+
+ if (process.env.VITE_DEV_SERVER_URL) {
+ backendEnv.AUTO_MAS_DEV = '1'
+ }
+
// 启动后端进程
this.backendProcess = spawn(pythonExe, [mainPy], {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
- env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
+ env: backendEnv,
})
this.startTime = new Date()
diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs
index 1e03225d..d95439c6 100644
--- a/frontend/eslint.config.mjs
+++ b/frontend/eslint.config.mjs
@@ -40,6 +40,26 @@ export default [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
+ 'no-restricted-imports': [
+ 'error',
+ {
+ patterns: [
+ {
+ group: [
+ '@/api/generated',
+ '@/api/generated/*',
+ '@/api/models',
+ '@/api/models/*',
+ '@/api/services',
+ '@/api/services/*',
+ '@/api/core',
+ '@/api/core/*',
+ ],
+ message: '请从 @/api 顶层出口导入,不要直接依赖生成层或旧目录',
+ },
+ ],
+ },
+ ],
},
},
@@ -109,7 +129,7 @@ export default [
'**/*.d.ts',
'**/*.js',
// 忽略自动生成的 API 文件
- 'src/api/*',
+ 'src/api/generated/**',
],
},
]
diff --git a/frontend/openapi-ts.config.mjs b/frontend/openapi-ts.config.mjs
new file mode 100644
index 00000000..08127404
--- /dev/null
+++ b/frontend/openapi-ts.config.mjs
@@ -0,0 +1,12 @@
+import { defineConfig } from '@hey-api/openapi-ts'
+
+export default defineConfig({
+ input: './openapi.json',
+ output: './src/api/generated',
+ client: 'legacy/axios',
+ useOptions: false,
+ exportCore: true,
+ exportServices: true,
+ exportModels: true,
+ exportSchemas: false,
+})
diff --git a/frontend/openapi.json b/frontend/openapi.json
new file mode 100644
index 00000000..464992bd
--- /dev/null
+++ b/frontend/openapi.json
@@ -0,0 +1,13039 @@
+{
+ "openapi": "3.1.0",
+ "info": {
+ "title": "AUTO-MAS",
+ "description": "API for managing automation scripts, plans, and tasks",
+ "version": "1.0.0"
+ },
+ "paths": {
+ "/api/core/close": {
+ "post": {
+ "tags": [
+ "核心信息"
+ ],
+ "summary": "关闭后端程序",
+ "description": "关闭后端程序",
+ "operationId": "close_api_core_close_post",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/info/version": {
+ "post": {
+ "tags": [
+ "信息获取",
+ "Get"
+ ],
+ "summary": "获取后端git版本信息",
+ "operationId": "get_git_version_api_info_version_post",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/VersionOut"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/info/combox/stage": {
+ "post": {
+ "tags": [
+ "信息获取",
+ "Get"
+ ],
+ "summary": "获取关卡号下拉框信息",
+ "operationId": "get_stage_combox_api_info_combox_stage_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GetStageIn",
+ "description": "关卡号类型"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ComboBoxOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/info/combox/script": {
+ "post": {
+ "tags": [
+ "信息获取",
+ "Get"
+ ],
+ "summary": "获取脚本下拉框信息",
+ "operationId": "get_script_combox_api_info_combox_script_post",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ComboBoxOut"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/info/combox/task": {
+ "post": {
+ "tags": [
+ "信息获取",
+ "Get"
+ ],
+ "summary": "获取可选任务下拉框信息",
+ "operationId": "get_task_combox_api_info_combox_task_post",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ComboBoxOut"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/info/combox/plan": {
+ "post": {
+ "tags": [
+ "信息获取",
+ "Get"
+ ],
+ "summary": "获取可选计划下拉框信息",
+ "operationId": "get_plan_combox_api_info_combox_plan_post",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ComboBoxOut"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/info/combox/emulator": {
+ "post": {
+ "tags": [
+ "信息获取",
+ "Get"
+ ],
+ "summary": "获取可选模拟器下拉框信息",
+ "operationId": "get_emulator_combox_api_info_combox_emulator_post",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ComboBoxOut"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/info/combox/emulator/devices": {
+ "post": {
+ "tags": [
+ "信息获取",
+ "Get"
+ ],
+ "summary": "获取可选模拟器多开实例下拉框信息",
+ "operationId": "get_emulator_devices_combox_api_info_combox_emulator_devices_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/EmulatorIdBody"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ComboBoxOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/info/notice/get": {
+ "post": {
+ "tags": [
+ "信息获取",
+ "Get"
+ ],
+ "summary": "获取通知信息",
+ "operationId": "get_notice_info_api_info_notice_get_post",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/NoticeOut"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/info/notice/confirm": {
+ "post": {
+ "tags": [
+ "信息获取",
+ "Action"
+ ],
+ "summary": "确认通知",
+ "operationId": "confirm_notice_api_info_notice_confirm_post",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/info/webconfig": {
+ "post": {
+ "tags": [
+ "信息获取",
+ "Get"
+ ],
+ "summary": "获取配置分享中心的配置信息",
+ "operationId": "get_web_config_api_info_webconfig_post",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InfoOut"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/info/get/overview": {
+ "post": {
+ "tags": [
+ "信息获取",
+ "Get"
+ ],
+ "summary": "信息总览",
+ "operationId": "get_overview_api_info_get_overview_post",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InfoOut"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/scripts": {
+ "get": {
+ "tags": [
+ "脚本管理",
+ "Get"
+ ],
+ "summary": "查询全部脚本",
+ "operationId": "list_scripts_api_scripts_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ScriptGetOut"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "脚本管理",
+ "Add"
+ ],
+ "summary": "创建脚本",
+ "operationId": "create_script_api_scripts_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ScriptCreateIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ScriptCreateOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/scripts/order": {
+ "patch": {
+ "tags": [
+ "脚本管理",
+ "Update"
+ ],
+ "summary": "重新排序脚本",
+ "operationId": "reorder_scripts_api_scripts_order_patch",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/IndexOrderPatch"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/scripts/{script_id}": {
+ "get": {
+ "tags": [
+ "脚本管理",
+ "Get"
+ ],
+ "summary": "查询单个脚本",
+ "operationId": "get_script_api_scripts__script_id__get",
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "脚本 ID",
+ "title": "Script Id"
+ },
+ "description": "脚本 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ScriptDetailOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "tags": [
+ "脚本管理",
+ "Update"
+ ],
+ "summary": "更新脚本配置",
+ "operationId": "update_script_api_scripts__script_id__patch",
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "脚本 ID",
+ "title": "Script Id"
+ },
+ "description": "脚本 ID"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ScriptPatchBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "脚本管理",
+ "Delete"
+ ],
+ "summary": "删除脚本",
+ "operationId": "delete_script_api_scripts__script_id__delete",
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "脚本 ID",
+ "title": "Script Id"
+ },
+ "description": "脚本 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/scripts/{script_id}/actions/import-file": {
+ "post": {
+ "tags": [
+ "脚本管理",
+ "Action"
+ ],
+ "summary": "从文件导入脚本配置",
+ "operationId": "import_script_from_file_api_scripts__script_id__actions_import_file_post",
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "脚本 ID",
+ "title": "Script Id"
+ },
+ "description": "脚本 ID"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ScriptFileBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/scripts/{script_id}/actions/export-file": {
+ "post": {
+ "tags": [
+ "脚本管理",
+ "Action"
+ ],
+ "summary": "导出脚本配置到文件",
+ "operationId": "export_script_to_file_api_scripts__script_id__actions_export_file_post",
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "脚本 ID",
+ "title": "Script Id"
+ },
+ "description": "脚本 ID"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ScriptFileBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/scripts/{script_id}/actions/import-web": {
+ "post": {
+ "tags": [
+ "脚本管理",
+ "Action"
+ ],
+ "summary": "从网络导入脚本配置",
+ "operationId": "import_script_from_web_api_scripts__script_id__actions_import_web_post",
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "脚本 ID",
+ "title": "Script Id"
+ },
+ "description": "脚本 ID"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ScriptUrlBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/scripts/{script_id}/actions/upload-web": {
+ "post": {
+ "tags": [
+ "脚本管理",
+ "Action"
+ ],
+ "summary": "上传脚本配置到网络",
+ "operationId": "upload_script_to_web_api_scripts__script_id__actions_upload_web_post",
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "脚本 ID",
+ "title": "Script Id"
+ },
+ "description": "脚本 ID"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ScriptUploadBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/scripts/{script_id}/users": {
+ "get": {
+ "tags": [
+ "脚本管理",
+ "Get"
+ ],
+ "summary": "查询脚本下的全部用户",
+ "operationId": "list_users_api_scripts__script_id__users_get",
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "脚本 ID",
+ "title": "Script Id"
+ },
+ "description": "脚本 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UserGetOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "脚本管理",
+ "Add"
+ ],
+ "summary": "创建用户",
+ "operationId": "create_user_api_scripts__script_id__users_post",
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "脚本 ID",
+ "title": "Script Id"
+ },
+ "description": "脚本 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UserCreateOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/scripts/{script_id}/users/order": {
+ "patch": {
+ "tags": [
+ "脚本管理",
+ "Update"
+ ],
+ "summary": "重新排序用户",
+ "operationId": "reorder_users_api_scripts__script_id__users_order_patch",
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "脚本 ID",
+ "title": "Script Id"
+ },
+ "description": "脚本 ID"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/IndexOrderPatch"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/scripts/{script_id}/users/{user_id}": {
+ "get": {
+ "tags": [
+ "脚本管理",
+ "Get"
+ ],
+ "summary": "查询单个用户",
+ "operationId": "get_user_api_scripts__script_id__users__user_id__get",
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "脚本 ID",
+ "title": "Script Id"
+ },
+ "description": "脚本 ID"
+ },
+ {
+ "name": "user_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "用户 ID",
+ "title": "User Id"
+ },
+ "description": "用户 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UserDetailOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "tags": [
+ "脚本管理",
+ "Update"
+ ],
+ "summary": "更新用户配置",
+ "operationId": "update_user_api_scripts__script_id__users__user_id__patch",
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "脚本 ID",
+ "title": "Script Id"
+ },
+ "description": "脚本 ID"
+ },
+ {
+ "name": "user_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "用户 ID",
+ "title": "User Id"
+ },
+ "description": "用户 ID"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UserPatchBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "脚本管理",
+ "Delete"
+ ],
+ "summary": "删除用户",
+ "operationId": "delete_user_api_scripts__script_id__users__user_id__delete",
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "脚本 ID",
+ "title": "Script Id"
+ },
+ "description": "脚本 ID"
+ },
+ {
+ "name": "user_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "用户 ID",
+ "title": "User Id"
+ },
+ "description": "用户 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/scripts/{script_id}/users/{user_id}/actions/import-infrastructure": {
+ "post": {
+ "tags": [
+ "脚本管理",
+ "Action"
+ ],
+ "summary": "导入基建配置文件",
+ "operationId": "import_infrastructure_api_scripts__script_id__users__user_id__actions_import_infrastructure_post",
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "脚本 ID",
+ "title": "Script Id"
+ },
+ "description": "脚本 ID"
+ },
+ {
+ "name": "user_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "用户 ID",
+ "title": "User Id"
+ },
+ "description": "用户 ID"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/InfrastructureImportBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/scripts/{script_id}/users/{user_id}/infrastructure-options": {
+ "get": {
+ "tags": [
+ "脚本管理",
+ "Get"
+ ],
+ "summary": "用户自定义基建排班可选项",
+ "operationId": "get_user_infrastructure_options_api_scripts__script_id__users__user_id__infrastructure_options_get",
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "脚本 ID",
+ "title": "Script Id"
+ },
+ "description": "脚本 ID"
+ },
+ {
+ "name": "user_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "用户 ID",
+ "title": "User Id"
+ },
+ "description": "用户 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ComboBoxOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/scripts/{script_id}/users/{user_id}/webhooks": {
+ "get": {
+ "tags": [
+ "脚本管理",
+ "Get"
+ ],
+ "summary": "查询用户下的全部 Webhook",
+ "operationId": "list_user_webhooks_api_scripts__script_id__users__user_id__webhooks_get",
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "脚本 ID",
+ "title": "Script Id"
+ },
+ "description": "脚本 ID"
+ },
+ {
+ "name": "user_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "用户 ID",
+ "title": "User Id"
+ },
+ "description": "用户 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WebhookGetOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "脚本管理",
+ "Add"
+ ],
+ "summary": "创建用户 Webhook",
+ "operationId": "create_user_webhook_api_scripts__script_id__users__user_id__webhooks_post",
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "脚本 ID",
+ "title": "Script Id"
+ },
+ "description": "脚本 ID"
+ },
+ {
+ "name": "user_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "用户 ID",
+ "title": "User Id"
+ },
+ "description": "用户 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WebhookCreateOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/scripts/{script_id}/users/{user_id}/webhooks/order": {
+ "patch": {
+ "tags": [
+ "脚本管理",
+ "Update"
+ ],
+ "summary": "重新排序用户 Webhook",
+ "operationId": "reorder_user_webhooks_api_scripts__script_id__users__user_id__webhooks_order_patch",
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "脚本 ID",
+ "title": "Script Id"
+ },
+ "description": "脚本 ID"
+ },
+ {
+ "name": "user_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "用户 ID",
+ "title": "User Id"
+ },
+ "description": "用户 ID"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/IndexOrderPatch"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/scripts/{script_id}/users/{user_id}/webhooks/{webhook_id}": {
+ "get": {
+ "tags": [
+ "脚本管理",
+ "Get"
+ ],
+ "summary": "查询单个用户 Webhook",
+ "operationId": "get_user_webhook_api_scripts__script_id__users__user_id__webhooks__webhook_id__get",
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "脚本 ID",
+ "title": "Script Id"
+ },
+ "description": "脚本 ID"
+ },
+ {
+ "name": "user_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "用户 ID",
+ "title": "User Id"
+ },
+ "description": "用户 ID"
+ },
+ {
+ "name": "webhook_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "Webhook ID",
+ "title": "Webhook Id"
+ },
+ "description": "Webhook ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WebhookDetailOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "tags": [
+ "脚本管理",
+ "Update"
+ ],
+ "summary": "更新用户 Webhook",
+ "operationId": "update_user_webhook_api_scripts__script_id__users__user_id__webhooks__webhook_id__patch",
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "脚本 ID",
+ "title": "Script Id"
+ },
+ "description": "脚本 ID"
+ },
+ {
+ "name": "user_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "用户 ID",
+ "title": "User Id"
+ },
+ "description": "用户 ID"
+ },
+ {
+ "name": "webhook_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "Webhook ID",
+ "title": "Webhook Id"
+ },
+ "description": "Webhook ID"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WebhookRead"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "脚本管理",
+ "Delete"
+ ],
+ "summary": "删除用户 Webhook",
+ "operationId": "delete_user_webhook_api_scripts__script_id__users__user_id__webhooks__webhook_id__delete",
+ "parameters": [
+ {
+ "name": "script_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "脚本 ID",
+ "title": "Script Id"
+ },
+ "description": "脚本 ID"
+ },
+ {
+ "name": "user_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "用户 ID",
+ "title": "User Id"
+ },
+ "description": "用户 ID"
+ },
+ {
+ "name": "webhook_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "Webhook ID",
+ "title": "Webhook Id"
+ },
+ "description": "Webhook ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/plan": {
+ "get": {
+ "tags": [
+ "计划管理",
+ "Get"
+ ],
+ "summary": "查询全部计划表",
+ "operationId": "list_plans_api_plan_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlanGetOut"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "计划管理",
+ "Add"
+ ],
+ "summary": "创建计划表",
+ "operationId": "create_plan_api_plan_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlanCreateIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlanCreateOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/plan/{plan_id}": {
+ "get": {
+ "tags": [
+ "计划管理",
+ "Get"
+ ],
+ "summary": "查询单个计划表",
+ "operationId": "get_plan_api_plan__plan_id__get",
+ "parameters": [
+ {
+ "name": "plan_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "计划 ID",
+ "title": "Plan Id"
+ },
+ "description": "计划 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlanDetailOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "tags": [
+ "计划管理",
+ "Update"
+ ],
+ "summary": "更新计划表",
+ "operationId": "update_plan_api_plan__plan_id__patch",
+ "parameters": [
+ {
+ "name": "plan_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "计划 ID",
+ "title": "Plan Id"
+ },
+ "description": "计划 ID"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlanUpdateBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "计划管理",
+ "Delete"
+ ],
+ "summary": "删除计划表",
+ "operationId": "delete_plan_api_plan__plan_id__delete",
+ "parameters": [
+ {
+ "name": "plan_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "计划 ID",
+ "title": "Plan Id"
+ },
+ "description": "计划 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/plan/order": {
+ "patch": {
+ "tags": [
+ "计划管理",
+ "Update"
+ ],
+ "summary": "重新排序计划表",
+ "operationId": "reorder_plan_api_plan_order_patch",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/IndexOrderPatch"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/emulator": {
+ "get": {
+ "tags": [
+ "模拟器管理",
+ "Get"
+ ],
+ "summary": "查询全部模拟器配置",
+ "operationId": "list_emulators_api_emulator_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/EmulatorGetOut"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "模拟器管理",
+ "Add"
+ ],
+ "summary": "创建模拟器配置",
+ "operationId": "create_emulator_api_emulator_post",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/EmulatorCreateOut"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/emulator/order": {
+ "patch": {
+ "tags": [
+ "模拟器管理",
+ "Update"
+ ],
+ "summary": "重新排序模拟器",
+ "operationId": "reorder_emulator_api_emulator_order_patch",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/IndexOrderPatch"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/emulator/detected": {
+ "get": {
+ "tags": [
+ "模拟器管理",
+ "Get"
+ ],
+ "summary": "搜索已安装的模拟器",
+ "operationId": "detect_emulators_api_emulator_detected_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/EmulatorSearchOut"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/emulator/status": {
+ "get": {
+ "tags": [
+ "模拟器管理",
+ "Get"
+ ],
+ "summary": "查询全部模拟器状态",
+ "operationId": "get_emulator_statuses_api_emulator_status_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/EmulatorStatusOut"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/emulator/{emulator_id}": {
+ "get": {
+ "tags": [
+ "模拟器管理",
+ "Get"
+ ],
+ "summary": "查询单个模拟器配置",
+ "operationId": "get_emulator_api_emulator__emulator_id__get",
+ "parameters": [
+ {
+ "name": "emulator_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "模拟器 ID",
+ "title": "Emulator Id"
+ },
+ "description": "模拟器 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/EmulatorDetailOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "tags": [
+ "模拟器管理",
+ "Update"
+ ],
+ "summary": "更新模拟器配置",
+ "operationId": "update_emulator_api_emulator__emulator_id__patch",
+ "parameters": [
+ {
+ "name": "emulator_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "模拟器 ID",
+ "title": "Emulator Id"
+ },
+ "description": "模拟器 ID"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/EmulatorRead"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "模拟器管理",
+ "Delete"
+ ],
+ "summary": "删除模拟器配置",
+ "operationId": "delete_emulator_api_emulator__emulator_id__delete",
+ "parameters": [
+ {
+ "name": "emulator_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "模拟器 ID",
+ "title": "Emulator Id"
+ },
+ "description": "模拟器 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/emulator/{emulator_id}/status": {
+ "get": {
+ "tags": [
+ "模拟器管理",
+ "Get"
+ ],
+ "summary": "查询单个模拟器状态",
+ "operationId": "get_emulator_status_api_emulator__emulator_id__status_get",
+ "parameters": [
+ {
+ "name": "emulator_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "模拟器 ID",
+ "title": "Emulator Id"
+ },
+ "description": "模拟器 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/EmulatorDeviceStatusOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/emulator/{emulator_id}/actions/{action}": {
+ "post": {
+ "tags": [
+ "模拟器管理",
+ "Action"
+ ],
+ "summary": "执行模拟器动作",
+ "operationId": "operate_emulator_api_emulator__emulator_id__actions__action__post",
+ "parameters": [
+ {
+ "name": "emulator_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "模拟器 ID",
+ "title": "Emulator Id"
+ },
+ "description": "模拟器 ID"
+ },
+ {
+ "name": "action",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "enum": [
+ "open",
+ "close",
+ "show"
+ ],
+ "type": "string",
+ "description": "模拟器动作",
+ "title": "Action"
+ },
+ "description": "模拟器动作"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/EmulatorActionBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/queue": {
+ "get": {
+ "tags": [
+ "调度队列管理",
+ "Get"
+ ],
+ "summary": "查询全部调度队列",
+ "operationId": "list_queues_api_queue_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/QueueGetOut"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "调度队列管理",
+ "Add"
+ ],
+ "summary": "创建调度队列",
+ "operationId": "create_queue_api_queue_post",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/QueueCreateOut"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/queue/order": {
+ "patch": {
+ "tags": [
+ "调度队列管理",
+ "Update"
+ ],
+ "summary": "重新排序调度队列",
+ "operationId": "reorder_queue_api_queue_order_patch",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/IndexOrderPatch"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/queue/{queue_id}": {
+ "get": {
+ "tags": [
+ "调度队列管理",
+ "Get"
+ ],
+ "summary": "查询单个调度队列",
+ "operationId": "get_queue_api_queue__queue_id__get",
+ "parameters": [
+ {
+ "name": "queue_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "队列 ID",
+ "title": "Queue Id"
+ },
+ "description": "队列 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/QueueDetailOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "tags": [
+ "调度队列管理",
+ "Update"
+ ],
+ "summary": "更新调度队列",
+ "operationId": "update_queue_api_queue__queue_id__patch",
+ "parameters": [
+ {
+ "name": "queue_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "队列 ID",
+ "title": "Queue Id"
+ },
+ "description": "队列 ID"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/QueueRead"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "调度队列管理",
+ "Delete"
+ ],
+ "summary": "删除调度队列",
+ "operationId": "delete_queue_api_queue__queue_id__delete",
+ "parameters": [
+ {
+ "name": "queue_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "队列 ID",
+ "title": "Queue Id"
+ },
+ "description": "队列 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/queue/{queue_id}/times": {
+ "get": {
+ "tags": [
+ "调度队列管理",
+ "Get"
+ ],
+ "summary": "查询队列下的全部定时项",
+ "operationId": "list_time_sets_api_queue__queue_id__times_get",
+ "parameters": [
+ {
+ "name": "queue_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "队列 ID",
+ "title": "Queue Id"
+ },
+ "description": "队列 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TimeSetGetOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "调度队列管理",
+ "Add"
+ ],
+ "summary": "创建定时项",
+ "operationId": "create_time_set_api_queue__queue_id__times_post",
+ "parameters": [
+ {
+ "name": "queue_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "队列 ID",
+ "title": "Queue Id"
+ },
+ "description": "队列 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TimeSetCreateOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/queue/{queue_id}/times/order": {
+ "patch": {
+ "tags": [
+ "调度队列管理",
+ "Update"
+ ],
+ "summary": "重新排序定时项",
+ "operationId": "reorder_time_sets_api_queue__queue_id__times_order_patch",
+ "parameters": [
+ {
+ "name": "queue_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "队列 ID",
+ "title": "Queue Id"
+ },
+ "description": "队列 ID"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/IndexOrderPatch"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/queue/{queue_id}/times/{time_set_id}": {
+ "get": {
+ "tags": [
+ "调度队列管理",
+ "Get"
+ ],
+ "summary": "查询单个定时项",
+ "operationId": "get_time_set_api_queue__queue_id__times__time_set_id__get",
+ "parameters": [
+ {
+ "name": "queue_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "队列 ID",
+ "title": "Queue Id"
+ },
+ "description": "队列 ID"
+ },
+ {
+ "name": "time_set_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "时间设置 ID",
+ "title": "Time Set Id"
+ },
+ "description": "时间设置 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TimeSetDetailOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "tags": [
+ "调度队列管理",
+ "Update"
+ ],
+ "summary": "更新定时项",
+ "operationId": "update_time_set_api_queue__queue_id__times__time_set_id__patch",
+ "parameters": [
+ {
+ "name": "queue_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "队列 ID",
+ "title": "Queue Id"
+ },
+ "description": "队列 ID"
+ },
+ {
+ "name": "time_set_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "时间设置 ID",
+ "title": "Time Set Id"
+ },
+ "description": "时间设置 ID"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TimeSetRead"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "调度队列管理",
+ "Delete"
+ ],
+ "summary": "删除定时项",
+ "operationId": "delete_time_set_api_queue__queue_id__times__time_set_id__delete",
+ "parameters": [
+ {
+ "name": "queue_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "队列 ID",
+ "title": "Queue Id"
+ },
+ "description": "队列 ID"
+ },
+ {
+ "name": "time_set_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "时间设置 ID",
+ "title": "Time Set Id"
+ },
+ "description": "时间设置 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/queue/{queue_id}/items": {
+ "get": {
+ "tags": [
+ "调度队列管理",
+ "Get"
+ ],
+ "summary": "查询队列下的全部队列项",
+ "operationId": "list_queue_items_api_queue__queue_id__items_get",
+ "parameters": [
+ {
+ "name": "queue_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "队列 ID",
+ "title": "Queue Id"
+ },
+ "description": "队列 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/QueueItemGetOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "调度队列管理",
+ "Add"
+ ],
+ "summary": "创建队列项",
+ "operationId": "create_queue_item_api_queue__queue_id__items_post",
+ "parameters": [
+ {
+ "name": "queue_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "队列 ID",
+ "title": "Queue Id"
+ },
+ "description": "队列 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/QueueItemCreateOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/queue/{queue_id}/items/order": {
+ "patch": {
+ "tags": [
+ "调度队列管理",
+ "Update"
+ ],
+ "summary": "重新排序队列项",
+ "operationId": "reorder_queue_items_api_queue__queue_id__items_order_patch",
+ "parameters": [
+ {
+ "name": "queue_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "队列 ID",
+ "title": "Queue Id"
+ },
+ "description": "队列 ID"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/IndexOrderPatch"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/queue/{queue_id}/items/{queue_item_id}": {
+ "get": {
+ "tags": [
+ "调度队列管理",
+ "Get"
+ ],
+ "summary": "查询单个队列项",
+ "operationId": "get_queue_item_api_queue__queue_id__items__queue_item_id__get",
+ "parameters": [
+ {
+ "name": "queue_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "队列 ID",
+ "title": "Queue Id"
+ },
+ "description": "队列 ID"
+ },
+ {
+ "name": "queue_item_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "队列项 ID",
+ "title": "Queue Item Id"
+ },
+ "description": "队列项 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/QueueItemDetailOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "tags": [
+ "调度队列管理",
+ "Update"
+ ],
+ "summary": "更新队列项",
+ "operationId": "update_queue_item_api_queue__queue_id__items__queue_item_id__patch",
+ "parameters": [
+ {
+ "name": "queue_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "队列 ID",
+ "title": "Queue Id"
+ },
+ "description": "队列 ID"
+ },
+ {
+ "name": "queue_item_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "队列项 ID",
+ "title": "Queue Item Id"
+ },
+ "description": "队列项 ID"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/QueueItemRead"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "调度队列管理",
+ "Delete"
+ ],
+ "summary": "删除队列项",
+ "operationId": "delete_queue_item_api_queue__queue_id__items__queue_item_id__delete",
+ "parameters": [
+ {
+ "name": "queue_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "队列 ID",
+ "title": "Queue Id"
+ },
+ "description": "队列 ID"
+ },
+ {
+ "name": "queue_item_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "队列项 ID",
+ "title": "Queue Item Id"
+ },
+ "description": "队列项 ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/dispatch/start": {
+ "post": {
+ "tags": [
+ "任务调度",
+ "Action"
+ ],
+ "summary": "添加任务",
+ "operationId": "add_task_api_dispatch_start_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TaskCreateIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TaskCreateOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/dispatch/stop": {
+ "post": {
+ "tags": [
+ "任务调度",
+ "Action"
+ ],
+ "summary": "中止任务",
+ "operationId": "stop_task_api_dispatch_stop_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DispatchIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/dispatch/get/power": {
+ "post": {
+ "tags": [
+ "任务调度",
+ "Get"
+ ],
+ "summary": "获取电源标志",
+ "operationId": "get_power_api_dispatch_get_power_post",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PowerOut"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/dispatch/set/power": {
+ "post": {
+ "tags": [
+ "任务调度",
+ "Action"
+ ],
+ "summary": "设置电源标志",
+ "operationId": "set_power_api_dispatch_set_power_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PowerIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/dispatch/cancel/power": {
+ "post": {
+ "tags": [
+ "任务调度",
+ "Action"
+ ],
+ "summary": "取消电源任务",
+ "operationId": "cancel_power_task_api_dispatch_cancel_power_post",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/history/search": {
+ "post": {
+ "tags": [
+ "历史记录",
+ "Get"
+ ],
+ "summary": "搜索历史记录总览信息",
+ "operationId": "search_history_api_history_search_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HistorySearchIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HistorySearchOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/history/data": {
+ "post": {
+ "tags": [
+ "历史记录",
+ "Get"
+ ],
+ "summary": "从指定文件内获取历史记录数据",
+ "operationId": "get_history_data_api_history_data_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HistoryDataGetIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HistoryDataGetOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/tools": {
+ "get": {
+ "tags": [
+ "工具设置",
+ "Get"
+ ],
+ "summary": "查询工具配置",
+ "operationId": "get_tools_api_tools_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ToolsGetOut"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "tags": [
+ "工具设置",
+ "Update"
+ ],
+ "summary": "更新工具配置",
+ "operationId": "update_tools_api_tools_patch",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ToolsConfigRead"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/plugins/get": {
+ "post": {
+ "tags": [
+ "插件实例",
+ "Get"
+ ],
+ "summary": "获取插件实例配置",
+ "operationId": "get_plugins_api_plugins_get_post",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PluginsGetOut"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/plugins/reload": {
+ "post": {
+ "tags": [
+ "插件实例",
+ "Action"
+ ],
+ "summary": "重载插件实例",
+ "operationId": "reload_plugins_api_plugins_reload_post",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/plugins/reload_instance": {
+ "post": {
+ "tags": [
+ "插件实例",
+ "Action"
+ ],
+ "summary": "重载单个插件实例",
+ "operationId": "reload_plugin_instance_api_plugins_reload_instance_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PluginReloadInstanceIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/plugins/reload_plugin": {
+ "post": {
+ "tags": [
+ "插件实例",
+ "Action"
+ ],
+ "summary": "按插件名重载所有实例",
+ "operationId": "reload_plugin_by_name_api_plugins_reload_plugin_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PluginReloadPluginIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/plugins/install_package": {
+ "post": {
+ "tags": [
+ "插件实例",
+ "Action"
+ ],
+ "summary": "下载安装插件包",
+ "operationId": "install_plugin_package_api_plugins_install_package_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PluginPackageIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/plugins/uninstall_package": {
+ "post": {
+ "tags": [
+ "插件实例",
+ "Action"
+ ],
+ "summary": "卸载插件包",
+ "operationId": "uninstall_plugin_package_api_plugins_uninstall_package_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PluginPackageIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/plugins/dev/rebuild_ctx_stub": {
+ "post": {
+ "tags": [
+ "插件实例",
+ "Action"
+ ],
+ "summary": "重建插件 ctx 类型提示文件",
+ "operationId": "rebuild_plugin_ctx_stub_api_plugins_dev_rebuild_ctx_stub_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PluginDevRebuildCtxStubIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PluginDevRebuildCtxStubOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/plugins/add": {
+ "post": {
+ "tags": [
+ "插件实例",
+ "Add"
+ ],
+ "summary": "新增插件实例",
+ "operationId": "add_plugin_instance_api_plugins_add_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PluginAddIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PluginMutationOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/plugins/update": {
+ "post": {
+ "tags": [
+ "插件实例",
+ "Update"
+ ],
+ "summary": "更新插件实例",
+ "operationId": "update_plugin_instance_api_plugins_update_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PluginUpdateIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PluginMutationOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/plugins/delete": {
+ "post": {
+ "tags": [
+ "插件实例",
+ "Delete"
+ ],
+ "summary": "删除插件实例",
+ "operationId": "delete_plugin_instance_api_plugins_delete_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PluginDeleteIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/setting": {
+ "get": {
+ "tags": [
+ "全局设置",
+ "Get"
+ ],
+ "summary": "查询全局配置",
+ "operationId": "get_setting_api_setting_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SettingGetOut"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "tags": [
+ "全局设置",
+ "Update"
+ ],
+ "summary": "更新全局配置",
+ "operationId": "update_setting_api_setting_patch",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GlobalConfigRead"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/setting/actions/test-notify": {
+ "post": {
+ "tags": [
+ "全局设置",
+ "Action"
+ ],
+ "summary": "测试通知",
+ "operationId": "test_notify_api_setting_actions_test_notify_post",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/setting/webhooks": {
+ "get": {
+ "tags": [
+ "全局设置",
+ "Get"
+ ],
+ "summary": "查询全部全局 Webhook 配置",
+ "operationId": "list_webhooks_api_setting_webhooks_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WebhookGetOut"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "全局设置",
+ "Add"
+ ],
+ "summary": "创建全局 Webhook 配置",
+ "operationId": "create_webhook_api_setting_webhooks_post",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WebhookCreateOut"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/setting/webhooks/order": {
+ "patch": {
+ "tags": [
+ "全局设置",
+ "Update"
+ ],
+ "summary": "重新排序全局 Webhook",
+ "operationId": "reorder_webhooks_api_setting_webhooks_order_patch",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/IndexOrderPatch"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/setting/webhooks/test": {
+ "post": {
+ "tags": [
+ "全局设置",
+ "Action"
+ ],
+ "summary": "测试指定 Webhook 配置",
+ "operationId": "test_webhook_api_setting_webhooks_test_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WebhookRead"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/setting/webhooks/{webhook_id}": {
+ "get": {
+ "tags": [
+ "全局设置",
+ "Get"
+ ],
+ "summary": "查询单个全局 Webhook 配置",
+ "operationId": "get_webhook_api_setting_webhooks__webhook_id__get",
+ "parameters": [
+ {
+ "name": "webhook_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "Webhook ID",
+ "title": "Webhook Id"
+ },
+ "description": "Webhook ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WebhookDetailOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "tags": [
+ "全局设置",
+ "Update"
+ ],
+ "summary": "更新全局 Webhook 配置",
+ "operationId": "update_webhook_api_setting_webhooks__webhook_id__patch",
+ "parameters": [
+ {
+ "name": "webhook_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "Webhook ID",
+ "title": "Webhook Id"
+ },
+ "description": "Webhook ID"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WebhookRead"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "全局设置",
+ "Delete"
+ ],
+ "summary": "删除全局 Webhook 配置",
+ "operationId": "delete_webhook_api_setting_webhooks__webhook_id__delete",
+ "parameters": [
+ {
+ "name": "webhook_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "description": "Webhook ID",
+ "title": "Webhook Id"
+ },
+ "description": "Webhook ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/update/check": {
+ "post": {
+ "tags": [
+ "软件更新",
+ "Get"
+ ],
+ "summary": "检查更新",
+ "operationId": "check_update_api_update_check_post",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateCheckIn"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateCheckOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "get": {
+ "tags": [
+ "软件更新",
+ "Get"
+ ],
+ "summary": "按 REST 风格检查更新",
+ "operationId": "check_update_rest_api_update_check_get",
+ "parameters": [
+ {
+ "name": "current_version",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Current Version"
+ }
+ },
+ {
+ "name": "if_force",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "boolean",
+ "default": false,
+ "title": "If Force"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateCheckOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/update/download": {
+ "post": {
+ "tags": [
+ "软件更新",
+ "Action"
+ ],
+ "summary": "下载更新",
+ "operationId": "download_update_api_update_download_post",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/update/install": {
+ "post": {
+ "tags": [
+ "软件更新",
+ "Action"
+ ],
+ "summary": "安装更新",
+ "operationId": "install_update_api_update_install_post",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OutBase"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/ocr/screenshot": {
+ "post": {
+ "tags": [
+ "OCR识别",
+ "Get"
+ ],
+ "summary": "获取窗口截图",
+ "description": "根据窗口标题获取截图,返回Base64编码的图像数据\n\nArgs:\n params: 截图参数\n - window_title: 窗口标题关键字\n - should_preprocess: 是否预处理图片区域(默认True)\n - aspect_ratio_width: 宽高比宽度(默认16)\n - aspect_ratio_height: 宽高比高度(默认9)\n - region: 自定义截图区域,格式为 (left, top, width, height)\n\nReturns:\n OCRScreenshotOut: 包含Base64编码的截图和区域信息",
+ "operationId": "get_screenshot_api_ocr_screenshot_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OCRScreenshotIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OCRScreenshotOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/ocr/screenshot/adb": {
+ "post": {
+ "tags": [
+ "OCR识别",
+ "Get"
+ ],
+ "summary": "通过ADB获取设备截图",
+ "description": "通过 ADB 端口获取 Android 设备/模拟器截图,返回Base64编码的图像数据\n\n支持两种截图方法:\n1. screencap PNG 方法(推荐):速度快,直接获取 PNG 图像\n2. screencap raw 方法:获取原始像素数据,适用于某些不支持 PNG 的设备\n\nArgs:\n params: ADB 截图参数\n - adb_path: ADB 可执行文件的路径\n - serial: 设备序列号,格式如 \"127.0.0.1:5555\" 或 \"emulator-5554\"\n - use_screencap: 是否使用 screencap PNG 方法(默认True)\n\nReturns:\n ADBScreenshotOut: 包含Base64编码的截图和设备信息",
+ "operationId": "get_screenshot_adb_api_ocr_screenshot_adb_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ADBScreenshotIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ADBScreenshotOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/ocr/check/image": {
+ "post": {
+ "tags": [
+ "OCR识别",
+ "Get"
+ ],
+ "summary": "检查是否存在指定图像",
+ "description": "截图并查找是否存在图片内的内容\n\nArgs:\n params: 检查图像参数\n - window_title: 窗口标题关键字\n - image_path: 要查找的图片路径\n - interval: 截图间隔时间(秒),默认为 0\n - retry_times: 重复截图次数,默认为 1\n - threshold: 图像匹配阈值,范围 0-1,默认 0.8\n\nReturns:\n CheckImageOut: 包含查找结果和尝试次数",
+ "operationId": "check_image_api_ocr_check_image_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CheckImageIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CheckImageOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/ocr/check/image/any": {
+ "post": {
+ "tags": [
+ "OCR识别",
+ "Get"
+ ],
+ "summary": "检查是否存在任意一个指定图像",
+ "description": "截图并查找是否存在列表中任意一张图片的内容\n\nArgs:\n params: 检查图像参数\n - window_title: 窗口标题关键字\n - image_paths: 要查找的图片路径列表\n - interval: 截图间隔时间(秒),默认为 0\n - retry_times: 重复截图次数,默认为 1\n - threshold: 图像匹配阈值,范围 0-1,默认 0.8\n\nReturns:\n CheckImageOut: 包含查找结果和尝试次数",
+ "operationId": "check_image_any_api_ocr_check_image_any_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CheckImageAnyIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CheckImageOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/ocr/check/image/all": {
+ "post": {
+ "tags": [
+ "OCR识别",
+ "Get"
+ ],
+ "summary": "检查是否存在所有指定图像",
+ "description": "截图并查找是否存在列表中所有图片的内容\n\nArgs:\n params: 检查图像参数\n - window_title: 窗口标题关键字\n - image_paths: 要查找的图片路径列表\n - interval: 截图间隔时间(秒),默认为 0\n - retry_times: 重复截图次数,默认为 1\n - threshold: 图像匹配阈值,范围 0-1,默认 0.8\n\nReturns:\n CheckImageOut: 包含查找结果和尝试次数",
+ "operationId": "check_image_all_api_ocr_check_image_all_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CheckImageAllIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CheckImageOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/ocr/click/image": {
+ "post": {
+ "tags": [
+ "OCR识别",
+ "Action"
+ ],
+ "summary": "点击指定图像位置",
+ "description": "截图、查找并点击与图像一致的位置\n\nArgs:\n params: 点击图像参数\n - window_title: 窗口标题关键字\n - image_path: 要查找并点击的图片路径\n - interval: 截图间隔时间(秒),默认为 0\n - retry_times: 重复截图次数,默认为 1\n - threshold: 图像匹配阈值,范围 0-1,默认 0.8\n\nReturns:\n ClickOut: 包含点击结果和尝试次数",
+ "operationId": "click_image_api_ocr_click_image_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ClickImageIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ClickOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/ocr/click/text": {
+ "post": {
+ "tags": [
+ "OCR识别",
+ "Action"
+ ],
+ "summary": "点击指定文字位置",
+ "description": "截图、OCR识别并点击与文字一致的位置\n\nArgs:\n params: 点击文字参数\n - window_title: 窗口标题关键字\n - text: 要查找并点击的文字内容\n - interval: 截图间隔时间(秒),默认为 0\n - retry_times: 重复截图次数,默认为 1\n\nReturns:\n ClickOut: 包含点击结果和尝试次数",
+ "operationId": "click_text_api_ocr_click_text_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ClickTextIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ClickOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/ws_debug/client/create": {
+ "post": {
+ "tags": [
+ "WebSocket调试"
+ ],
+ "summary": "创建 WebSocket 客户端",
+ "description": "创建一个新的 WebSocket 客户端实例\n\n- **name**: 客户端唯一名称\n- **url**: WebSocket 服务器地址\n- **ping_interval**: 心跳发送间隔\n- **ping_timeout**: 心跳超时时间\n- **reconnect_interval**: 重连间隔\n- **max_reconnect_attempts**: 最大重连次数",
+ "operationId": "create_client_api_ws_debug_client_create_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WSClientCreateIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WSClientCreateOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/ws_debug/client/connect": {
+ "post": {
+ "tags": [
+ "WebSocket调试"
+ ],
+ "summary": "连接 WebSocket 客户端",
+ "description": "启动指定客户端的连接(非阻塞)",
+ "operationId": "connect_client_api_ws_debug_client_connect_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WSClientConnectIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WSClientStatusOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/ws_debug/client/disconnect": {
+ "post": {
+ "tags": [
+ "WebSocket调试"
+ ],
+ "summary": "断开 WebSocket 客户端",
+ "description": "断开指定客户端的连接",
+ "operationId": "disconnect_client_api_ws_debug_client_disconnect_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WSClientDisconnectIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WSClientStatusOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/ws_debug/client/remove": {
+ "post": {
+ "tags": [
+ "WebSocket调试"
+ ],
+ "summary": "删除 WebSocket 客户端",
+ "description": "删除指定客户端(会自动断开连接)\n\n注意:系统客户端(如 Koishi)不可删除",
+ "operationId": "remove_client_api_ws_debug_client_remove_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WSClientRemoveIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WSClientStatusOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/ws_debug/client/status": {
+ "post": {
+ "tags": [
+ "WebSocket调试"
+ ],
+ "summary": "获取客户端状态",
+ "description": "获取指定客户端的状态信息",
+ "operationId": "get_client_status_api_ws_debug_client_status_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WSClientStatusIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WSClientStatusOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/ws_debug/client/list": {
+ "get": {
+ "tags": [
+ "WebSocket调试"
+ ],
+ "summary": "列出所有客户端",
+ "description": "获取所有已创建的 WebSocket 客户端列表及状态",
+ "operationId": "list_clients_api_ws_debug_client_list_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WSClientListOut"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/ws_debug/message/send": {
+ "post": {
+ "tags": [
+ "WebSocket调试"
+ ],
+ "summary": "发送原始消息",
+ "description": "发送原始 JSON 消息到指定客户端连接的服务器",
+ "operationId": "send_message_api_ws_debug_message_send_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WSClientSendIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WSClientStatusOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/ws_debug/message/send_json": {
+ "post": {
+ "tags": [
+ "WebSocket调试"
+ ],
+ "summary": "发送格式化消息",
+ "description": "发送格式化的 JSON 消息(自动组装 id、type、data 结构)",
+ "operationId": "send_json_message_api_ws_debug_message_send_json_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WSClientSendJsonIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WSClientStatusOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/ws_debug/message/auth": {
+ "post": {
+ "tags": [
+ "WebSocket调试"
+ ],
+ "summary": "发送认证消息",
+ "description": "发送认证消息到服务器\n\n- **name**: 客户端名称\n- **token**: 认证 Token\n- **auth_type**: 认证消息类型,默认 \"auth\"\n- **extra_data**: 额外的认证数据",
+ "operationId": "send_auth_api_ws_debug_message_auth_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WSClientAuthIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WSClientStatusOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/ws_debug/history": {
+ "get": {
+ "tags": [
+ "WebSocket调试"
+ ],
+ "summary": "获取消息历史",
+ "description": "获取消息历史记录\n\n- **name**: 客户端名称,为空则获取所有客户端的历史",
+ "operationId": "get_history_api_ws_debug_history_get",
+ "parameters": [
+ {
+ "name": "name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Name"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WSMessageHistoryOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/ws_debug/history/clear": {
+ "post": {
+ "tags": [
+ "WebSocket调试"
+ ],
+ "summary": "清空消息历史",
+ "description": "清空消息历史记录\n\n- **name**: 客户端名称,为空则清空所有",
+ "operationId": "clear_history_api_ws_debug_history_clear_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WSClearHistoryIn"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WSClientStatusOut"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/ws_debug/commands": {
+ "get": {
+ "tags": [
+ "WebSocket调试"
+ ],
+ "summary": "获取可用 WS 命令",
+ "description": "获取所有已注册的 WebSocket 命令端点",
+ "operationId": "get_commands_api_ws_debug_commands_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WSCommandsOut"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "ADBScreenshotIn": {
+ "properties": {
+ "adb_path": {
+ "type": "string",
+ "title": "Adb Path",
+ "description": "ADB 可执行文件的路径"
+ },
+ "serial": {
+ "type": "string",
+ "title": "Serial",
+ "description": "设备序列号,格式如 '127.0.0.1:5555' 或 'emulator-5554'"
+ },
+ "use_screencap": {
+ "type": "boolean",
+ "title": "Use Screencap",
+ "description": "是否使用 screencap PNG 方法,False 时使用 screencap raw 方法",
+ "default": true
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "adb_path",
+ "serial"
+ ],
+ "title": "ADBScreenshotIn"
+ },
+ "ADBScreenshotOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "image_base64": {
+ "type": "string",
+ "title": "Image Base64",
+ "description": "截图的Base64编码(PNG格式)"
+ },
+ "image_width": {
+ "type": "integer",
+ "title": "Image Width",
+ "description": "截图宽度"
+ },
+ "image_height": {
+ "type": "integer",
+ "title": "Image Height",
+ "description": "截图高度"
+ },
+ "serial": {
+ "type": "string",
+ "title": "Serial",
+ "description": "设备序列号"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "image_base64",
+ "image_width",
+ "image_height",
+ "serial"
+ ],
+ "title": "ADBScreenshotOut"
+ },
+ "CheckImageAllIn": {
+ "properties": {
+ "window_title": {
+ "type": "string",
+ "title": "Window Title",
+ "description": "窗口标题(用于查找窗口)"
+ },
+ "interval": {
+ "type": "number",
+ "minimum": 0.0,
+ "title": "Interval",
+ "description": "截图间隔时间(秒)",
+ "default": 0
+ },
+ "retry_times": {
+ "type": "integer",
+ "minimum": 1.0,
+ "title": "Retry Times",
+ "description": "重复截图次数",
+ "default": 1
+ },
+ "threshold": {
+ "type": "number",
+ "maximum": 1.0,
+ "minimum": 0.0,
+ "title": "Threshold",
+ "description": "图像匹配阈值,范围 0-1",
+ "default": 0.8
+ },
+ "image_paths": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "title": "Image Paths",
+ "description": "要查找的图片路径列表"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "window_title",
+ "image_paths"
+ ],
+ "title": "CheckImageAllIn"
+ },
+ "CheckImageAnyIn": {
+ "properties": {
+ "window_title": {
+ "type": "string",
+ "title": "Window Title",
+ "description": "窗口标题(用于查找窗口)"
+ },
+ "interval": {
+ "type": "number",
+ "minimum": 0.0,
+ "title": "Interval",
+ "description": "截图间隔时间(秒)",
+ "default": 0
+ },
+ "retry_times": {
+ "type": "integer",
+ "minimum": 1.0,
+ "title": "Retry Times",
+ "description": "重复截图次数",
+ "default": 1
+ },
+ "threshold": {
+ "type": "number",
+ "maximum": 1.0,
+ "minimum": 0.0,
+ "title": "Threshold",
+ "description": "图像匹配阈值,范围 0-1",
+ "default": 0.8
+ },
+ "image_paths": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "title": "Image Paths",
+ "description": "要查找的图片路径列表"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "window_title",
+ "image_paths"
+ ],
+ "title": "CheckImageAnyIn"
+ },
+ "CheckImageIn": {
+ "properties": {
+ "window_title": {
+ "type": "string",
+ "title": "Window Title",
+ "description": "窗口标题(用于查找窗口)"
+ },
+ "interval": {
+ "type": "number",
+ "minimum": 0.0,
+ "title": "Interval",
+ "description": "截图间隔时间(秒)",
+ "default": 0
+ },
+ "retry_times": {
+ "type": "integer",
+ "minimum": 1.0,
+ "title": "Retry Times",
+ "description": "重复截图次数",
+ "default": 1
+ },
+ "threshold": {
+ "type": "number",
+ "maximum": 1.0,
+ "minimum": 0.0,
+ "title": "Threshold",
+ "description": "图像匹配阈值,范围 0-1",
+ "default": 0.8
+ },
+ "image_path": {
+ "type": "string",
+ "title": "Image Path",
+ "description": "要查找的图片路径"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "window_title",
+ "image_path"
+ ],
+ "title": "CheckImageIn"
+ },
+ "CheckImageOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "found": {
+ "type": "boolean",
+ "title": "Found",
+ "description": "是否找到图像"
+ },
+ "attempts": {
+ "type": "integer",
+ "title": "Attempts",
+ "description": "实际尝试次数"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "found",
+ "attempts"
+ ],
+ "title": "CheckImageOut"
+ },
+ "ClickImageIn": {
+ "properties": {
+ "window_title": {
+ "type": "string",
+ "title": "Window Title",
+ "description": "窗口标题(用于查找窗口)"
+ },
+ "interval": {
+ "type": "number",
+ "minimum": 0.0,
+ "title": "Interval",
+ "description": "截图间隔时间(秒)",
+ "default": 0
+ },
+ "retry_times": {
+ "type": "integer",
+ "minimum": 1.0,
+ "title": "Retry Times",
+ "description": "重复截图次数",
+ "default": 1
+ },
+ "threshold": {
+ "type": "number",
+ "maximum": 1.0,
+ "minimum": 0.0,
+ "title": "Threshold",
+ "description": "图像匹配阈值,范围 0-1",
+ "default": 0.8
+ },
+ "image_path": {
+ "type": "string",
+ "title": "Image Path",
+ "description": "要查找并点击的图片路径"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "window_title",
+ "image_path"
+ ],
+ "title": "ClickImageIn"
+ },
+ "ClickOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "success": {
+ "type": "boolean",
+ "title": "Success",
+ "description": "是否成功点击"
+ },
+ "attempts": {
+ "type": "integer",
+ "title": "Attempts",
+ "description": "实际尝试次数"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "success",
+ "attempts"
+ ],
+ "title": "ClickOut"
+ },
+ "ClickTextIn": {
+ "properties": {
+ "window_title": {
+ "type": "string",
+ "title": "Window Title",
+ "description": "窗口标题(用于查找窗口)"
+ },
+ "interval": {
+ "type": "number",
+ "minimum": 0.0,
+ "title": "Interval",
+ "description": "截图间隔时间(秒)",
+ "default": 0
+ },
+ "retry_times": {
+ "type": "integer",
+ "minimum": 1.0,
+ "title": "Retry Times",
+ "description": "重复截图次数",
+ "default": 1
+ },
+ "text": {
+ "type": "string",
+ "title": "Text",
+ "description": "要查找并点击的文字内容"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "window_title",
+ "text"
+ ],
+ "title": "ClickTextIn"
+ },
+ "ComboBoxItem": {
+ "properties": {
+ "label": {
+ "type": "string",
+ "title": "Label",
+ "description": "展示值"
+ },
+ "value": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Value",
+ "description": "实际值"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "label",
+ "value"
+ ],
+ "title": "ComboBoxItem"
+ },
+ "ComboBoxOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "items": {
+ "$ref": "#/components/schemas/ComboBoxItem"
+ },
+ "type": "array",
+ "title": "Data",
+ "description": "下拉框选项"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "title": "ComboBoxOut"
+ },
+ "DeviceInfo": {
+ "properties": {
+ "title": {
+ "type": "string",
+ "title": "Title",
+ "description": "设备标题/名称"
+ },
+ "status": {
+ "type": "integer",
+ "title": "Status",
+ "description": "设备状态, 参考 DeviceStatus 枚举值"
+ },
+ "adb_address": {
+ "type": "string",
+ "title": "Adb Address",
+ "description": "ADB连接地址"
+ }
+ },
+ "type": "object",
+ "required": [
+ "title",
+ "status",
+ "adb_address"
+ ],
+ "title": "DeviceInfo",
+ "description": "API 层使用的设备信息模型。"
+ },
+ "DispatchIn": {
+ "properties": {
+ "taskId": {
+ "type": "string",
+ "title": "Taskid",
+ "description": "目标任务ID, 设置类任务可选对应脚本ID或用户ID, 代理类任务可选对应队列ID或脚本ID"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "taskId"
+ ],
+ "title": "DispatchIn"
+ },
+ "EmulatorActionBody": {
+ "properties": {
+ "index": {
+ "type": "string",
+ "title": "Index",
+ "description": "模拟器索引"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "index"
+ ],
+ "title": "EmulatorActionBody"
+ },
+ "EmulatorConfigIndexItem": {
+ "properties": {
+ "uid": {
+ "type": "string",
+ "title": "Uid",
+ "description": "唯一标识符"
+ },
+ "type": {
+ "type": "string",
+ "const": "EmulatorConfig",
+ "title": "Type",
+ "description": "配置类型"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "uid",
+ "type"
+ ],
+ "title": "EmulatorConfigIndexItem"
+ },
+ "EmulatorCreateOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "$ref": "#/components/schemas/EmulatorRead",
+ "description": "资源数据"
+ },
+ "id": {
+ "type": "string",
+ "title": "Id",
+ "description": "新创建资源的唯一 ID"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data",
+ "id"
+ ],
+ "title": "EmulatorCreateOut",
+ "description": "模拟器创建响应模型"
+ },
+ "EmulatorDetailOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "$ref": "#/components/schemas/EmulatorRead",
+ "description": "资源数据"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "title": "EmulatorDetailOut",
+ "description": "模拟器详情响应模型"
+ },
+ "EmulatorDeviceStatusOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "additionalProperties": {
+ "$ref": "#/components/schemas/DeviceInfo"
+ },
+ "type": "object",
+ "title": "Data",
+ "description": "资源数据"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "title": "EmulatorDeviceStatusOut",
+ "description": "模拟器设备状态响应模型"
+ },
+ "EmulatorGetOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "index": {
+ "items": {
+ "$ref": "#/components/schemas/EmulatorConfigIndexItem"
+ },
+ "type": "array",
+ "title": "Index",
+ "description": "资源索引列表"
+ },
+ "data": {
+ "additionalProperties": {
+ "$ref": "#/components/schemas/EmulatorRead"
+ },
+ "type": "object",
+ "title": "Data",
+ "description": "资源数据字典"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "index",
+ "data"
+ ],
+ "title": "EmulatorGetOut",
+ "description": "模拟器列表响应模型"
+ },
+ "EmulatorIdBody": {
+ "properties": {
+ "emulatorId": {
+ "type": "string",
+ "title": "Emulatorid",
+ "description": "模拟器 ID"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "emulatorId"
+ ],
+ "title": "EmulatorIdBody"
+ },
+ "EmulatorRead": {
+ "properties": {
+ "Info": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/EmulatorReadInfo"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "EmulatorRead",
+ "description": "模拟器配置读取/写入模型。"
+ },
+ "EmulatorReadInfo": {
+ "properties": {
+ "Name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Name"
+ },
+ "Type": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "general",
+ "mumu",
+ "ldplayer"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Type"
+ },
+ "Path": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Path"
+ },
+ "BossKey": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Bosskey"
+ },
+ "MaxWaitTime": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": 1.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Maxwaittime"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "EmulatorReadInfo"
+ },
+ "EmulatorSearchOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "items": {
+ "$ref": "#/components/schemas/EmulatorSearchResult"
+ },
+ "type": "array",
+ "title": "Data",
+ "description": "搜索到的模拟器列表"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "title": "EmulatorSearchOut"
+ },
+ "EmulatorSearchResult": {
+ "properties": {
+ "type": {
+ "type": "string",
+ "title": "Type",
+ "description": "模拟器类型"
+ },
+ "path": {
+ "type": "string",
+ "title": "Path",
+ "description": "模拟器路径"
+ },
+ "name": {
+ "type": "string",
+ "title": "Name",
+ "description": "模拟器名称"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "type",
+ "path",
+ "name"
+ ],
+ "title": "EmulatorSearchResult"
+ },
+ "EmulatorStatusOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "additionalProperties": {
+ "additionalProperties": {
+ "$ref": "#/components/schemas/DeviceInfo"
+ },
+ "type": "object"
+ },
+ "type": "object",
+ "title": "Data",
+ "description": "资源数据"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "title": "EmulatorStatusOut",
+ "description": "模拟器状态响应模型"
+ },
+ "GeneralConfig": {
+ "properties": {
+ "Info": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/GeneralConfigInfo"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Script": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/GeneralConfigScript"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Game": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/GeneralConfigGame"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Run": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/GeneralConfigRun"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "type": {
+ "type": "string",
+ "const": "GeneralConfig",
+ "title": "Type",
+ "description": "配置类型",
+ "default": "GeneralConfig"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "GeneralConfig"
+ },
+ "GeneralConfigGame": {
+ "properties": {
+ "Enabled": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Enabled"
+ },
+ "Type": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "Emulator",
+ "Client",
+ "URL"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Type"
+ },
+ "Path": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Path"
+ },
+ "URL": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Url"
+ },
+ "ProcessName": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Processname"
+ },
+ "Arguments": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Arguments"
+ },
+ "WaitTime": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": 0.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Waittime"
+ },
+ "IfForceClose": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifforceclose"
+ },
+ "EmulatorId": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Emulatorid"
+ },
+ "EmulatorIndex": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Emulatorindex"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "GeneralConfigGame"
+ },
+ "GeneralConfigInfo": {
+ "properties": {
+ "Name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Name"
+ },
+ "RootPath": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Rootpath"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "GeneralConfigInfo"
+ },
+ "GeneralConfigRun": {
+ "properties": {
+ "ProxyTimesLimit": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": 0.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Proxytimeslimit"
+ },
+ "RunTimesLimit": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": 1.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Runtimeslimit"
+ },
+ "RunTimeLimit": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": 1.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Runtimelimit"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "GeneralConfigRun"
+ },
+ "GeneralConfigScript": {
+ "properties": {
+ "ScriptPath": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Scriptpath"
+ },
+ "Arguments": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Arguments"
+ },
+ "IfTrackProcess": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Iftrackprocess"
+ },
+ "TrackProcessName": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Trackprocessname"
+ },
+ "TrackProcessExe": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Trackprocessexe"
+ },
+ "TrackProcessCmdline": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Trackprocesscmdline"
+ },
+ "ConfigPath": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Configpath"
+ },
+ "ConfigPathMode": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "File",
+ "Folder"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Configpathmode"
+ },
+ "UpdateConfigMode": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "Never",
+ "Success",
+ "Failure",
+ "Always"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Updateconfigmode"
+ },
+ "LogPath": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Logpath"
+ },
+ "LogPathFormat": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Logpathformat"
+ },
+ "LogTimeStart": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": 1.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Logtimestart"
+ },
+ "LogTimeEnd": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": 1.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Logtimeend"
+ },
+ "LogTimeFormat": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Logtimeformat"
+ },
+ "SuccessLog": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Successlog"
+ },
+ "ErrorLog": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Errorlog"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "GeneralConfigScript"
+ },
+ "GeneralUserConfig": {
+ "properties": {
+ "Info": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/GeneralUserConfigInfo"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Data": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/GeneralUserConfigData"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Notify": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/GeneralUserConfigNotify"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "type": {
+ "type": "string",
+ "const": "GeneralUserConfig",
+ "title": "Type",
+ "description": "配置类型",
+ "default": "GeneralUserConfig"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "GeneralUserConfig"
+ },
+ "GeneralUserConfigData": {
+ "properties": {
+ "LastProxyDate": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Lastproxydate"
+ },
+ "ProxyTimes": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": 0.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Proxytimes"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "GeneralUserConfigData"
+ },
+ "GeneralUserConfigInfo": {
+ "properties": {
+ "Name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Name"
+ },
+ "Status": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Status"
+ },
+ "RemainedDay": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": -1.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Remainedday"
+ },
+ "IfScriptBeforeTask": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifscriptbeforetask"
+ },
+ "ScriptBeforeTask": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Scriptbeforetask"
+ },
+ "IfScriptAfterTask": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifscriptaftertask"
+ },
+ "ScriptAfterTask": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Scriptaftertask"
+ },
+ "Notes": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Notes"
+ },
+ "Tag": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Tag",
+ "readOnly": true
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "GeneralUserConfigInfo"
+ },
+ "GeneralUserConfigNotify": {
+ "properties": {
+ "Enabled": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Enabled"
+ },
+ "IfSendStatistic": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifsendstatistic"
+ },
+ "IfSendMail": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifsendmail"
+ },
+ "ToAddress": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Toaddress"
+ },
+ "IfServerChan": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifserverchan"
+ },
+ "ServerChanKey": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Serverchankey"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "GeneralUserConfigNotify"
+ },
+ "GetStageIn": {
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "User",
+ "Today",
+ "ALL",
+ "Monday",
+ "Tuesday",
+ "Wednesday",
+ "Thursday",
+ "Friday",
+ "Saturday",
+ "Sunday"
+ ],
+ "title": "Type",
+ "description": "选择的日期类型, Today为当天, ALL为包含当天未开放关卡在内的所有项"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "type"
+ ],
+ "title": "GetStageIn"
+ },
+ "GlobalConfigRead": {
+ "properties": {
+ "Function": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/GlobalConfigReadFunction"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Voice": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/GlobalConfigReadVoice"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Start": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/GlobalConfigReadStart"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "UI": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/GlobalConfigReadUI"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Notify": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/GlobalConfigReadNotify"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Update": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/GlobalConfigReadUpdate"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "GlobalConfigRead",
+ "description": "全局配置读取/写入模型。"
+ },
+ "GlobalConfigReadFunction": {
+ "properties": {
+ "HistoryRetentionTime": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "enum": [
+ 7,
+ 15,
+ 30,
+ 60,
+ 90,
+ 180,
+ 365,
+ 0
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Historyretentiontime"
+ },
+ "IfAllowSleep": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifallowsleep"
+ },
+ "IfSilence": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifsilence"
+ },
+ "IfAgreeBilibili": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifagreebilibili"
+ },
+ "IfBlockAd": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifblockad"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "GlobalConfigReadFunction"
+ },
+ "GlobalConfigReadNotify": {
+ "properties": {
+ "SendTaskResultTime": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "不推送",
+ "任何时刻",
+ "仅失败时"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Sendtaskresulttime"
+ },
+ "IfSendStatistic": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifsendstatistic"
+ },
+ "IfSendSixStar": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifsendsixstar"
+ },
+ "IfPushPlyer": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifpushplyer"
+ },
+ "IfSendMail": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifsendmail"
+ },
+ "IfKoishiSupport": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifkoishisupport"
+ },
+ "KoishiServerAddress": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Koishiserveraddress"
+ },
+ "KoishiToken": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Koishitoken"
+ },
+ "SMTPServerAddress": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Smtpserveraddress"
+ },
+ "AuthorizationCode": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Authorizationcode"
+ },
+ "FromAddress": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Fromaddress"
+ },
+ "ToAddress": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Toaddress"
+ },
+ "IfServerChan": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifserverchan"
+ },
+ "ServerChanKey": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Serverchankey"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "GlobalConfigReadNotify"
+ },
+ "GlobalConfigReadStart": {
+ "properties": {
+ "IfSelfStart": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifselfstart"
+ },
+ "IfMinimizeDirectly": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifminimizedirectly"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "GlobalConfigReadStart"
+ },
+ "GlobalConfigReadUI": {
+ "properties": {
+ "IfShowTray": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifshowtray"
+ },
+ "IfToTray": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Iftotray"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "GlobalConfigReadUI"
+ },
+ "GlobalConfigReadUpdate": {
+ "properties": {
+ "IfAutoUpdate": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifautoupdate"
+ },
+ "Source": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "GitHub",
+ "MirrorChyan",
+ "AutoSite"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Source"
+ },
+ "Channel": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "stable",
+ "beta"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Channel"
+ },
+ "ProxyAddress": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Proxyaddress"
+ },
+ "MirrorChyanCDK": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Mirrorchyancdk"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "GlobalConfigReadUpdate"
+ },
+ "GlobalConfigReadVoice": {
+ "properties": {
+ "Enabled": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Enabled"
+ },
+ "Type": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "simple",
+ "noisy"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Type"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "GlobalConfigReadVoice"
+ },
+ "HTTPValidationError": {
+ "properties": {
+ "detail": {
+ "items": {
+ "$ref": "#/components/schemas/ValidationError"
+ },
+ "type": "array",
+ "title": "Detail"
+ }
+ },
+ "type": "object",
+ "title": "HTTPValidationError"
+ },
+ "HistoryData": {
+ "properties": {
+ "index": {
+ "anyOf": [
+ {
+ "items": {
+ "$ref": "#/components/schemas/HistoryIndexItem"
+ },
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Index",
+ "description": "历史记录索引列表"
+ },
+ "recruit_statistics": {
+ "anyOf": [
+ {
+ "additionalProperties": {
+ "type": "integer"
+ },
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Recruit Statistics",
+ "description": "公招统计数据, key为星级, value为对应的公招数量"
+ },
+ "drop_statistics": {
+ "anyOf": [
+ {
+ "additionalProperties": {
+ "additionalProperties": {
+ "type": "integer"
+ },
+ "type": "object"
+ },
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Drop Statistics",
+ "description": "掉落统计数据, 格式为 { '关卡号': { '掉落物': 数量 } }"
+ },
+ "error_info": {
+ "anyOf": [
+ {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Error Info",
+ "description": "报错信息, key为时间戳, value为错误描述"
+ },
+ "sanity": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Sanity",
+ "description": "当前理智值"
+ },
+ "sanity_full_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Sanity Full At",
+ "description": "理智回满时间, 格式通常为 YYYY-MM-DD HH:MM:SS"
+ },
+ "log_content": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Log Content",
+ "description": "日志内容, 仅在提取单条历史记录数据时返回"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "HistoryData"
+ },
+ "HistoryDataGetIn": {
+ "properties": {
+ "jsonPath": {
+ "type": "string",
+ "title": "Jsonpath",
+ "description": "需要提取数据的历史记录JSON文件"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "jsonPath"
+ ],
+ "title": "HistoryDataGetIn"
+ },
+ "HistoryDataGetOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "$ref": "#/components/schemas/HistoryData",
+ "description": "历史记录数据"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "title": "HistoryDataGetOut"
+ },
+ "HistoryIndexItem": {
+ "properties": {
+ "date": {
+ "type": "string",
+ "title": "Date",
+ "description": "日期"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "DONE",
+ "ERROR"
+ ],
+ "title": "Status",
+ "description": "状态"
+ },
+ "jsonFile": {
+ "type": "string",
+ "title": "Jsonfile",
+ "description": "对应JSON文件"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "date",
+ "status",
+ "jsonFile"
+ ],
+ "title": "HistoryIndexItem"
+ },
+ "HistorySearchIn": {
+ "properties": {
+ "mode": {
+ "type": "string",
+ "enum": [
+ "DAILY",
+ "WEEKLY",
+ "MONTHLY"
+ ],
+ "title": "Mode",
+ "description": "合并模式"
+ },
+ "start_date": {
+ "type": "string",
+ "title": "Start Date",
+ "description": "开始日期, 格式YYYY-MM-DD"
+ },
+ "end_date": {
+ "type": "string",
+ "title": "End Date",
+ "description": "结束日期, 格式YYYY-MM-DD"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "mode",
+ "start_date",
+ "end_date"
+ ],
+ "title": "HistorySearchIn"
+ },
+ "HistorySearchOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "additionalProperties": {
+ "additionalProperties": {
+ "$ref": "#/components/schemas/HistoryData"
+ },
+ "type": "object"
+ },
+ "type": "object",
+ "title": "Data",
+ "description": "历史记录索引数据字典, 格式为 { '日期': { '用户名': [历史记录信息] } }"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "title": "HistorySearchOut"
+ },
+ "IndexOrderPatch": {
+ "properties": {
+ "index_list": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "title": "Index List",
+ "description": "按新顺序排列的资源 ID 列表"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "index_list"
+ ],
+ "title": "IndexOrderPatch"
+ },
+ "InfoOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "additionalProperties": true,
+ "type": "object",
+ "title": "Data",
+ "description": "收到的服务器数据"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "title": "InfoOut"
+ },
+ "InfrastructureImportBody": {
+ "properties": {
+ "path": {
+ "type": "string",
+ "title": "Path",
+ "description": "JSON 文件路径, 用于导入自定义基建文件"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "path"
+ ],
+ "title": "InfrastructureImportBody"
+ },
+ "MaaConfig": {
+ "properties": {
+ "Info": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MaaConfigInfo"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Emulator": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MaaConfigEmulator"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Run": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MaaConfigRun"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "type": {
+ "type": "string",
+ "const": "MaaConfig",
+ "title": "Type",
+ "description": "配置类型",
+ "default": "MaaConfig"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaConfig"
+ },
+ "MaaConfigEmulator": {
+ "properties": {
+ "Id": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Id"
+ },
+ "Index": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Index"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaConfigEmulator"
+ },
+ "MaaConfigInfo": {
+ "properties": {
+ "Name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Name"
+ },
+ "Path": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Path"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaConfigInfo"
+ },
+ "MaaConfigRun": {
+ "properties": {
+ "TaskTransitionMethod": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "NoAction",
+ "ExitGame",
+ "ExitEmulator"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Tasktransitionmethod"
+ },
+ "ProxyTimesLimit": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": 0.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Proxytimeslimit"
+ },
+ "RunTimesLimit": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": 1.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Runtimeslimit"
+ },
+ "AnnihilationTimeLimit": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": 1.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Annihilationtimelimit"
+ },
+ "RoutineTimeLimit": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": 1.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Routinetimelimit"
+ },
+ "AnnihilationAvoidWaste": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Annihilationavoidwaste"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaConfigRun"
+ },
+ "MaaEndConfig": {
+ "properties": {
+ "Info": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MaaEndConfigInfo"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Run": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MaaEndConfigRun"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Game": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MaaEndConfigGame"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "type": {
+ "type": "string",
+ "const": "MaaEndConfig",
+ "title": "Type",
+ "description": "配置类型",
+ "default": "MaaEndConfig"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaEndConfig"
+ },
+ "MaaEndConfigGame": {
+ "properties": {
+ "ControllerType": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "Win32-Window",
+ "Win32-Front",
+ "Win32-Window-Background",
+ "ADB"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Controllertype"
+ },
+ "Path": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Path"
+ },
+ "Arguments": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Arguments"
+ },
+ "WaitTime": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": 0.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Waittime"
+ },
+ "EmulatorId": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Emulatorid"
+ },
+ "EmulatorIndex": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Emulatorindex"
+ },
+ "CloseOnFinish": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Closeonfinish"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaEndConfigGame"
+ },
+ "MaaEndConfigInfo": {
+ "properties": {
+ "Name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Name"
+ },
+ "Path": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Path"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaEndConfigInfo"
+ },
+ "MaaEndConfigRun": {
+ "properties": {
+ "RunTimeLimit": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": 1.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Runtimelimit"
+ },
+ "ProxyTimesLimit": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": 0.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Proxytimeslimit"
+ },
+ "RunTimesLimit": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": 1.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Runtimeslimit"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaEndConfigRun"
+ },
+ "MaaEndUserConfig": {
+ "properties": {
+ "Info": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MaaEndUserConfigInfo"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Task": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MaaEndUserConfigTask"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Data": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MaaEndUserConfigData"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Notify": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MaaEndUserConfigNotify"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "type": {
+ "type": "string",
+ "const": "MaaEndUserConfig",
+ "title": "Type",
+ "description": "配置类型",
+ "default": "MaaEndUserConfig"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaEndUserConfig"
+ },
+ "MaaEndUserConfigData": {
+ "properties": {
+ "LastProxyDate": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Lastproxydate"
+ },
+ "ProxyTimes": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": 0.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Proxytimes"
+ },
+ "LastProxyStatus": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "未知",
+ "成功",
+ "失败"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Lastproxystatus"
+ },
+ "LastSklandDate": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Lastsklanddate"
+ },
+ "IfPassCheck": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifpasscheck"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaEndUserConfigData"
+ },
+ "MaaEndUserConfigInfo": {
+ "properties": {
+ "Name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Name"
+ },
+ "Status": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Status"
+ },
+ "Id": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Id"
+ },
+ "Password": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Password"
+ },
+ "Mode": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "简洁",
+ "详细"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Mode"
+ },
+ "Resource": {
+ "anyOf": [
+ {
+ "type": "string",
+ "const": "官服"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Resource"
+ },
+ "RemainedDay": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": -1.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Remainedday"
+ },
+ "Notes": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Notes"
+ },
+ "IfSkland": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifskland"
+ },
+ "SklandToken": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Sklandtoken"
+ },
+ "Tag": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Tag",
+ "readOnly": true
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaEndUserConfigInfo"
+ },
+ "MaaEndUserConfigNotify": {
+ "properties": {
+ "Enabled": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Enabled"
+ },
+ "IfSendStatistic": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifsendstatistic"
+ },
+ "IfSendMail": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifsendmail"
+ },
+ "ToAddress": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Toaddress"
+ },
+ "IfServerChan": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifserverchan"
+ },
+ "ServerChanKey": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Serverchankey"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaEndUserConfigNotify"
+ },
+ "MaaEndUserConfigTask": {
+ "properties": {
+ "ProtocolSpaceTab": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "OperatorProgression",
+ "WeaponProgression",
+ "CrisisDrills"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Protocolspacetab"
+ },
+ "OperatorProgression": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "OperatorEXP",
+ "Promotions",
+ "T-Creds",
+ "SkillUp"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Operatorprogression"
+ },
+ "WeaponProgression": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "WeaponEXP",
+ "WeaponTune"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Weaponprogression"
+ },
+ "CrisisDrills": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "AdvancedProgression1",
+ "AdvancedProgression2",
+ "AdvancedProgression3",
+ "AdvancedProgression4",
+ "AdvancedProgression5"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Crisisdrills"
+ },
+ "RewardsSetOption": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "RewardsSetA",
+ "RewardsSetB"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Rewardssetoption"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaEndUserConfigTask"
+ },
+ "MaaPlanDayPatch": {
+ "properties": {
+ "medicine_numb": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Medicine Numb",
+ "description": "吃理智药"
+ },
+ "series_numb": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "0",
+ "6",
+ "5",
+ "4",
+ "3",
+ "2",
+ "1",
+ "-1"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Series Numb",
+ "description": "连战次数"
+ },
+ "stage": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Stage",
+ "description": "关卡选择"
+ },
+ "stage_1": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Stage 1",
+ "description": "备选关卡 - 1"
+ },
+ "stage_2": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Stage 2",
+ "description": "备选关卡 - 2"
+ },
+ "stage_3": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Stage 3",
+ "description": "备选关卡 - 3"
+ },
+ "stage_remain": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Stage Remain",
+ "description": "剩余理智关卡"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaPlanDayPatch"
+ },
+ "MaaPlanDayRead": {
+ "properties": {
+ "MedicineNumb": {
+ "type": "integer",
+ "title": "Medicinenumb",
+ "description": "吃理智药",
+ "default": 0
+ },
+ "SeriesNumb": {
+ "type": "string",
+ "enum": [
+ "0",
+ "6",
+ "5",
+ "4",
+ "3",
+ "2",
+ "1",
+ "-1"
+ ],
+ "title": "Seriesnumb",
+ "description": "连战次数",
+ "default": "0"
+ },
+ "Stage": {
+ "type": "string",
+ "title": "Stage",
+ "description": "关卡选择",
+ "default": "-"
+ },
+ "Stage_1": {
+ "type": "string",
+ "title": "Stage 1",
+ "description": "备选关卡 - 1",
+ "default": "-"
+ },
+ "Stage_2": {
+ "type": "string",
+ "title": "Stage 2",
+ "description": "备选关卡 - 2",
+ "default": "-"
+ },
+ "Stage_3": {
+ "type": "string",
+ "title": "Stage 3",
+ "description": "备选关卡 - 3",
+ "default": "-"
+ },
+ "Stage_Remain": {
+ "type": "string",
+ "title": "Stage Remain",
+ "description": "剩余理智关卡",
+ "default": "-"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaPlanDayRead"
+ },
+ "MaaPlanInfoPatch": {
+ "properties": {
+ "name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Name",
+ "description": "计划表名称"
+ },
+ "mode": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "ALL",
+ "Weekly"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Mode",
+ "description": "计划表模式"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaPlanInfoPatch"
+ },
+ "MaaPlanInfoRead": {
+ "properties": {
+ "Name": {
+ "type": "string",
+ "title": "Name",
+ "description": "计划表名称",
+ "default": "新 MAA 计划表"
+ },
+ "Mode": {
+ "type": "string",
+ "enum": [
+ "ALL",
+ "Weekly"
+ ],
+ "title": "Mode",
+ "description": "计划表模式",
+ "default": "ALL"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaPlanInfoRead"
+ },
+ "MaaPlanPatch": {
+ "properties": {
+ "info": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MaaPlanInfoPatch"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "基础信息"
+ },
+ "all_days": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MaaPlanDayPatch"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "全局"
+ },
+ "monday": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MaaPlanDayPatch"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "周一"
+ },
+ "tuesday": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MaaPlanDayPatch"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "周二"
+ },
+ "wednesday": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MaaPlanDayPatch"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "周三"
+ },
+ "thursday": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MaaPlanDayPatch"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "周四"
+ },
+ "friday": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MaaPlanDayPatch"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "周五"
+ },
+ "saturday": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MaaPlanDayPatch"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "周六"
+ },
+ "sunday": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MaaPlanDayPatch"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "周日"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaPlanPatch"
+ },
+ "MaaPlanRead": {
+ "properties": {
+ "Info": {
+ "$ref": "#/components/schemas/MaaPlanInfoRead",
+ "description": "基础信息"
+ },
+ "ALL": {
+ "$ref": "#/components/schemas/MaaPlanDayRead",
+ "description": "全局"
+ },
+ "Monday": {
+ "$ref": "#/components/schemas/MaaPlanDayRead",
+ "description": "周一"
+ },
+ "Tuesday": {
+ "$ref": "#/components/schemas/MaaPlanDayRead",
+ "description": "周二"
+ },
+ "Wednesday": {
+ "$ref": "#/components/schemas/MaaPlanDayRead",
+ "description": "周三"
+ },
+ "Thursday": {
+ "$ref": "#/components/schemas/MaaPlanDayRead",
+ "description": "周四"
+ },
+ "Friday": {
+ "$ref": "#/components/schemas/MaaPlanDayRead",
+ "description": "周五"
+ },
+ "Saturday": {
+ "$ref": "#/components/schemas/MaaPlanDayRead",
+ "description": "周六"
+ },
+ "Sunday": {
+ "$ref": "#/components/schemas/MaaPlanDayRead",
+ "description": "周日"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaPlanRead"
+ },
+ "MaaUserConfig": {
+ "properties": {
+ "Info": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MaaUserConfigInfo"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Data": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MaaUserConfigData"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Task": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MaaUserConfigTask"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Notify": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MaaUserConfigNotify"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "type": {
+ "type": "string",
+ "const": "MaaUserConfig",
+ "title": "Type",
+ "description": "配置类型",
+ "default": "MaaUserConfig"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaUserConfig"
+ },
+ "MaaUserConfigData": {
+ "properties": {
+ "LastProxyDate": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Lastproxydate"
+ },
+ "LastSklandDate": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Lastsklanddate"
+ },
+ "ProxyTimes": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "minimum": 0.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Proxytimes"
+ },
+ "IfPassCheck": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifpasscheck"
+ },
+ "CustomInfrast": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Custominfrast"
+ },
+ "InfrastIndex": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Infrastindex"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaUserConfigData"
+ },
+ "MaaUserConfigInfo": {
+ "properties": {
+ "Name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Name"
+ },
+ "Id": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Id"
+ },
+ "Password": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Password"
+ },
+ "Mode": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "简洁",
+ "详细"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Mode"
+ },
+ "StageMode": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Stagemode"
+ },
+ "Server": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "Official",
+ "Bilibili",
+ "YoStarEN",
+ "YoStarJP",
+ "YoStarKR",
+ "txwy"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Server"
+ },
+ "Status": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Status"
+ },
+ "RemainedDay": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": -1.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Remainedday"
+ },
+ "Annihilation": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "Close",
+ "Annihilation",
+ "Chernobog@Annihilation",
+ "LungmenOutskirts@Annihilation",
+ "LungmenDowntown@Annihilation"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Annihilation"
+ },
+ "InfrastMode": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "Normal",
+ "Rotation",
+ "Custom"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Infrastmode"
+ },
+ "InfrastName": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Infrastname",
+ "readOnly": true
+ },
+ "InfrastIndex": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Infrastindex",
+ "readOnly": true
+ },
+ "Notes": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Notes"
+ },
+ "MedicineNumb": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "minimum": 0.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Medicinenumb"
+ },
+ "SeriesNumb": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "0",
+ "6",
+ "5",
+ "4",
+ "3",
+ "2",
+ "1",
+ "-1"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Seriesnumb"
+ },
+ "Stage": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Stage"
+ },
+ "Stage_1": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Stage 1"
+ },
+ "Stage_2": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Stage 2"
+ },
+ "Stage_3": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Stage 3"
+ },
+ "Stage_Remain": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Stage Remain"
+ },
+ "IfSkland": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifskland"
+ },
+ "SklandToken": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Sklandtoken"
+ },
+ "Tag": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Tag",
+ "readOnly": true
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaUserConfigInfo"
+ },
+ "MaaUserConfigNotify": {
+ "properties": {
+ "Enabled": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Enabled"
+ },
+ "IfSendStatistic": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifsendstatistic"
+ },
+ "IfSendSixStar": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifsendsixstar"
+ },
+ "IfSendMail": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifsendmail"
+ },
+ "ToAddress": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Toaddress"
+ },
+ "IfServerChan": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifserverchan"
+ },
+ "ServerChanKey": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Serverchankey"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaUserConfigNotify"
+ },
+ "MaaUserConfigTask": {
+ "properties": {
+ "IfStartUp": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifstartup"
+ },
+ "IfFight": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Iffight"
+ },
+ "IfInfrast": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifinfrast"
+ },
+ "IfRecruit": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifrecruit"
+ },
+ "IfMall": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifmall"
+ },
+ "IfAward": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifaward"
+ },
+ "IfRoguelike": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifroguelike"
+ },
+ "IfReclamation": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifreclamation"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "MaaUserConfigTask"
+ },
+ "NoticeOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "if_need_show": {
+ "type": "boolean",
+ "title": "If Need Show",
+ "description": "是否需要显示公告"
+ },
+ "data": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "type": "object",
+ "title": "Data",
+ "description": "公告信息, key为公告标题, value为公告内容"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "if_need_show",
+ "data"
+ ],
+ "title": "NoticeOut"
+ },
+ "OCRScreenshotIn": {
+ "properties": {
+ "window_title": {
+ "type": "string",
+ "title": "Window Title",
+ "description": "窗口标题(用于查找窗口)"
+ },
+ "should_preprocess": {
+ "type": "boolean",
+ "title": "Should Preprocess",
+ "description": "是否预处理图片区域,True时排除边框和标题栏,False时使用完整窗口",
+ "default": true
+ },
+ "aspect_ratio_width": {
+ "type": "integer",
+ "title": "Aspect Ratio Width",
+ "description": "宽高比宽度",
+ "default": 16
+ },
+ "aspect_ratio_height": {
+ "type": "integer",
+ "title": "Aspect Ratio Height",
+ "description": "宽高比高度",
+ "default": 9
+ },
+ "region": {
+ "anyOf": [
+ {
+ "prefixItems": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "integer"
+ },
+ {
+ "type": "integer"
+ },
+ {
+ "type": "integer"
+ }
+ ],
+ "type": "array",
+ "maxItems": 4,
+ "minItems": 4
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Region",
+ "description": "自定义截图区域 (left, top, width, height)"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "window_title"
+ ],
+ "title": "OCRScreenshotIn"
+ },
+ "OCRScreenshotOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "image_base64": {
+ "type": "string",
+ "title": "Image Base64",
+ "description": "截图的Base64编码(PNG格式)"
+ },
+ "region": {
+ "prefixItems": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "integer"
+ },
+ {
+ "type": "integer"
+ },
+ {
+ "type": "integer"
+ }
+ ],
+ "type": "array",
+ "maxItems": 4,
+ "minItems": 4,
+ "title": "Region",
+ "description": "实际使用的截图区域 (left, top, width, height)"
+ },
+ "image_width": {
+ "type": "integer",
+ "title": "Image Width",
+ "description": "截图宽度"
+ },
+ "image_height": {
+ "type": "integer",
+ "title": "Image Height",
+ "description": "截图高度"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "image_base64",
+ "region",
+ "image_width",
+ "image_height"
+ ],
+ "title": "OCRScreenshotOut"
+ },
+ "OutBase": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "OutBase"
+ },
+ "PlanCreateIn": {
+ "properties": {
+ "type": {
+ "type": "string",
+ "const": "MaaPlan",
+ "title": "Type",
+ "description": "计划类型"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "type"
+ ],
+ "title": "PlanCreateIn"
+ },
+ "PlanCreateOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "$ref": "#/components/schemas/MaaPlanRead",
+ "description": "资源数据"
+ },
+ "id": {
+ "type": "string",
+ "title": "Id",
+ "description": "新创建资源的唯一 ID"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data",
+ "id"
+ ],
+ "title": "PlanCreateOut",
+ "description": "计划创建响应模型"
+ },
+ "PlanDetailOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "$ref": "#/components/schemas/MaaPlanRead",
+ "description": "资源数据"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "title": "PlanDetailOut",
+ "description": "计划详情响应模型"
+ },
+ "PlanGetOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "index": {
+ "items": {
+ "$ref": "#/components/schemas/PlanIndexItem"
+ },
+ "type": "array",
+ "title": "Index",
+ "description": "资源索引列表"
+ },
+ "data": {
+ "additionalProperties": {
+ "$ref": "#/components/schemas/MaaPlanRead"
+ },
+ "type": "object",
+ "title": "Data",
+ "description": "资源数据字典"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "index",
+ "data"
+ ],
+ "title": "PlanGetOut",
+ "description": "计划列表响应模型"
+ },
+ "PlanIndexItem": {
+ "properties": {
+ "uid": {
+ "type": "string",
+ "title": "Uid",
+ "description": "唯一标识符"
+ },
+ "type": {
+ "type": "string",
+ "const": "MaaPlanConfig",
+ "title": "Type",
+ "description": "配置类型"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "uid",
+ "type"
+ ],
+ "title": "PlanIndexItem"
+ },
+ "PlanUpdateBody": {
+ "properties": {
+ "data": {
+ "$ref": "#/components/schemas/MaaPlanPatch",
+ "description": "计划更新数据"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "title": "PlanUpdateBody"
+ },
+ "PluginAddIn": {
+ "properties": {
+ "plugin": {
+ "type": "string",
+ "title": "Plugin",
+ "description": "插件名"
+ },
+ "name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Name",
+ "description": "实例名称"
+ },
+ "enabled": {
+ "type": "boolean",
+ "title": "Enabled",
+ "description": "是否启用",
+ "default": true
+ },
+ "config": {
+ "additionalProperties": true,
+ "type": "object",
+ "title": "Config",
+ "description": "插件配置"
+ }
+ },
+ "type": "object",
+ "required": [
+ "plugin"
+ ],
+ "title": "PluginAddIn"
+ },
+ "PluginDeleteIn": {
+ "properties": {
+ "instanceId": {
+ "type": "string",
+ "title": "Instanceid",
+ "description": "实例ID"
+ }
+ },
+ "type": "object",
+ "required": [
+ "instanceId"
+ ],
+ "title": "PluginDeleteIn"
+ },
+ "PluginDevRebuildCtxStubIn": {
+ "properties": {
+ "force": {
+ "type": "boolean",
+ "title": "Force",
+ "description": "是否强制重建",
+ "default": false
+ }
+ },
+ "type": "object",
+ "title": "PluginDevRebuildCtxStubIn"
+ },
+ "PluginDevRebuildCtxStubOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "output_dir": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Output Dir",
+ "description": "生成目录"
+ },
+ "changed_files": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "title": "Changed Files",
+ "description": "已更新文件"
+ },
+ "unchanged_files": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "title": "Unchanged Files",
+ "description": "未变更文件"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "PluginDevRebuildCtxStubOut"
+ },
+ "PluginInstanceModel": {
+ "properties": {
+ "id": {
+ "type": "string",
+ "title": "Id",
+ "description": "实例ID"
+ },
+ "plugin": {
+ "type": "string",
+ "title": "Plugin",
+ "description": "插件名"
+ },
+ "enabled": {
+ "type": "boolean",
+ "title": "Enabled",
+ "description": "是否启用",
+ "default": true
+ },
+ "name": {
+ "type": "string",
+ "title": "Name",
+ "description": "实例名称"
+ },
+ "config": {
+ "additionalProperties": true,
+ "type": "object",
+ "title": "Config",
+ "description": "插件配置"
+ }
+ },
+ "type": "object",
+ "required": [
+ "id",
+ "plugin",
+ "name"
+ ],
+ "title": "PluginInstanceModel"
+ },
+ "PluginMutationOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "instance": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/PluginInstanceModel"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "当前实例"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "PluginMutationOut"
+ },
+ "PluginPackageIn": {
+ "properties": {
+ "package": {
+ "type": "string",
+ "title": "Package",
+ "description": "PyPI 包名"
+ }
+ },
+ "type": "object",
+ "required": [
+ "package"
+ ],
+ "title": "PluginPackageIn"
+ },
+ "PluginReloadInstanceIn": {
+ "properties": {
+ "instanceId": {
+ "type": "string",
+ "title": "Instanceid",
+ "description": "实例ID"
+ }
+ },
+ "type": "object",
+ "required": [
+ "instanceId"
+ ],
+ "title": "PluginReloadInstanceIn"
+ },
+ "PluginReloadPluginIn": {
+ "properties": {
+ "plugin": {
+ "type": "string",
+ "title": "Plugin",
+ "description": "插件名"
+ }
+ },
+ "type": "object",
+ "required": [
+ "plugin"
+ ],
+ "title": "PluginReloadPluginIn"
+ },
+ "PluginRuntimeStateModel": {
+ "properties": {
+ "instance_id": {
+ "type": "string",
+ "title": "Instance Id",
+ "description": "实例ID"
+ },
+ "plugin": {
+ "type": "string",
+ "title": "Plugin",
+ "description": "插件名"
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "运行状态",
+ "default": "configured"
+ },
+ "generation": {
+ "type": "integer",
+ "title": "Generation",
+ "description": "实例代际",
+ "default": 0
+ },
+ "lifecycle_phase": {
+ "type": "string",
+ "title": "Lifecycle Phase",
+ "description": "生命周期阶段",
+ "default": "configured"
+ },
+ "lifecycle_updated_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Lifecycle Updated At",
+ "description": "生命周期阶段更新时间"
+ },
+ "reload_count": {
+ "type": "integer",
+ "title": "Reload Count",
+ "description": "成功重载次数",
+ "default": 0
+ },
+ "last_reload_reason": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Last Reload Reason",
+ "description": "最近重载原因"
+ },
+ "last_reload_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Last Reload At",
+ "description": "最近重载时间"
+ },
+ "created_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Created At",
+ "description": "记录创建时间"
+ },
+ "discovered_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Discovered At",
+ "description": "发现时间"
+ },
+ "loaded_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Loaded At",
+ "description": "代码加载时间"
+ },
+ "activated_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Activated At",
+ "description": "激活时间"
+ },
+ "disposed_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Disposed At",
+ "description": "销毁时间"
+ },
+ "unloaded_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Unloaded At",
+ "description": "卸载时间"
+ },
+ "last_error": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Last Error",
+ "description": "最近错误"
+ },
+ "last_error_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Last Error At",
+ "description": "最近错误时间"
+ }
+ },
+ "type": "object",
+ "required": [
+ "instance_id",
+ "plugin"
+ ],
+ "title": "PluginRuntimeStateModel"
+ },
+ "PluginUpdateIn": {
+ "properties": {
+ "instanceId": {
+ "type": "string",
+ "title": "Instanceid",
+ "description": "实例ID"
+ },
+ "plugin": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Plugin",
+ "description": "插件名"
+ },
+ "name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Name",
+ "description": "实例名称"
+ },
+ "enabled": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Enabled",
+ "description": "是否启用"
+ },
+ "config": {
+ "anyOf": [
+ {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Config",
+ "description": "插件配置"
+ }
+ },
+ "type": "object",
+ "required": [
+ "instanceId"
+ ],
+ "title": "PluginUpdateIn"
+ },
+ "PluginsGetOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "discovered_plugins": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "title": "Discovered Plugins",
+ "description": "已发现插件"
+ },
+ "schemas": {
+ "additionalProperties": {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ "type": "object",
+ "title": "Schemas",
+ "description": "插件Schema映射"
+ },
+ "schema_errors": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "type": "object",
+ "title": "Schema Errors",
+ "description": "插件Schema加载错误"
+ },
+ "instances": {
+ "items": {
+ "$ref": "#/components/schemas/PluginInstanceModel"
+ },
+ "type": "array",
+ "title": "Instances",
+ "description": "插件实例列表"
+ },
+ "runtime_states": {
+ "additionalProperties": {
+ "$ref": "#/components/schemas/PluginRuntimeStateModel"
+ },
+ "type": "object",
+ "title": "Runtime States",
+ "description": "插件实例运行态"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "PluginsGetOut"
+ },
+ "PowerIn": {
+ "properties": {
+ "signal": {
+ "type": "string",
+ "enum": [
+ "NoAction",
+ "Shutdown",
+ "ShutdownForce",
+ "Reboot",
+ "Hibernate",
+ "Sleep",
+ "KillSelf"
+ ],
+ "title": "Signal",
+ "description": "电源操作信号"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "signal"
+ ],
+ "title": "PowerIn"
+ },
+ "PowerOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "signal": {
+ "type": "string",
+ "enum": [
+ "NoAction",
+ "Shutdown",
+ "ShutdownForce",
+ "Reboot",
+ "Hibernate",
+ "Sleep",
+ "KillSelf"
+ ],
+ "title": "Signal",
+ "description": "电源操作信号"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "signal"
+ ],
+ "title": "PowerOut"
+ },
+ "QueueCreateOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "$ref": "#/components/schemas/QueueRead",
+ "description": "资源数据"
+ },
+ "id": {
+ "type": "string",
+ "title": "Id",
+ "description": "新创建资源的唯一 ID"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data",
+ "id"
+ ],
+ "title": "QueueCreateOut",
+ "description": "队列创建响应模型"
+ },
+ "QueueDetailOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "$ref": "#/components/schemas/QueueRead",
+ "description": "资源数据"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "title": "QueueDetailOut",
+ "description": "队列详情响应模型"
+ },
+ "QueueGetOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "index": {
+ "items": {
+ "$ref": "#/components/schemas/QueueIndexItem"
+ },
+ "type": "array",
+ "title": "Index",
+ "description": "资源索引列表"
+ },
+ "data": {
+ "additionalProperties": {
+ "$ref": "#/components/schemas/QueueRead"
+ },
+ "type": "object",
+ "title": "Data",
+ "description": "资源数据字典"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "index",
+ "data"
+ ],
+ "title": "QueueGetOut",
+ "description": "队列列表响应模型"
+ },
+ "QueueIndexItem": {
+ "properties": {
+ "uid": {
+ "type": "string",
+ "title": "Uid",
+ "description": "唯一标识符"
+ },
+ "type": {
+ "type": "string",
+ "const": "QueueConfig",
+ "title": "Type",
+ "description": "配置类型"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "uid",
+ "type"
+ ],
+ "title": "QueueIndexItem"
+ },
+ "QueueItemCreateOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "$ref": "#/components/schemas/QueueItemRead",
+ "description": "资源数据"
+ },
+ "id": {
+ "type": "string",
+ "title": "Id",
+ "description": "新创建资源的唯一 ID"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data",
+ "id"
+ ],
+ "title": "QueueItemCreateOut",
+ "description": "队列项创建响应模型"
+ },
+ "QueueItemDetailOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "$ref": "#/components/schemas/QueueItemRead",
+ "description": "资源数据"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "title": "QueueItemDetailOut",
+ "description": "队列项详情响应模型"
+ },
+ "QueueItemGetOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "index": {
+ "items": {
+ "$ref": "#/components/schemas/QueueItemIndexItem"
+ },
+ "type": "array",
+ "title": "Index",
+ "description": "资源索引列表"
+ },
+ "data": {
+ "additionalProperties": {
+ "$ref": "#/components/schemas/QueueItemRead"
+ },
+ "type": "object",
+ "title": "Data",
+ "description": "资源数据字典"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "index",
+ "data"
+ ],
+ "title": "QueueItemGetOut",
+ "description": "队列项列表响应模型"
+ },
+ "QueueItemIndexItem": {
+ "properties": {
+ "uid": {
+ "type": "string",
+ "title": "Uid",
+ "description": "唯一标识符"
+ },
+ "type": {
+ "type": "string",
+ "const": "QueueItem",
+ "title": "Type",
+ "description": "配置类型"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "uid",
+ "type"
+ ],
+ "title": "QueueItemIndexItem"
+ },
+ "QueueItemRead": {
+ "properties": {
+ "Info": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/QueueItemReadInfo"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "QueueItemRead",
+ "description": "任务项读取/写入模型。"
+ },
+ "QueueItemReadInfo": {
+ "properties": {
+ "ScriptId": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Scriptid"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "QueueItemReadInfo"
+ },
+ "QueueRead": {
+ "properties": {
+ "Info": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/QueueReadInfo"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "QueueRead",
+ "description": "队列配置读取/写入模型。"
+ },
+ "QueueReadInfo": {
+ "properties": {
+ "Name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Name"
+ },
+ "TimeEnabled": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Timeenabled"
+ },
+ "StartUpEnabled": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Startupenabled"
+ },
+ "AfterAccomplish": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "NoAction",
+ "Shutdown",
+ "ShutdownForce",
+ "Reboot",
+ "Hibernate",
+ "Sleep",
+ "KillSelf"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Afteraccomplish"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "QueueReadInfo"
+ },
+ "ScriptCreateIn": {
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "MAA",
+ "SRC",
+ "General",
+ "MaaEnd"
+ ],
+ "title": "Type",
+ "description": "脚本类型: MAA脚本, 通用脚本, SRC脚本, MaaEnd脚本"
+ },
+ "copyFromId": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Copyfromid",
+ "description": "直接从该脚本 ID 复制创建, 仅复制创建时使用"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "type"
+ ],
+ "title": "ScriptCreateIn"
+ },
+ "ScriptCreateOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/MaaConfig"
+ },
+ {
+ "$ref": "#/components/schemas/SrcConfig"
+ },
+ {
+ "$ref": "#/components/schemas/GeneralConfig"
+ },
+ {
+ "$ref": "#/components/schemas/MaaEndConfig"
+ }
+ ],
+ "title": "Data",
+ "description": "资源数据",
+ "discriminator": {
+ "propertyName": "type",
+ "mapping": {
+ "GeneralConfig": "#/components/schemas/GeneralConfig",
+ "MaaConfig": "#/components/schemas/MaaConfig",
+ "MaaEndConfig": "#/components/schemas/MaaEndConfig",
+ "SrcConfig": "#/components/schemas/SrcConfig"
+ }
+ }
+ },
+ "id": {
+ "type": "string",
+ "title": "Id",
+ "description": "新创建资源的唯一 ID"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data",
+ "id"
+ ],
+ "title": "ScriptCreateOut",
+ "description": "脚本创建响应模型"
+ },
+ "ScriptDetailOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/MaaConfig"
+ },
+ {
+ "$ref": "#/components/schemas/SrcConfig"
+ },
+ {
+ "$ref": "#/components/schemas/GeneralConfig"
+ },
+ {
+ "$ref": "#/components/schemas/MaaEndConfig"
+ }
+ ],
+ "title": "Data",
+ "description": "资源数据",
+ "discriminator": {
+ "propertyName": "type",
+ "mapping": {
+ "GeneralConfig": "#/components/schemas/GeneralConfig",
+ "MaaConfig": "#/components/schemas/MaaConfig",
+ "MaaEndConfig": "#/components/schemas/MaaEndConfig",
+ "SrcConfig": "#/components/schemas/SrcConfig"
+ }
+ }
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "title": "ScriptDetailOut",
+ "description": "脚本详情响应模型"
+ },
+ "ScriptFileBody": {
+ "properties": {
+ "path": {
+ "type": "string",
+ "title": "Path",
+ "description": "文件路径"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "path"
+ ],
+ "title": "ScriptFileBody"
+ },
+ "ScriptGetOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "index": {
+ "items": {
+ "$ref": "#/components/schemas/ScriptIndexItem"
+ },
+ "type": "array",
+ "title": "Index",
+ "description": "资源索引列表"
+ },
+ "data": {
+ "additionalProperties": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/MaaConfig"
+ },
+ {
+ "$ref": "#/components/schemas/SrcConfig"
+ },
+ {
+ "$ref": "#/components/schemas/GeneralConfig"
+ },
+ {
+ "$ref": "#/components/schemas/MaaEndConfig"
+ }
+ ],
+ "discriminator": {
+ "propertyName": "type",
+ "mapping": {
+ "GeneralConfig": "#/components/schemas/GeneralConfig",
+ "MaaConfig": "#/components/schemas/MaaConfig",
+ "MaaEndConfig": "#/components/schemas/MaaEndConfig",
+ "SrcConfig": "#/components/schemas/SrcConfig"
+ }
+ }
+ },
+ "type": "object",
+ "title": "Data",
+ "description": "资源数据字典"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "index",
+ "data"
+ ],
+ "title": "ScriptGetOut",
+ "description": "脚本列表响应模型"
+ },
+ "ScriptIndexItem": {
+ "properties": {
+ "uid": {
+ "type": "string",
+ "title": "Uid",
+ "description": "唯一标识符"
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "MaaConfig",
+ "GeneralConfig",
+ "SrcConfig",
+ "MaaEndConfig"
+ ],
+ "title": "Type",
+ "description": "配置类型"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "uid",
+ "type"
+ ],
+ "title": "ScriptIndexItem"
+ },
+ "ScriptPatchBody": {
+ "properties": {
+ "data": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/MaaConfig"
+ },
+ {
+ "$ref": "#/components/schemas/SrcConfig"
+ },
+ {
+ "$ref": "#/components/schemas/GeneralConfig"
+ },
+ {
+ "$ref": "#/components/schemas/MaaEndConfig"
+ }
+ ],
+ "title": "Data",
+ "description": "脚本 Patch 数据",
+ "discriminator": {
+ "propertyName": "type",
+ "mapping": {
+ "GeneralConfig": "#/components/schemas/GeneralConfig",
+ "MaaConfig": "#/components/schemas/MaaConfig",
+ "MaaEndConfig": "#/components/schemas/MaaEndConfig",
+ "SrcConfig": "#/components/schemas/SrcConfig"
+ }
+ }
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "title": "ScriptPatchBody"
+ },
+ "ScriptUploadBody": {
+ "properties": {
+ "config_name": {
+ "type": "string",
+ "title": "Config Name",
+ "description": "配置名称"
+ },
+ "author": {
+ "type": "string",
+ "title": "Author",
+ "description": "作者"
+ },
+ "description": {
+ "type": "string",
+ "title": "Description",
+ "description": "描述"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "config_name",
+ "author",
+ "description"
+ ],
+ "title": "ScriptUploadBody"
+ },
+ "ScriptUrlBody": {
+ "properties": {
+ "url": {
+ "type": "string",
+ "title": "Url",
+ "description": "配置文件 URL"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "url"
+ ],
+ "title": "ScriptUrlBody"
+ },
+ "SettingGetOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "$ref": "#/components/schemas/GlobalConfigRead",
+ "description": "资源数据"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "title": "SettingGetOut",
+ "description": "全局设置响应模型"
+ },
+ "SrcConfig": {
+ "properties": {
+ "Info": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/SrcConfigInfo"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Emulator": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/SrcConfigEmulator"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Run": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/SrcConfigRun"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "type": {
+ "type": "string",
+ "const": "SrcConfig",
+ "title": "Type",
+ "description": "配置类型",
+ "default": "SrcConfig"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "SrcConfig"
+ },
+ "SrcConfigEmulator": {
+ "properties": {
+ "Id": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Id"
+ },
+ "Index": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Index"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "SrcConfigEmulator"
+ },
+ "SrcConfigInfo": {
+ "properties": {
+ "Name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Name"
+ },
+ "Path": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Path"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "SrcConfigInfo"
+ },
+ "SrcConfigRun": {
+ "properties": {
+ "TaskTransitionMethod": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "ExitGame",
+ "ExitEmulator"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Tasktransitionmethod"
+ },
+ "ProxyTimesLimit": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": 0.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Proxytimeslimit"
+ },
+ "RunTimesLimit": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": 1.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Runtimeslimit"
+ },
+ "RunTimeLimit": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": 1.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Runtimelimit"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "SrcConfigRun"
+ },
+ "SrcUserConfig": {
+ "properties": {
+ "Info": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/SrcUserConfigInfo"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Stage": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/SrcUserConfigStage"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Data": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/SrcUserConfigData"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Notify": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/SrcUserConfigNotify"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "type": {
+ "type": "string",
+ "const": "SrcUserConfig",
+ "title": "Type",
+ "description": "配置类型",
+ "default": "SrcUserConfig"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "SrcUserConfig"
+ },
+ "SrcUserConfigData": {
+ "properties": {
+ "LastProxyDate": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Lastproxydate"
+ },
+ "ProxyTimes": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": 0.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Proxytimes"
+ },
+ "IfPassCheck": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifpasscheck"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "SrcUserConfigData"
+ },
+ "SrcUserConfigInfo": {
+ "properties": {
+ "Name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Name"
+ },
+ "Status": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Status"
+ },
+ "Id": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Id"
+ },
+ "Password": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Password"
+ },
+ "Mode": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "简洁",
+ "详细"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Mode"
+ },
+ "Server": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "CN-Official",
+ "CN-Bilibili",
+ "VN-Official",
+ "OVERSEA-America",
+ "OVERSEA-Asia",
+ "OVERSEA-Europe",
+ "OVERSEA-TWHKMO"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Server"
+ },
+ "RemainedDay": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": -1.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Remainedday"
+ },
+ "Notes": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Notes"
+ },
+ "Tag": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Tag",
+ "readOnly": true
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "SrcUserConfigInfo"
+ },
+ "SrcUserConfigNotify": {
+ "properties": {
+ "Enabled": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Enabled"
+ },
+ "IfSendStatistic": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifsendstatistic"
+ },
+ "IfSendMail": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifsendmail"
+ },
+ "ToAddress": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Toaddress"
+ },
+ "IfServerChan": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ifserverchan"
+ },
+ "ServerChanKey": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Serverchankey"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "SrcUserConfigNotify"
+ },
+ "SrcUserConfigStage": {
+ "properties": {
+ "Channel": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "Relic",
+ "Materials",
+ "Ornament"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Channel"
+ },
+ "Relic": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Relic"
+ },
+ "Materials": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Materials"
+ },
+ "Ornament": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ornament"
+ },
+ "ExtractReservedTrailblazePower": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Extractreservedtrailblazepower"
+ },
+ "UseFuel": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Usefuel"
+ },
+ "FuelReserve": {
+ "anyOf": [
+ {
+ "type": "integer",
+ "maximum": 9999.0,
+ "minimum": 0.0
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Fuelreserve"
+ },
+ "EchoOfWar": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Echoofwar"
+ },
+ "SimulatedUniverseWorld": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Simulateduniverseworld"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "SrcUserConfigStage"
+ },
+ "TaskCreateIn": {
+ "properties": {
+ "taskId": {
+ "type": "string",
+ "title": "Taskid",
+ "description": "目标任务ID, 设置类任务可选对应脚本ID或用户ID, 代理类任务可选对应队列ID或脚本ID"
+ },
+ "mode": {
+ "type": "string",
+ "enum": [
+ "AutoProxy",
+ "ManualReview",
+ "ScriptConfig"
+ ],
+ "title": "Mode",
+ "description": "任务模式"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "taskId",
+ "mode"
+ ],
+ "title": "TaskCreateIn"
+ },
+ "TaskCreateOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "taskId": {
+ "type": "string",
+ "title": "Taskid",
+ "description": "新创建的任务ID"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "taskId"
+ ],
+ "title": "TaskCreateOut"
+ },
+ "TimeSetCreateOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "$ref": "#/components/schemas/TimeSetRead",
+ "description": "资源数据"
+ },
+ "id": {
+ "type": "string",
+ "title": "Id",
+ "description": "新创建资源的唯一 ID"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data",
+ "id"
+ ],
+ "title": "TimeSetCreateOut",
+ "description": "时间集创建响应模型"
+ },
+ "TimeSetDetailOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "$ref": "#/components/schemas/TimeSetRead",
+ "description": "资源数据"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "title": "TimeSetDetailOut",
+ "description": "时间集详情响应模型"
+ },
+ "TimeSetGetOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "index": {
+ "items": {
+ "$ref": "#/components/schemas/TimeSetIndexItem"
+ },
+ "type": "array",
+ "title": "Index",
+ "description": "资源索引列表"
+ },
+ "data": {
+ "additionalProperties": {
+ "$ref": "#/components/schemas/TimeSetRead"
+ },
+ "type": "object",
+ "title": "Data",
+ "description": "资源数据字典"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "index",
+ "data"
+ ],
+ "title": "TimeSetGetOut",
+ "description": "时间集列表响应模型"
+ },
+ "TimeSetIndexItem": {
+ "properties": {
+ "uid": {
+ "type": "string",
+ "title": "Uid",
+ "description": "唯一标识符"
+ },
+ "type": {
+ "type": "string",
+ "const": "TimeSet",
+ "title": "Type",
+ "description": "配置类型"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "uid",
+ "type"
+ ],
+ "title": "TimeSetIndexItem"
+ },
+ "TimeSetRead": {
+ "properties": {
+ "Info": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/TimeSetReadInfo"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "TimeSetRead",
+ "description": "时间集合读取/写入模型。"
+ },
+ "TimeSetReadInfo": {
+ "properties": {
+ "Enabled": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Enabled"
+ },
+ "Days": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Days"
+ },
+ "Time": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Time"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "TimeSetReadInfo"
+ },
+ "ToolsConfigRead": {
+ "properties": {
+ "ArknightsPC": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/ToolsConfigReadArknightsPC"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "ToolsConfigRead",
+ "description": "工具配置读取/写入模型。"
+ },
+ "ToolsConfigReadArknightsPC": {
+ "properties": {
+ "Enabled": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Enabled"
+ },
+ "PauseKey": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Pausekey"
+ },
+ "SelectDeployedKey": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Selectdeployedkey"
+ },
+ "UseSkillKey": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Useskillkey"
+ },
+ "RetreatKey": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Retreatkey"
+ },
+ "NextFrameKey": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Nextframekey"
+ },
+ "AnotherQuitKey": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Anotherquitkey"
+ },
+ "Status": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Status",
+ "readOnly": true
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "ToolsConfigReadArknightsPC"
+ },
+ "ToolsGetOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "$ref": "#/components/schemas/ToolsConfigRead",
+ "description": "资源数据"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "title": "ToolsGetOut",
+ "description": "工具配置响应模型"
+ },
+ "UpdateCheckIn": {
+ "properties": {
+ "current_version": {
+ "type": "string",
+ "title": "Current Version",
+ "description": "当前前端版本号"
+ },
+ "if_force": {
+ "type": "boolean",
+ "title": "If Force",
+ "description": "是否强制拉取更新信息",
+ "default": false
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "current_version"
+ ],
+ "title": "UpdateCheckIn"
+ },
+ "UpdateCheckOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "if_need_update": {
+ "type": "boolean",
+ "title": "If Need Update",
+ "description": "是否需要更新前端"
+ },
+ "latest_version": {
+ "type": "string",
+ "title": "Latest Version",
+ "description": "最新前端版本号"
+ },
+ "update_info": {
+ "additionalProperties": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "type": "object",
+ "title": "Update Info",
+ "description": "版本更新信息字典"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "if_need_update",
+ "latest_version",
+ "update_info"
+ ],
+ "title": "UpdateCheckOut"
+ },
+ "UserCreateOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/MaaUserConfig"
+ },
+ {
+ "$ref": "#/components/schemas/SrcUserConfig"
+ },
+ {
+ "$ref": "#/components/schemas/GeneralUserConfig"
+ },
+ {
+ "$ref": "#/components/schemas/MaaEndUserConfig"
+ }
+ ],
+ "title": "Data",
+ "description": "资源数据",
+ "discriminator": {
+ "propertyName": "type",
+ "mapping": {
+ "GeneralUserConfig": "#/components/schemas/GeneralUserConfig",
+ "MaaEndUserConfig": "#/components/schemas/MaaEndUserConfig",
+ "MaaUserConfig": "#/components/schemas/MaaUserConfig",
+ "SrcUserConfig": "#/components/schemas/SrcUserConfig"
+ }
+ }
+ },
+ "id": {
+ "type": "string",
+ "title": "Id",
+ "description": "新创建资源的唯一 ID"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data",
+ "id"
+ ],
+ "title": "UserCreateOut",
+ "description": "用户创建响应模型"
+ },
+ "UserDetailOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/MaaUserConfig"
+ },
+ {
+ "$ref": "#/components/schemas/SrcUserConfig"
+ },
+ {
+ "$ref": "#/components/schemas/GeneralUserConfig"
+ },
+ {
+ "$ref": "#/components/schemas/MaaEndUserConfig"
+ }
+ ],
+ "title": "Data",
+ "description": "资源数据",
+ "discriminator": {
+ "propertyName": "type",
+ "mapping": {
+ "GeneralUserConfig": "#/components/schemas/GeneralUserConfig",
+ "MaaEndUserConfig": "#/components/schemas/MaaEndUserConfig",
+ "MaaUserConfig": "#/components/schemas/MaaUserConfig",
+ "SrcUserConfig": "#/components/schemas/SrcUserConfig"
+ }
+ }
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "title": "UserDetailOut",
+ "description": "用户详情响应模型"
+ },
+ "UserGetOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "index": {
+ "items": {
+ "$ref": "#/components/schemas/UserIndexItem"
+ },
+ "type": "array",
+ "title": "Index",
+ "description": "资源索引列表"
+ },
+ "data": {
+ "additionalProperties": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/MaaUserConfig"
+ },
+ {
+ "$ref": "#/components/schemas/SrcUserConfig"
+ },
+ {
+ "$ref": "#/components/schemas/GeneralUserConfig"
+ },
+ {
+ "$ref": "#/components/schemas/MaaEndUserConfig"
+ }
+ ],
+ "discriminator": {
+ "propertyName": "type",
+ "mapping": {
+ "GeneralUserConfig": "#/components/schemas/GeneralUserConfig",
+ "MaaEndUserConfig": "#/components/schemas/MaaEndUserConfig",
+ "MaaUserConfig": "#/components/schemas/MaaUserConfig",
+ "SrcUserConfig": "#/components/schemas/SrcUserConfig"
+ }
+ }
+ },
+ "type": "object",
+ "title": "Data",
+ "description": "资源数据字典"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "index",
+ "data"
+ ],
+ "title": "UserGetOut",
+ "description": "用户列表响应模型"
+ },
+ "UserIndexItem": {
+ "properties": {
+ "uid": {
+ "type": "string",
+ "title": "Uid",
+ "description": "唯一标识符"
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "MaaUserConfig",
+ "GeneralUserConfig",
+ "SrcUserConfig",
+ "MaaEndUserConfig"
+ ],
+ "title": "Type",
+ "description": "配置类型"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "uid",
+ "type"
+ ],
+ "title": "UserIndexItem"
+ },
+ "UserPatchBody": {
+ "properties": {
+ "data": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/MaaUserConfig"
+ },
+ {
+ "$ref": "#/components/schemas/SrcUserConfig"
+ },
+ {
+ "$ref": "#/components/schemas/GeneralUserConfig"
+ },
+ {
+ "$ref": "#/components/schemas/MaaEndUserConfig"
+ }
+ ],
+ "title": "Data",
+ "description": "用户 Patch 数据",
+ "discriminator": {
+ "propertyName": "type",
+ "mapping": {
+ "GeneralUserConfig": "#/components/schemas/GeneralUserConfig",
+ "MaaEndUserConfig": "#/components/schemas/MaaEndUserConfig",
+ "MaaUserConfig": "#/components/schemas/MaaUserConfig",
+ "SrcUserConfig": "#/components/schemas/SrcUserConfig"
+ }
+ }
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "title": "UserPatchBody"
+ },
+ "ValidationError": {
+ "properties": {
+ "loc": {
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "integer"
+ }
+ ]
+ },
+ "type": "array",
+ "title": "Location"
+ },
+ "msg": {
+ "type": "string",
+ "title": "Message"
+ },
+ "type": {
+ "type": "string",
+ "title": "Error Type"
+ }
+ },
+ "type": "object",
+ "required": [
+ "loc",
+ "msg",
+ "type"
+ ],
+ "title": "ValidationError"
+ },
+ "VersionOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "if_need_update": {
+ "type": "boolean",
+ "title": "If Need Update",
+ "description": "后端代码是否需要更新"
+ },
+ "current_time": {
+ "type": "string",
+ "title": "Current Time",
+ "description": "后端代码当前时间戳"
+ },
+ "current_hash": {
+ "type": "string",
+ "title": "Current Hash",
+ "description": "后端代码当前哈希值"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "if_need_update",
+ "current_time",
+ "current_hash"
+ ],
+ "title": "VersionOut"
+ },
+ "WSClearHistoryIn": {
+ "properties": {
+ "name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Name",
+ "description": "客户端名称,为空则清空所有"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "WSClearHistoryIn",
+ "description": "清空消息历史请求"
+ },
+ "WSClientAuthIn": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name",
+ "description": "客户端名称"
+ },
+ "token": {
+ "type": "string",
+ "title": "Token",
+ "description": "认证 Token"
+ },
+ "auth_type": {
+ "type": "string",
+ "title": "Auth Type",
+ "description": "认证消息类型",
+ "default": "auth"
+ },
+ "extra_data": {
+ "anyOf": [
+ {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Extra Data",
+ "description": "额外认证数据"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "name",
+ "token"
+ ],
+ "title": "WSClientAuthIn",
+ "description": "发送认证请求"
+ },
+ "WSClientConnectIn": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name",
+ "description": "客户端名称"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "title": "WSClientConnectIn",
+ "description": "连接请求"
+ },
+ "WSClientCreateIn": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name",
+ "description": "客户端名称,用于标识"
+ },
+ "url": {
+ "type": "string",
+ "title": "Url",
+ "description": "WebSocket 服务器地址,如 ws://localhost:5140/path"
+ },
+ "ping_interval": {
+ "type": "number",
+ "title": "Ping Interval",
+ "description": "心跳发送间隔(秒)",
+ "default": 15.0
+ },
+ "ping_timeout": {
+ "type": "number",
+ "title": "Ping Timeout",
+ "description": "心跳超时时间(秒)",
+ "default": 30.0
+ },
+ "reconnect_interval": {
+ "type": "number",
+ "title": "Reconnect Interval",
+ "description": "重连间隔(秒)",
+ "default": 5.0
+ },
+ "max_reconnect_attempts": {
+ "type": "integer",
+ "title": "Max Reconnect Attempts",
+ "description": "最大重连次数,-1为无限",
+ "default": -1
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "name",
+ "url"
+ ],
+ "title": "WSClientCreateIn",
+ "description": "创建 WebSocket 客户端请求"
+ },
+ "WSClientCreateOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "anyOf": [
+ {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Data",
+ "description": "返回数据"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "WSClientCreateOut",
+ "description": "创建客户端响应"
+ },
+ "WSClientDisconnectIn": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name",
+ "description": "客户端名称"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "title": "WSClientDisconnectIn",
+ "description": "断开连接请求"
+ },
+ "WSClientListOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "anyOf": [
+ {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Data",
+ "description": "客户端列表"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "WSClientListOut",
+ "description": "客户端列表响应"
+ },
+ "WSClientRemoveIn": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name",
+ "description": "客户端名称"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "title": "WSClientRemoveIn",
+ "description": "删除客户端请求"
+ },
+ "WSClientSendIn": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name",
+ "description": "客户端名称"
+ },
+ "message": {
+ "additionalProperties": true,
+ "type": "object",
+ "title": "Message",
+ "description": "要发送的 JSON 消息"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "name",
+ "message"
+ ],
+ "title": "WSClientSendIn",
+ "description": "发送消息请求"
+ },
+ "WSClientSendJsonIn": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name",
+ "description": "客户端名称"
+ },
+ "msg_id": {
+ "type": "string",
+ "title": "Msg Id",
+ "description": "消息 ID",
+ "default": "Client"
+ },
+ "msg_type": {
+ "type": "string",
+ "title": "Msg Type",
+ "description": "消息类型"
+ },
+ "data": {
+ "additionalProperties": true,
+ "type": "object",
+ "title": "Data",
+ "description": "消息数据"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "name",
+ "msg_type"
+ ],
+ "title": "WSClientSendJsonIn",
+ "description": "发送自定义 JSON 消息请求"
+ },
+ "WSClientStatusIn": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name",
+ "description": "客户端名称"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "title": "WSClientStatusIn",
+ "description": "获取客户端状态请求"
+ },
+ "WSClientStatusOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "anyOf": [
+ {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Data",
+ "description": "状态数据"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "WSClientStatusOut",
+ "description": "客户端状态响应"
+ },
+ "WSCommandsOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "anyOf": [
+ {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Data",
+ "description": "命令列表"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "WSCommandsOut",
+ "description": "可用命令列表响应"
+ },
+ "WSMessageHistoryOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "anyOf": [
+ {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Data",
+ "description": "消息历史"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "WSMessageHistoryOut",
+ "description": "消息历史响应"
+ },
+ "WebhookCreateOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "$ref": "#/components/schemas/WebhookRead",
+ "description": "资源数据"
+ },
+ "id": {
+ "type": "string",
+ "title": "Id",
+ "description": "新创建资源的唯一 ID"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data",
+ "id"
+ ],
+ "title": "WebhookCreateOut",
+ "description": "Webhook 创建响应模型"
+ },
+ "WebhookDetailOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "data": {
+ "$ref": "#/components/schemas/WebhookRead",
+ "description": "资源数据"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "title": "WebhookDetailOut",
+ "description": "Webhook 详情响应模型"
+ },
+ "WebhookGetOut": {
+ "properties": {
+ "code": {
+ "type": "integer",
+ "title": "Code",
+ "description": "状态码",
+ "default": 200
+ },
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "操作状态",
+ "default": "success"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "操作消息",
+ "default": "操作成功"
+ },
+ "index": {
+ "items": {
+ "$ref": "#/components/schemas/WebhookIndexItem"
+ },
+ "type": "array",
+ "title": "Index",
+ "description": "资源索引列表"
+ },
+ "data": {
+ "additionalProperties": {
+ "$ref": "#/components/schemas/WebhookRead"
+ },
+ "type": "object",
+ "title": "Data",
+ "description": "资源数据字典"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "index",
+ "data"
+ ],
+ "title": "WebhookGetOut",
+ "description": "Webhook 列表响应模型"
+ },
+ "WebhookIndexItem": {
+ "properties": {
+ "uid": {
+ "type": "string",
+ "title": "Uid",
+ "description": "唯一标识符"
+ },
+ "type": {
+ "type": "string",
+ "const": "Webhook",
+ "title": "Type",
+ "description": "配置类型"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "uid",
+ "type"
+ ],
+ "title": "WebhookIndexItem"
+ },
+ "WebhookRead": {
+ "properties": {
+ "Info": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/WebhookReadInfo"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "Data": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/WebhookReadData"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "WebhookRead",
+ "description": "Webhook 配置读取/写入模型。"
+ },
+ "WebhookReadData": {
+ "properties": {
+ "Url": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Url"
+ },
+ "Template": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Template"
+ },
+ "Headers": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Headers"
+ },
+ "Method": {
+ "anyOf": [
+ {
+ "type": "string",
+ "enum": [
+ "POST",
+ "GET"
+ ]
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Method"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "WebhookReadData"
+ },
+ "WebhookReadInfo": {
+ "properties": {
+ "Name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Name"
+ },
+ "Enabled": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Enabled"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "title": "WebhookReadInfo"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend/package.json b/frontend/package.json
index 73ebb46f..7828164e 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -8,7 +8,7 @@
"scripts": {
"dev": "concurrently \"vite\" \"yarn watch:main\" \"yarn electron-dev\"",
"dev:fullstack": "concurrently \"yarn backend\" \"vite\" \"yarn watch:main\" \"yarn electron-dev:wait\"",
- "backend": "python ../main.py",
+ "backend": "cross-env AUTO_MAS_DEV=1 powershell -NoProfile -Command \"Set-Location ..; & './.venv/Scripts/python.exe' main.py\"",
"electron-dev:wait": "wait-on http://localhost:5173 http://localhost:36163 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 electron .",
"watch:main": "tsc -p tsconfig.electron.json --watch",
"electron-dev": "wait-on http://localhost:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 electron .",
@@ -21,7 +21,7 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier . --write",
- "openapi": "openapi --input http://127.0.0.1:36163/openapi.json --output ./src/api --client axios"
+ "openapi": "openapi-ts"
},
"build": {
"asar": true,
@@ -81,6 +81,7 @@
},
"devDependencies": {
"@eslint/js": "^9.37.0",
+ "@hey-api/openapi-ts": "^0.86.0",
"@types/matter-js": "^0",
"@types/node": "22.17.1",
"@typescript-eslint/eslint-plugin": "^8.38.0",
@@ -96,7 +97,6 @@
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-vue": "^10.4.0",
"globals": "^16.4.0",
- "openapi-typescript-codegen": "^0.29.0",
"prettier": "^3.6.2",
"typescript": "^5.9.2",
"typescript-eslint": "^8.45.0",
diff --git a/frontend/src/api/constants.ts b/frontend/src/api/constants.ts
new file mode 100644
index 00000000..42d97ace
--- /dev/null
+++ b/frontend/src/api/constants.ts
@@ -0,0 +1,55 @@
+import type {
+ GetStageIn,
+ HistorySearchIn,
+ PowerIn,
+ ScriptCreateIn,
+ TaskCreateIn,
+} from './generated/types.gen'
+
+export type StageQueryType = GetStageIn['type']
+export type HistorySearchMode = HistorySearchIn['mode']
+export type ScriptCreateType = ScriptCreateIn['type']
+export type TaskCreateMode = TaskCreateIn['mode']
+export type PowerSignal = PowerIn['signal']
+
+export const STAGE_QUERY_TYPE = {
+ USER: 'User',
+ TODAY: 'Today',
+ ALL: 'ALL',
+ MONDAY: 'Monday',
+ TUESDAY: 'Tuesday',
+ WEDNESDAY: 'Wednesday',
+ THURSDAY: 'Thursday',
+ FRIDAY: 'Friday',
+ SATURDAY: 'Saturday',
+ SUNDAY: 'Sunday',
+} as const satisfies Record
+
+export const HISTORY_SEARCH_MODE = {
+ DAILY: 'DAILY',
+ WEEKLY: 'WEEKLY',
+ MONTHLY: 'MONTHLY',
+} as const satisfies Record
+
+export const SCRIPT_CREATE_TYPE = {
+ MAA: 'MAA',
+ SRC: 'SRC',
+ GENERAL: 'General',
+ MAA_END: 'MaaEnd',
+} as const satisfies Record
+
+export const TASK_CREATE_MODE = {
+ AUTO_PROXY: 'AutoProxy',
+ MANUAL_REVIEW: 'ManualReview',
+ SCRIPT_CONFIG: 'ScriptConfig',
+} as const satisfies Record
+
+export const POWER_SIGNAL = {
+ NO_ACTION: 'NoAction',
+ SHUTDOWN: 'Shutdown',
+ SHUTDOWN_FORCE: 'ShutdownForce',
+ REBOOT: 'Reboot',
+ HIBERNATE: 'Hibernate',
+ SLEEP: 'Sleep',
+ KILL_SELF: 'KillSelf',
+} as const satisfies Record
diff --git a/frontend/src/api/core/ApiError.ts b/frontend/src/api/core/ApiError.ts
deleted file mode 100644
index ec7b16af..00000000
--- a/frontend/src/api/core/ApiError.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-import type { ApiRequestOptions } from './ApiRequestOptions';
-import type { ApiResult } from './ApiResult';
-
-export class ApiError extends Error {
- public readonly url: string;
- public readonly status: number;
- public readonly statusText: string;
- public readonly body: any;
- public readonly request: ApiRequestOptions;
-
- constructor(request: ApiRequestOptions, response: ApiResult, message: string) {
- super(message);
-
- this.name = 'ApiError';
- this.url = response.url;
- this.status = response.status;
- this.statusText = response.statusText;
- this.body = response.body;
- this.request = request;
- }
-}
diff --git a/frontend/src/api/core/ApiRequestOptions.ts b/frontend/src/api/core/ApiRequestOptions.ts
deleted file mode 100644
index 93143c3c..00000000
--- a/frontend/src/api/core/ApiRequestOptions.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export type ApiRequestOptions = {
- readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH';
- readonly url: string;
- readonly path?: Record;
- readonly cookies?: Record;
- readonly headers?: Record;
- readonly query?: Record;
- readonly formData?: Record;
- readonly body?: any;
- readonly mediaType?: string;
- readonly responseHeader?: string;
- readonly errors?: Record;
-};
diff --git a/frontend/src/api/core/ApiResult.ts b/frontend/src/api/core/ApiResult.ts
deleted file mode 100644
index ee1126e2..00000000
--- a/frontend/src/api/core/ApiResult.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export type ApiResult = {
- readonly url: string;
- readonly ok: boolean;
- readonly status: number;
- readonly statusText: string;
- readonly body: any;
-};
diff --git a/frontend/src/api/core/CancelablePromise.ts b/frontend/src/api/core/CancelablePromise.ts
deleted file mode 100644
index d70de929..00000000
--- a/frontend/src/api/core/CancelablePromise.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export class CancelError extends Error {
-
- constructor(message: string) {
- super(message);
- this.name = 'CancelError';
- }
-
- public get isCancelled(): boolean {
- return true;
- }
-}
-
-export interface OnCancel {
- readonly isResolved: boolean;
- readonly isRejected: boolean;
- readonly isCancelled: boolean;
-
- (cancelHandler: () => void): void;
-}
-
-export class CancelablePromise implements Promise {
- #isResolved: boolean;
- #isRejected: boolean;
- #isCancelled: boolean;
- readonly #cancelHandlers: (() => void)[];
- readonly #promise: Promise;
- #resolve?: (value: T | PromiseLike) => void;
- #reject?: (reason?: any) => void;
-
- constructor(
- executor: (
- resolve: (value: T | PromiseLike) => void,
- reject: (reason?: any) => void,
- onCancel: OnCancel
- ) => void
- ) {
- this.#isResolved = false;
- this.#isRejected = false;
- this.#isCancelled = false;
- this.#cancelHandlers = [];
- this.#promise = new Promise((resolve, reject) => {
- this.#resolve = resolve;
- this.#reject = reject;
-
- const onResolve = (value: T | PromiseLike): void => {
- if (this.#isResolved || this.#isRejected || this.#isCancelled) {
- return;
- }
- this.#isResolved = true;
- if (this.#resolve) this.#resolve(value);
- };
-
- const onReject = (reason?: any): void => {
- if (this.#isResolved || this.#isRejected || this.#isCancelled) {
- return;
- }
- this.#isRejected = true;
- if (this.#reject) this.#reject(reason);
- };
-
- const onCancel = (cancelHandler: () => void): void => {
- if (this.#isResolved || this.#isRejected || this.#isCancelled) {
- return;
- }
- this.#cancelHandlers.push(cancelHandler);
- };
-
- Object.defineProperty(onCancel, 'isResolved', {
- get: (): boolean => this.#isResolved,
- });
-
- Object.defineProperty(onCancel, 'isRejected', {
- get: (): boolean => this.#isRejected,
- });
-
- Object.defineProperty(onCancel, 'isCancelled', {
- get: (): boolean => this.#isCancelled,
- });
-
- return executor(onResolve, onReject, onCancel as OnCancel);
- });
- }
-
- get [Symbol.toStringTag]() {
- return "Cancellable Promise";
- }
-
- public then(
- onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null,
- onRejected?: ((reason: any) => TResult2 | PromiseLike) | null
- ): Promise {
- return this.#promise.then(onFulfilled, onRejected);
- }
-
- public catch(
- onRejected?: ((reason: any) => TResult | PromiseLike) | null
- ): Promise {
- return this.#promise.catch(onRejected);
- }
-
- public finally(onFinally?: (() => void) | null): Promise {
- return this.#promise.finally(onFinally);
- }
-
- public cancel(): void {
- if (this.#isResolved || this.#isRejected || this.#isCancelled) {
- return;
- }
- this.#isCancelled = true;
- if (this.#cancelHandlers.length) {
- try {
- for (const cancelHandler of this.#cancelHandlers) {
- cancelHandler();
- }
- } catch (error) {
- console.warn('Cancellation threw an error', error);
- return;
- }
- }
- this.#cancelHandlers.length = 0;
- if (this.#reject) this.#reject(new CancelError('Request aborted'));
- }
-
- public get isCancelled(): boolean {
- return this.#isCancelled;
- }
-}
diff --git a/frontend/src/api/core/OpenAPI.ts b/frontend/src/api/core/OpenAPI.ts
deleted file mode 100644
index a0a9ed4a..00000000
--- a/frontend/src/api/core/OpenAPI.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-import type { ApiRequestOptions } from './ApiRequestOptions';
-
-type Resolver = (options: ApiRequestOptions) => Promise;
-type Headers = Record;
-
-export type OpenAPIConfig = {
- BASE: string;
- VERSION: string;
- WITH_CREDENTIALS: boolean;
- CREDENTIALS: 'include' | 'omit' | 'same-origin';
- TOKEN?: string | Resolver | undefined;
- USERNAME?: string | Resolver | undefined;
- PASSWORD?: string | Resolver | undefined;
- HEADERS?: Headers | Resolver | undefined;
- ENCODE_PATH?: ((path: string) => string) | undefined;
-};
-
-export const OpenAPI: OpenAPIConfig = {
- BASE: '',
- VERSION: '1.0.0',
- WITH_CREDENTIALS: false,
- CREDENTIALS: 'include',
- TOKEN: undefined,
- USERNAME: undefined,
- PASSWORD: undefined,
- HEADERS: undefined,
- ENCODE_PATH: undefined,
-};
diff --git a/frontend/src/api/core/request.ts b/frontend/src/api/core/request.ts
deleted file mode 100644
index 1dc6fef4..00000000
--- a/frontend/src/api/core/request.ts
+++ /dev/null
@@ -1,323 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-import axios from 'axios';
-import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios';
-import FormData from 'form-data';
-
-import { ApiError } from './ApiError';
-import type { ApiRequestOptions } from './ApiRequestOptions';
-import type { ApiResult } from './ApiResult';
-import { CancelablePromise } from './CancelablePromise';
-import type { OnCancel } from './CancelablePromise';
-import type { OpenAPIConfig } from './OpenAPI';
-
-export const isDefined = (value: T | null | undefined): value is Exclude => {
- return value !== undefined && value !== null;
-};
-
-export const isString = (value: any): value is string => {
- return typeof value === 'string';
-};
-
-export const isStringWithValue = (value: any): value is string => {
- return isString(value) && value !== '';
-};
-
-export const isBlob = (value: any): value is Blob => {
- return (
- typeof value === 'object' &&
- typeof value.type === 'string' &&
- typeof value.stream === 'function' &&
- typeof value.arrayBuffer === 'function' &&
- typeof value.constructor === 'function' &&
- typeof value.constructor.name === 'string' &&
- /^(Blob|File)$/.test(value.constructor.name) &&
- /^(Blob|File)$/.test(value[Symbol.toStringTag])
- );
-};
-
-export const isFormData = (value: any): value is FormData => {
- return value instanceof FormData;
-};
-
-export const isSuccess = (status: number): boolean => {
- return status >= 200 && status < 300;
-};
-
-export const base64 = (str: string): string => {
- try {
- return btoa(str);
- } catch (err) {
- // @ts-ignore
- return Buffer.from(str).toString('base64');
- }
-};
-
-export const getQueryString = (params: Record): string => {
- const qs: string[] = [];
-
- const append = (key: string, value: any) => {
- qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
- };
-
- const process = (key: string, value: any) => {
- if (isDefined(value)) {
- if (Array.isArray(value)) {
- value.forEach(v => {
- process(key, v);
- });
- } else if (typeof value === 'object') {
- Object.entries(value).forEach(([k, v]) => {
- process(`${key}[${k}]`, v);
- });
- } else {
- append(key, value);
- }
- }
- };
-
- Object.entries(params).forEach(([key, value]) => {
- process(key, value);
- });
-
- if (qs.length > 0) {
- return `?${qs.join('&')}`;
- }
-
- return '';
-};
-
-const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => {
- const encoder = config.ENCODE_PATH || encodeURI;
-
- const path = options.url
- .replace('{api-version}', config.VERSION)
- .replace(/{(.*?)}/g, (substring: string, group: string) => {
- if (options.path?.hasOwnProperty(group)) {
- return encoder(String(options.path[group]));
- }
- return substring;
- });
-
- const url = `${config.BASE}${path}`;
- if (options.query) {
- return `${url}${getQueryString(options.query)}`;
- }
- return url;
-};
-
-export const getFormData = (options: ApiRequestOptions): FormData | undefined => {
- if (options.formData) {
- const formData = new FormData();
-
- const process = (key: string, value: any) => {
- if (isString(value) || isBlob(value)) {
- formData.append(key, value);
- } else {
- formData.append(key, JSON.stringify(value));
- }
- };
-
- Object.entries(options.formData)
- .filter(([_, value]) => isDefined(value))
- .forEach(([key, value]) => {
- if (Array.isArray(value)) {
- value.forEach(v => process(key, v));
- } else {
- process(key, value);
- }
- });
-
- return formData;
- }
- return undefined;
-};
-
-type Resolver = (options: ApiRequestOptions) => Promise;
-
-export const resolve = async (options: ApiRequestOptions, resolver?: T | Resolver): Promise => {
- if (typeof resolver === 'function') {
- return (resolver as Resolver)(options);
- }
- return resolver;
-};
-
-export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise> => {
- const [token, username, password, additionalHeaders] = await Promise.all([
- resolve(options, config.TOKEN),
- resolve(options, config.USERNAME),
- resolve(options, config.PASSWORD),
- resolve(options, config.HEADERS),
- ]);
-
- const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {}
-
- const headers = Object.entries({
- Accept: 'application/json',
- ...additionalHeaders,
- ...options.headers,
- ...formHeaders,
- })
- .filter(([_, value]) => isDefined(value))
- .reduce((headers, [key, value]) => ({
- ...headers,
- [key]: String(value),
- }), {} as Record);
-
- if (isStringWithValue(token)) {
- headers['Authorization'] = `Bearer ${token}`;
- }
-
- if (isStringWithValue(username) && isStringWithValue(password)) {
- const credentials = base64(`${username}:${password}`);
- headers['Authorization'] = `Basic ${credentials}`;
- }
-
- if (options.body !== undefined) {
- if (options.mediaType) {
- headers['Content-Type'] = options.mediaType;
- } else if (isBlob(options.body)) {
- headers['Content-Type'] = options.body.type || 'application/octet-stream';
- } else if (isString(options.body)) {
- headers['Content-Type'] = 'text/plain';
- } else if (!isFormData(options.body)) {
- headers['Content-Type'] = 'application/json';
- }
- }
-
- return headers;
-};
-
-export const getRequestBody = (options: ApiRequestOptions): any => {
- if (options.body) {
- return options.body;
- }
- return undefined;
-};
-
-export const sendRequest = async (
- config: OpenAPIConfig,
- options: ApiRequestOptions,
- url: string,
- body: any,
- formData: FormData | undefined,
- headers: Record,
- onCancel: OnCancel,
- axiosClient: AxiosInstance
-): Promise> => {
- const source = axios.CancelToken.source();
-
- const requestConfig: AxiosRequestConfig = {
- url,
- headers,
- data: body ?? formData,
- method: options.method,
- withCredentials: config.WITH_CREDENTIALS,
- withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false,
- cancelToken: source.token,
- };
-
- onCancel(() => source.cancel('The user aborted a request.'));
-
- try {
- return await axiosClient.request(requestConfig);
- } catch (error) {
- const axiosError = error as AxiosError;
- if (axiosError.response) {
- return axiosError.response;
- }
- throw error;
- }
-};
-
-export const getResponseHeader = (response: AxiosResponse, responseHeader?: string): string | undefined => {
- if (responseHeader) {
- const content = response.headers[responseHeader];
- if (isString(content)) {
- return content;
- }
- }
- return undefined;
-};
-
-export const getResponseBody = (response: AxiosResponse): any => {
- if (response.status !== 204) {
- return response.data;
- }
- return undefined;
-};
-
-export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => {
- const errors: Record = {
- 400: 'Bad Request',
- 401: 'Unauthorized',
- 403: 'Forbidden',
- 404: 'Not Found',
- 500: 'Internal Server Error',
- 502: 'Bad Gateway',
- 503: 'Service Unavailable',
- ...options.errors,
- }
-
- const error = errors[result.status];
- if (error) {
- throw new ApiError(options, result, error);
- }
-
- if (!result.ok) {
- const errorStatus = result.status ?? 'unknown';
- const errorStatusText = result.statusText ?? 'unknown';
- const errorBody = (() => {
- try {
- return JSON.stringify(result.body, null, 2);
- } catch (e) {
- return undefined;
- }
- })();
-
- throw new ApiError(options, result,
- `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`
- );
- }
-};
-
-/**
- * Request method
- * @param config The OpenAPI configuration object
- * @param options The request options from the service
- * @param axiosClient The axios client instance to use
- * @returns CancelablePromise
- * @throws ApiError
- */
-export const request = (config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise => {
- return new CancelablePromise(async (resolve, reject, onCancel) => {
- try {
- const url = getUrl(config, options);
- const formData = getFormData(options);
- const body = getRequestBody(options);
- const headers = await getHeaders(config, options, formData);
-
- if (!onCancel.isCancelled) {
- const response = await sendRequest(config, options, url, body, formData, headers, onCancel, axiosClient);
- const responseBody = getResponseBody(response);
- const responseHeader = getResponseHeader(response, options.responseHeader);
-
- const result: ApiResult = {
- url,
- ok: isSuccess(response.status),
- status: response.status,
- statusText: response.statusText,
- body: responseHeader ?? responseBody,
- };
-
- catchErrorCodes(options, result);
-
- resolve(result.body);
- }
- } catch (error) {
- reject(error);
- }
- });
-};
diff --git a/frontend/src/api/gateways/dispatch.ts b/frontend/src/api/gateways/dispatch.ts
new file mode 100644
index 00000000..6e646991
--- /dev/null
+++ b/frontend/src/api/gateways/dispatch.ts
@@ -0,0 +1,30 @@
+import {
+ addTaskApiDispatchStartPost,
+ cancelPowerTaskApiDispatchCancelPowerPost,
+ getPowerApiDispatchGetPowerPost,
+ setPowerApiDispatchSetPowerPost,
+ stopTaskApiDispatchStopPost,
+} from '../generated/sdk.gen'
+import type { DispatchIn, PowerIn, TaskCreateIn } from '../generated/types.gen'
+
+export const dispatchApi = {
+ startTask(payload: TaskCreateIn) {
+ return addTaskApiDispatchStartPost({ body: payload })
+ },
+
+ stopTask(payload: DispatchIn) {
+ return stopTaskApiDispatchStopPost({ body: payload })
+ },
+
+ getPower() {
+ return getPowerApiDispatchGetPowerPost()
+ },
+
+ setPower(payload: PowerIn) {
+ return setPowerApiDispatchSetPowerPost({ body: payload })
+ },
+
+ cancelPowerTask() {
+ return cancelPowerTaskApiDispatchCancelPowerPost()
+ },
+}
diff --git a/frontend/src/api/gateways/emulator.ts b/frontend/src/api/gateways/emulator.ts
new file mode 100644
index 00000000..c749348d
--- /dev/null
+++ b/frontend/src/api/gateways/emulator.ts
@@ -0,0 +1,62 @@
+import {
+ createEmulatorApiEmulatorPost,
+ deleteEmulatorApiEmulatorEmulatorIdDelete,
+ detectEmulatorsApiEmulatorDetectedGet,
+ getEmulatorApiEmulatorEmulatorIdGet,
+ getEmulatorStatusApiEmulatorEmulatorIdStatusGet,
+ getEmulatorStatusesApiEmulatorStatusGet,
+ listEmulatorsApiEmulatorGet,
+ operateEmulatorApiEmulatorEmulatorIdActionsActionPost,
+ updateEmulatorApiEmulatorEmulatorIdPatch,
+} from '../generated/sdk.gen'
+import type { EmulatorActionBody, EmulatorRead } from '../generated/types.gen'
+
+export const emulatorApi = {
+ list() {
+ return listEmulatorsApiEmulatorGet()
+ },
+
+ create() {
+ return createEmulatorApiEmulatorPost()
+ },
+
+ get(emulatorId: string) {
+ return getEmulatorApiEmulatorEmulatorIdGet({
+ path: { emulator_id: emulatorId },
+ })
+ },
+
+ update(emulatorId: string, payload: EmulatorRead) {
+ return updateEmulatorApiEmulatorEmulatorIdPatch({
+ path: { emulator_id: emulatorId },
+ body: payload,
+ })
+ },
+
+ remove(emulatorId: string) {
+ return deleteEmulatorApiEmulatorEmulatorIdDelete({
+ path: { emulator_id: emulatorId },
+ })
+ },
+
+ listDetected() {
+ return detectEmulatorsApiEmulatorDetectedGet()
+ },
+
+ getAllStatuses() {
+ return getEmulatorStatusesApiEmulatorStatusGet()
+ },
+
+ getStatus(emulatorId: string) {
+ return getEmulatorStatusApiEmulatorEmulatorIdStatusGet({
+ path: { emulator_id: emulatorId },
+ })
+ },
+
+ operate(emulatorId: string, action: 'open' | 'close' | 'show', payload: EmulatorActionBody) {
+ return operateEmulatorApiEmulatorEmulatorIdActionsActionPost({
+ path: { emulator_id: emulatorId, action },
+ body: payload,
+ })
+ },
+}
diff --git a/frontend/src/api/gateways/history.ts b/frontend/src/api/gateways/history.ts
new file mode 100644
index 00000000..d164ebda
--- /dev/null
+++ b/frontend/src/api/gateways/history.ts
@@ -0,0 +1,15 @@
+import {
+ getHistoryDataApiHistoryDataPost,
+ searchHistoryApiHistorySearchPost,
+} from '../generated/sdk.gen'
+import type { HistoryDataGetIn, HistorySearchIn } from '../generated/types.gen'
+
+export const historyApi = {
+ search(payload: HistorySearchIn) {
+ return searchHistoryApiHistorySearchPost({ body: payload })
+ },
+
+ getData(payload: HistoryDataGetIn) {
+ return getHistoryDataApiHistoryDataPost({ body: payload })
+ },
+}
diff --git a/frontend/src/api/gateways/info.ts b/frontend/src/api/gateways/info.ts
new file mode 100644
index 00000000..955f980a
--- /dev/null
+++ b/frontend/src/api/gateways/info.ts
@@ -0,0 +1,62 @@
+import {
+ confirmNoticeApiInfoNoticeConfirmPost,
+ getEmulatorComboxApiInfoComboxEmulatorPost,
+ getEmulatorDevicesComboxApiInfoComboxEmulatorDevicesPost,
+ getGitVersionApiInfoVersionPost,
+ getNoticeInfoApiInfoNoticeGetPost,
+ getOverviewApiInfoGetOverviewPost,
+ getPlanComboxApiInfoComboxPlanPost,
+ getScriptComboxApiInfoComboxScriptPost,
+ getStageComboxApiInfoComboxStagePost,
+ getTaskComboxApiInfoComboxTaskPost,
+ getWebConfigApiInfoWebconfigPost,
+} from '../generated/sdk.gen'
+import type { EmulatorIdBody, GetStageIn } from '../generated/types.gen'
+
+export const infoApi = {
+ getVersion() {
+ return getGitVersionApiInfoVersionPost()
+ },
+
+ getStageOptions(payload: GetStageIn) {
+ return getStageComboxApiInfoComboxStagePost({ body: payload })
+ },
+
+ getScriptOptions() {
+ return getScriptComboxApiInfoComboxScriptPost()
+ },
+
+ getTaskOptions() {
+ return getTaskComboxApiInfoComboxTaskPost()
+ },
+
+ getPlanOptions() {
+ return getPlanComboxApiInfoComboxPlanPost()
+ },
+
+ getEmulatorOptions() {
+ return getEmulatorComboxApiInfoComboxEmulatorPost()
+ },
+
+ getEmulatorDeviceOptions(payload: EmulatorIdBody) {
+ return getEmulatorDevicesComboxApiInfoComboxEmulatorDevicesPost({
+ body: payload,
+ })
+ },
+
+ getNotice() {
+ return getNoticeInfoApiInfoNoticeGetPost()
+ },
+
+ confirmNotice() {
+ return confirmNoticeApiInfoNoticeConfirmPost()
+ },
+
+ getWebConfig() {
+ return getWebConfigApiInfoWebconfigPost()
+ },
+
+ getOverview() {
+ return getOverviewApiInfoGetOverviewPost()
+ },
+}
diff --git a/frontend/src/api/gateways/ocr.ts b/frontend/src/api/gateways/ocr.ts
new file mode 100644
index 00000000..51bb68e0
--- /dev/null
+++ b/frontend/src/api/gateways/ocr.ts
@@ -0,0 +1,48 @@
+import {
+ checkImageAllApiOcrCheckImageAllPost,
+ checkImageAnyApiOcrCheckImageAnyPost,
+ checkImageApiOcrCheckImagePost,
+ clickImageApiOcrClickImagePost,
+ clickTextApiOcrClickTextPost,
+ getScreenshotAdbApiOcrScreenshotAdbPost,
+ getScreenshotApiOcrScreenshotPost,
+} from '../generated/sdk.gen'
+import type {
+ AdbScreenshotIn,
+ CheckImageAllIn,
+ CheckImageAnyIn,
+ CheckImageIn,
+ ClickImageIn,
+ ClickTextIn,
+ OcrScreenshotIn,
+} from '../generated/types.gen'
+
+export const ocrApi = {
+ getWindowScreenshot(payload: OcrScreenshotIn) {
+ return getScreenshotApiOcrScreenshotPost({ body: payload })
+ },
+
+ getAdbScreenshot(payload: AdbScreenshotIn) {
+ return getScreenshotAdbApiOcrScreenshotAdbPost({ body: payload })
+ },
+
+ checkImage(payload: CheckImageIn) {
+ return checkImageApiOcrCheckImagePost({ body: payload })
+ },
+
+ checkAnyImage(payload: CheckImageAnyIn) {
+ return checkImageAnyApiOcrCheckImageAnyPost({ body: payload })
+ },
+
+ checkAllImages(payload: CheckImageAllIn) {
+ return checkImageAllApiOcrCheckImageAllPost({ body: payload })
+ },
+
+ clickImage(payload: ClickImageIn) {
+ return clickImageApiOcrClickImagePost({ body: payload })
+ },
+
+ clickText(payload: ClickTextIn) {
+ return clickTextApiOcrClickTextPost({ body: payload })
+ },
+}
diff --git a/frontend/src/api/gateways/plan.ts b/frontend/src/api/gateways/plan.ts
new file mode 100644
index 00000000..6c509fd1
--- /dev/null
+++ b/frontend/src/api/gateways/plan.ts
@@ -0,0 +1,38 @@
+import {
+ createPlanApiPlanPost,
+ deletePlanApiPlanPlanIdDelete,
+ getPlanApiPlanPlanIdGet,
+ listPlansApiPlanGet,
+ reorderPlanApiPlanOrderPatch,
+ updatePlanApiPlanPlanIdPatch,
+} from '../generated/sdk.gen'
+import type { IndexOrderPatch, PlanCreateIn, PlanUpdateBody } from '../generated/types.gen'
+
+export const planApi = {
+ list() {
+ return listPlansApiPlanGet()
+ },
+
+ create(payload: PlanCreateIn) {
+ return createPlanApiPlanPost({ body: payload })
+ },
+
+ get(planId: string) {
+ return getPlanApiPlanPlanIdGet({ path: { plan_id: planId } })
+ },
+
+ update(planId: string, payload: PlanUpdateBody) {
+ return updatePlanApiPlanPlanIdPatch({
+ path: { plan_id: planId },
+ body: payload,
+ })
+ },
+
+ remove(planId: string) {
+ return deletePlanApiPlanPlanIdDelete({ path: { plan_id: planId } })
+ },
+
+ reorder(payload: IndexOrderPatch) {
+ return reorderPlanApiPlanOrderPatch({ body: payload })
+ },
+}
diff --git a/frontend/src/api/gateways/plugin.ts b/frontend/src/api/gateways/plugin.ts
new file mode 100644
index 00000000..5ef0ab11
--- /dev/null
+++ b/frontend/src/api/gateways/plugin.ts
@@ -0,0 +1,85 @@
+import {
+ addPluginInstanceApiPluginsAddPost,
+ deletePluginInstanceApiPluginsDeletePost,
+ getPluginsApiPluginsGetPost,
+ installPluginPackageApiPluginsInstallPackagePost,
+ reloadPluginByNameApiPluginsReloadPluginPost,
+ reloadPluginInstanceApiPluginsReloadInstancePost,
+ reloadPluginsApiPluginsReloadPost,
+ uninstallPluginPackageApiPluginsUninstallPackagePost,
+ updatePluginInstanceApiPluginsUpdatePost,
+} from '../generated/sdk.gen'
+import type {
+ OutBase,
+ PluginAddIn,
+ PluginDeleteIn,
+ PluginInstanceModel as GeneratedPluginInstance,
+ PluginMutationOut as GeneratedPluginMutationOut,
+ PluginPackageIn,
+ PluginReloadInstanceIn,
+ PluginReloadPluginIn,
+ PluginRuntimeStateModel as GeneratedPluginRuntimeState,
+ PluginUpdateIn,
+ PluginsGetOut as GeneratedPluginGetOut,
+} from '../generated/types.gen'
+
+export type PluginInstance = GeneratedPluginInstance
+export type PluginGetOut = GeneratedPluginGetOut
+export type PluginRuntimeState = GeneratedPluginRuntimeState
+export type PluginMutationOut = GeneratedPluginMutationOut
+
+export interface PluginSchemaField {
+ type: string
+ format?: string
+ default?: unknown
+ required?: boolean
+ description?: string
+ item_type?: string
+ [key: string]: unknown
+}
+
+export const pluginApi = {
+ get() {
+ return getPluginsApiPluginsGetPost()
+ },
+
+ add(payload: PluginAddIn) {
+ return addPluginInstanceApiPluginsAddPost({ body: payload })
+ },
+
+ update(payload: PluginUpdateIn) {
+ return updatePluginInstanceApiPluginsUpdatePost({ body: payload })
+ },
+
+ remove(payload: PluginDeleteIn) {
+ return deletePluginInstanceApiPluginsDeletePost({ body: payload }) as Promise
+ },
+
+ reloadAll() {
+ return reloadPluginsApiPluginsReloadPost() as Promise
+ },
+
+ reloadInstance(payload: PluginReloadInstanceIn) {
+ return reloadPluginInstanceApiPluginsReloadInstancePost({
+ body: payload,
+ }) as Promise
+ },
+
+ reloadPlugin(payload: PluginReloadPluginIn) {
+ return reloadPluginByNameApiPluginsReloadPluginPost({
+ body: payload,
+ }) as Promise
+ },
+
+ installPackage(payload: PluginPackageIn) {
+ return installPluginPackageApiPluginsInstallPackagePost({
+ body: payload,
+ }) as Promise
+ },
+
+ uninstallPackage(payload: PluginPackageIn) {
+ return uninstallPluginPackageApiPluginsUninstallPackagePost({
+ body: payload,
+ }) as Promise
+ },
+}
diff --git a/frontend/src/api/gateways/queue.ts b/frontend/src/api/gateways/queue.ts
new file mode 100644
index 00000000..ac63d6f0
--- /dev/null
+++ b/frontend/src/api/gateways/queue.ts
@@ -0,0 +1,122 @@
+import {
+ createQueueApiQueuePost,
+ createQueueItemApiQueueQueueIdItemsPost,
+ createTimeSetApiQueueQueueIdTimesPost,
+ deleteQueueApiQueueQueueIdDelete,
+ deleteQueueItemApiQueueQueueIdItemsQueueItemIdDelete,
+ deleteTimeSetApiQueueQueueIdTimesTimeSetIdDelete,
+ getQueueApiQueueQueueIdGet,
+ getQueueItemApiQueueQueueIdItemsQueueItemIdGet,
+ getTimeSetApiQueueQueueIdTimesTimeSetIdGet,
+ listQueueItemsApiQueueQueueIdItemsGet,
+ listQueuesApiQueueGet,
+ listTimeSetsApiQueueQueueIdTimesGet,
+ reorderQueueApiQueueOrderPatch,
+ reorderQueueItemsApiQueueQueueIdItemsOrderPatch,
+ reorderTimeSetsApiQueueQueueIdTimesOrderPatch,
+ updateQueueApiQueueQueueIdPatch,
+ updateQueueItemApiQueueQueueIdItemsQueueItemIdPatch,
+ updateTimeSetApiQueueQueueIdTimesTimeSetIdPatch,
+} from '../generated/sdk.gen'
+import type { IndexOrderPatch, QueueItemRead, QueueRead, TimeSetRead } from '../generated/types.gen'
+
+export const queueApi = {
+ list() {
+ return listQueuesApiQueueGet()
+ },
+
+ create() {
+ return createQueueApiQueuePost()
+ },
+
+ get(queueId: string) {
+ return getQueueApiQueueQueueIdGet({ path: { queue_id: queueId } })
+ },
+
+ update(queueId: string, payload: QueueRead) {
+ return updateQueueApiQueueQueueIdPatch({
+ path: { queue_id: queueId },
+ body: payload,
+ })
+ },
+
+ remove(queueId: string) {
+ return deleteQueueApiQueueQueueIdDelete({ path: { queue_id: queueId } })
+ },
+
+ reorder(payload: IndexOrderPatch) {
+ return reorderQueueApiQueueOrderPatch({ body: payload })
+ },
+}
+
+export const timeSetApi = {
+ list(queueId: string) {
+ return listTimeSetsApiQueueQueueIdTimesGet({ path: { queue_id: queueId } })
+ },
+
+ create(queueId: string) {
+ return createTimeSetApiQueueQueueIdTimesPost({ path: { queue_id: queueId } })
+ },
+
+ get(queueId: string, timeSetId: string) {
+ return getTimeSetApiQueueQueueIdTimesTimeSetIdGet({
+ path: { queue_id: queueId, time_set_id: timeSetId },
+ })
+ },
+
+ update(queueId: string, timeSetId: string, payload: TimeSetRead) {
+ return updateTimeSetApiQueueQueueIdTimesTimeSetIdPatch({
+ path: { queue_id: queueId, time_set_id: timeSetId },
+ body: payload,
+ })
+ },
+
+ remove(queueId: string, timeSetId: string) {
+ return deleteTimeSetApiQueueQueueIdTimesTimeSetIdDelete({
+ path: { queue_id: queueId, time_set_id: timeSetId },
+ })
+ },
+
+ reorder(queueId: string, payload: IndexOrderPatch) {
+ return reorderTimeSetsApiQueueQueueIdTimesOrderPatch({
+ path: { queue_id: queueId },
+ body: payload,
+ })
+ },
+}
+
+export const queueItemApi = {
+ list(queueId: string) {
+ return listQueueItemsApiQueueQueueIdItemsGet({ path: { queue_id: queueId } })
+ },
+
+ create(queueId: string) {
+ return createQueueItemApiQueueQueueIdItemsPost({ path: { queue_id: queueId } })
+ },
+
+ get(queueId: string, queueItemId: string) {
+ return getQueueItemApiQueueQueueIdItemsQueueItemIdGet({
+ path: { queue_id: queueId, queue_item_id: queueItemId },
+ })
+ },
+
+ update(queueId: string, queueItemId: string, payload: QueueItemRead) {
+ return updateQueueItemApiQueueQueueIdItemsQueueItemIdPatch({
+ path: { queue_id: queueId, queue_item_id: queueItemId },
+ body: payload,
+ })
+ },
+
+ remove(queueId: string, queueItemId: string) {
+ return deleteQueueItemApiQueueQueueIdItemsQueueItemIdDelete({
+ path: { queue_id: queueId, queue_item_id: queueItemId },
+ })
+ },
+
+ reorder(queueId: string, payload: IndexOrderPatch) {
+ return reorderQueueItemsApiQueueQueueIdItemsOrderPatch({
+ path: { queue_id: queueId },
+ body: payload,
+ })
+ },
+}
diff --git a/frontend/src/api/gateways/script.ts b/frontend/src/api/gateways/script.ts
new file mode 100644
index 00000000..5a535c90
--- /dev/null
+++ b/frontend/src/api/gateways/script.ts
@@ -0,0 +1,151 @@
+import {
+ createScriptApiScriptsPost,
+ createUserApiScriptsScriptIdUsersPost,
+ deleteScriptApiScriptsScriptIdDelete,
+ deleteUserApiScriptsScriptIdUsersUserIdDelete,
+ exportScriptToFileApiScriptsScriptIdActionsExportFilePost,
+ getScriptApiScriptsScriptIdGet,
+ getUserApiScriptsScriptIdUsersUserIdGet,
+ getUserInfrastructureOptionsApiScriptsScriptIdUsersUserIdInfrastructureOptionsGet,
+ importInfrastructureApiScriptsScriptIdUsersUserIdActionsImportInfrastructurePost,
+ importScriptFromFileApiScriptsScriptIdActionsImportFilePost,
+ importScriptFromWebApiScriptsScriptIdActionsImportWebPost,
+ listScriptsApiScriptsGet,
+ listUsersApiScriptsScriptIdUsersGet,
+ reorderScriptsApiScriptsOrderPatch,
+ reorderUsersApiScriptsScriptIdUsersOrderPatch,
+ updateScriptApiScriptsScriptIdPatch,
+ updateUserApiScriptsScriptIdUsersUserIdPatch,
+ uploadScriptToWebApiScriptsScriptIdActionsUploadWebPost,
+} from '../generated/sdk.gen'
+import type {
+ IndexOrderPatch,
+ InfrastructureImportBody,
+ ScriptCreateIn,
+ ScriptPatchBody,
+ ScriptUploadBody,
+ ScriptUrlBody,
+ UserPatchBody,
+} from '../generated/types.gen'
+
+export const scriptApi = {
+ list() {
+ return listScriptsApiScriptsGet()
+ },
+
+ create(payload: ScriptCreateIn) {
+ return createScriptApiScriptsPost({ body: payload })
+ },
+
+ get(scriptId: string) {
+ return getScriptApiScriptsScriptIdGet({ path: { script_id: scriptId } })
+ },
+
+ update(scriptId: string, payload: ScriptPatchBody) {
+ return updateScriptApiScriptsScriptIdPatch({
+ path: { script_id: scriptId },
+ body: payload,
+ })
+ },
+
+ remove(scriptId: string) {
+ return deleteScriptApiScriptsScriptIdDelete({ path: { script_id: scriptId } })
+ },
+
+ reorder(payload: IndexOrderPatch) {
+ return reorderScriptsApiScriptsOrderPatch({ body: payload })
+ },
+
+ importFromFile(scriptId: string, path: string) {
+ return importScriptFromFileApiScriptsScriptIdActionsImportFilePost({
+ path: { script_id: scriptId },
+ body: { path },
+ })
+ },
+
+ exportToFile(scriptId: string, path: string) {
+ return exportScriptToFileApiScriptsScriptIdActionsExportFilePost({
+ path: { script_id: scriptId },
+ body: { path },
+ })
+ },
+
+ importFromWeb(scriptId: string, body: ScriptUrlBody) {
+ return importScriptFromWebApiScriptsScriptIdActionsImportWebPost({
+ path: { script_id: scriptId },
+ body,
+ })
+ },
+
+ importTemplateFromWeb(scriptId: string, body: ScriptUrlBody) {
+ return importScriptFromWebApiScriptsScriptIdActionsImportWebPost({
+ path: { script_id: scriptId },
+ body,
+ })
+ },
+
+ uploadToWeb(scriptId: string, body: ScriptUploadBody) {
+ return uploadScriptToWebApiScriptsScriptIdActionsUploadWebPost({
+ path: { script_id: scriptId },
+ body,
+ })
+ },
+
+ uploadTemplateToWeb(scriptId: string, body: ScriptUploadBody) {
+ return uploadScriptToWebApiScriptsScriptIdActionsUploadWebPost({
+ path: { script_id: scriptId },
+ body,
+ })
+ },
+}
+
+export const userApi = {
+ list(scriptId: string) {
+ return listUsersApiScriptsScriptIdUsersGet({
+ path: { script_id: scriptId },
+ })
+ },
+
+ create(scriptId: string) {
+ return createUserApiScriptsScriptIdUsersPost({ path: { script_id: scriptId } })
+ },
+
+ get(scriptId: string, userId: string) {
+ return getUserApiScriptsScriptIdUsersUserIdGet({
+ path: { script_id: scriptId, user_id: userId },
+ })
+ },
+
+ update(scriptId: string, userId: string, payload: UserPatchBody) {
+ return updateUserApiScriptsScriptIdUsersUserIdPatch({
+ path: { script_id: scriptId, user_id: userId },
+ body: payload,
+ })
+ },
+
+ remove(scriptId: string, userId: string) {
+ return deleteUserApiScriptsScriptIdUsersUserIdDelete({
+ path: { script_id: scriptId, user_id: userId },
+ })
+ },
+
+ reorder(scriptId: string, payload: IndexOrderPatch) {
+ return reorderUsersApiScriptsScriptIdUsersOrderPatch({
+ path: { script_id: scriptId },
+ body: payload,
+ })
+ },
+
+ importInfrastructure(scriptId: string, userId: string, body: InfrastructureImportBody) {
+ return importInfrastructureApiScriptsScriptIdUsersUserIdActionsImportInfrastructurePost({
+ path: { script_id: scriptId, user_id: userId },
+ body,
+ })
+ },
+
+ getInfrastructureOptions(scriptId: string, userId: string) {
+ return getUserInfrastructureOptionsApiScriptsScriptIdUsersUserIdInfrastructureOptionsGet({
+ path: { script_id: scriptId, user_id: userId },
+ })
+ },
+}
diff --git a/frontend/src/api/gateways/setting.ts b/frontend/src/api/gateways/setting.ts
new file mode 100644
index 00000000..f96efad2
--- /dev/null
+++ b/frontend/src/api/gateways/setting.ts
@@ -0,0 +1,108 @@
+import {
+ createUserWebhookApiScriptsScriptIdUsersUserIdWebhooksPost,
+ createWebhookApiSettingWebhooksPost,
+ deleteUserWebhookApiScriptsScriptIdUsersUserIdWebhooksWebhookIdDelete,
+ deleteWebhookApiSettingWebhooksWebhookIdDelete,
+ getSettingApiSettingGet,
+ getUserWebhookApiScriptsScriptIdUsersUserIdWebhooksWebhookIdGet,
+ getWebhookApiSettingWebhooksWebhookIdGet,
+ listUserWebhooksApiScriptsScriptIdUsersUserIdWebhooksGet,
+ listWebhooksApiSettingWebhooksGet,
+ reorderUserWebhooksApiScriptsScriptIdUsersUserIdWebhooksOrderPatch,
+ reorderWebhooksApiSettingWebhooksOrderPatch,
+ testNotifyApiSettingActionsTestNotifyPost,
+ testWebhookApiSettingWebhooksTestPost,
+ updateSettingApiSettingPatch,
+ updateUserWebhookApiScriptsScriptIdUsersUserIdWebhooksWebhookIdPatch,
+ updateWebhookApiSettingWebhooksWebhookIdPatch,
+} from '../generated/sdk.gen'
+import type { GlobalConfigRead, IndexOrderPatch, WebhookRead } from '../generated/types.gen'
+
+export const settingApi = {
+ get() {
+ return getSettingApiSettingGet()
+ },
+
+ update(payload: GlobalConfigRead) {
+ return updateSettingApiSettingPatch({ body: payload })
+ },
+
+ testNotify() {
+ return testNotifyApiSettingActionsTestNotifyPost()
+ },
+}
+
+export const webhookApi = {
+ listGlobal() {
+ return listWebhooksApiSettingWebhooksGet()
+ },
+
+ createGlobal() {
+ return createWebhookApiSettingWebhooksPost()
+ },
+
+ getGlobal(webhookId: string) {
+ return getWebhookApiSettingWebhooksWebhookIdGet({
+ path: { webhook_id: webhookId },
+ })
+ },
+
+ updateGlobal(webhookId: string, payload: WebhookRead) {
+ return updateWebhookApiSettingWebhooksWebhookIdPatch({
+ path: { webhook_id: webhookId },
+ body: payload,
+ })
+ },
+
+ removeGlobal(webhookId: string) {
+ return deleteWebhookApiSettingWebhooksWebhookIdDelete({
+ path: { webhook_id: webhookId },
+ })
+ },
+
+ reorderGlobal(payload: IndexOrderPatch) {
+ return reorderWebhooksApiSettingWebhooksOrderPatch({ body: payload })
+ },
+
+ testGlobal(payload: WebhookRead) {
+ return testWebhookApiSettingWebhooksTestPost({ body: payload })
+ },
+
+ listUser(scriptId: string, userId: string) {
+ return listUserWebhooksApiScriptsScriptIdUsersUserIdWebhooksGet({
+ path: { script_id: scriptId, user_id: userId },
+ })
+ },
+
+ createUser(scriptId: string, userId: string) {
+ return createUserWebhookApiScriptsScriptIdUsersUserIdWebhooksPost({
+ path: { script_id: scriptId, user_id: userId },
+ })
+ },
+
+ getUser(scriptId: string, userId: string, webhookId: string) {
+ return getUserWebhookApiScriptsScriptIdUsersUserIdWebhooksWebhookIdGet({
+ path: { script_id: scriptId, user_id: userId, webhook_id: webhookId },
+ })
+ },
+
+ updateUser(scriptId: string, userId: string, webhookId: string, payload: WebhookRead) {
+ return updateUserWebhookApiScriptsScriptIdUsersUserIdWebhooksWebhookIdPatch({
+ path: { script_id: scriptId, user_id: userId, webhook_id: webhookId },
+ body: payload,
+ })
+ },
+
+ removeUser(scriptId: string, userId: string, webhookId: string) {
+ return deleteUserWebhookApiScriptsScriptIdUsersUserIdWebhooksWebhookIdDelete({
+ path: { script_id: scriptId, user_id: userId, webhook_id: webhookId },
+ })
+ },
+
+ reorderUser(scriptId: string, userId: string, payload: IndexOrderPatch) {
+ return reorderUserWebhooksApiScriptsScriptIdUsersUserIdWebhooksOrderPatch({
+ path: { script_id: scriptId, user_id: userId },
+ body: payload,
+ })
+ },
+}
diff --git a/frontend/src/api/gateways/tools.ts b/frontend/src/api/gateways/tools.ts
new file mode 100644
index 00000000..cb152b49
--- /dev/null
+++ b/frontend/src/api/gateways/tools.ts
@@ -0,0 +1,12 @@
+import { getToolsApiToolsGet, updateToolsApiToolsPatch } from '../generated/sdk.gen'
+import type { ToolsConfigRead } from '../generated/types.gen'
+
+export const toolsApi = {
+ get() {
+ return getToolsApiToolsGet()
+ },
+
+ update(payload: ToolsConfigRead) {
+ return updateToolsApiToolsPatch({ body: payload })
+ },
+}
diff --git a/frontend/src/api/gateways/update.ts b/frontend/src/api/gateways/update.ts
new file mode 100644
index 00000000..84b5b70f
--- /dev/null
+++ b/frontend/src/api/gateways/update.ts
@@ -0,0 +1,20 @@
+import {
+ checkUpdateApiUpdateCheckPost,
+ downloadUpdateApiUpdateDownloadPost,
+ installUpdateApiUpdateInstallPost,
+} from '../generated/sdk.gen'
+import type { UpdateCheckIn } from '../generated/types.gen'
+
+export const updateApi = {
+ check(payload: UpdateCheckIn) {
+ return checkUpdateApiUpdateCheckPost({ body: payload })
+ },
+
+ download() {
+ return downloadUpdateApiUpdateDownloadPost()
+ },
+
+ install() {
+ return installUpdateApiUpdateInstallPost()
+ },
+}
diff --git a/frontend/src/api/gateways/wsDebug.ts b/frontend/src/api/gateways/wsDebug.ts
new file mode 100644
index 00000000..906f13d7
--- /dev/null
+++ b/frontend/src/api/gateways/wsDebug.ts
@@ -0,0 +1,64 @@
+import {
+ clearHistoryApiWsDebugHistoryClearPost,
+ connectClientApiWsDebugClientConnectPost,
+ createClientApiWsDebugClientCreatePost,
+ disconnectClientApiWsDebugClientDisconnectPost,
+ getHistoryApiWsDebugHistoryGet,
+ listClientsApiWsDebugClientListGet,
+ removeClientApiWsDebugClientRemovePost,
+ sendAuthApiWsDebugMessageAuthPost,
+ sendJsonMessageApiWsDebugMessageSendJsonPost,
+ sendMessageApiWsDebugMessageSendPost,
+} from '../generated/sdk.gen'
+import type {
+ WsClearHistoryIn,
+ WsClientAuthIn,
+ WsClientConnectIn,
+ WsClientCreateIn,
+ WsClientDisconnectIn,
+ WsClientRemoveIn,
+ WsClientSendIn,
+ WsClientSendJsonIn,
+} from '../generated/types.gen'
+
+export const wsDebugApi = {
+ listClients() {
+ return listClientsApiWsDebugClientListGet()
+ },
+
+ createClient(payload: WsClientCreateIn) {
+ return createClientApiWsDebugClientCreatePost({ body: payload })
+ },
+
+ connectClient(payload: WsClientConnectIn) {
+ return connectClientApiWsDebugClientConnectPost({ body: payload })
+ },
+
+ disconnectClient(payload: WsClientDisconnectIn) {
+ return disconnectClientApiWsDebugClientDisconnectPost({ body: payload })
+ },
+
+ removeClient(payload: WsClientRemoveIn) {
+ return removeClientApiWsDebugClientRemovePost({ body: payload })
+ },
+
+ sendJson(payload: WsClientSendJsonIn) {
+ return sendJsonMessageApiWsDebugMessageSendJsonPost({ body: payload })
+ },
+
+ sendMessage(payload: WsClientSendIn) {
+ return sendMessageApiWsDebugMessageSendPost({ body: payload })
+ },
+
+ sendAuth(payload: WsClientAuthIn) {
+ return sendAuthApiWsDebugMessageAuthPost({ body: payload })
+ },
+
+ clearHistory(payload: WsClearHistoryIn) {
+ return clearHistoryApiWsDebugHistoryClearPost({ body: payload })
+ },
+
+ getHistory() {
+ return getHistoryApiWsDebugHistoryGet()
+ },
+}
diff --git a/frontend/src/api/generated/client.gen.ts b/frontend/src/api/generated/client.gen.ts
new file mode 100644
index 00000000..cab3c701
--- /dev/null
+++ b/frontend/src/api/generated/client.gen.ts
@@ -0,0 +1,16 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+import { type ClientOptions, type Config, createClient, createConfig } from './client';
+import type { ClientOptions as ClientOptions2 } from './types.gen';
+
+/**
+ * The `createClientConfig()` function will be called on client initialization
+ * and the returned object will become the client's initial configuration.
+ *
+ * You may want to initialize your client this way instead of calling
+ * `setConfig()`. This is useful for example if you're using Next.js
+ * to ensure your client always has the correct values.
+ */
+export type CreateClientConfig = (override?: Config) => Config & T>;
+
+export const client = createClient(createConfig());
diff --git a/frontend/src/api/generated/client/client.gen.ts b/frontend/src/api/generated/client/client.gen.ts
new file mode 100644
index 00000000..a439d274
--- /dev/null
+++ b/frontend/src/api/generated/client/client.gen.ts
@@ -0,0 +1,268 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+import { createSseClient } from '../core/serverSentEvents.gen';
+import type { HttpMethod } from '../core/types.gen';
+import { getValidRequestBody } from '../core/utils.gen';
+import type {
+ Client,
+ Config,
+ RequestOptions,
+ ResolvedRequestOptions,
+} from './types.gen';
+import {
+ buildUrl,
+ createConfig,
+ createInterceptors,
+ getParseAs,
+ mergeConfigs,
+ mergeHeaders,
+ setAuthParams,
+} from './utils.gen';
+
+type ReqInit = Omit & {
+ body?: any;
+ headers: ReturnType;
+};
+
+export const createClient = (config: Config = {}): Client => {
+ let _config = mergeConfigs(createConfig(), config);
+
+ const getConfig = (): Config => ({ ..._config });
+
+ const setConfig = (config: Config): Config => {
+ _config = mergeConfigs(_config, config);
+ return getConfig();
+ };
+
+ const interceptors = createInterceptors<
+ Request,
+ Response,
+ unknown,
+ ResolvedRequestOptions
+ >();
+
+ const beforeRequest = async (options: RequestOptions) => {
+ const opts = {
+ ..._config,
+ ...options,
+ fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
+ headers: mergeHeaders(_config.headers, options.headers),
+ serializedBody: undefined,
+ };
+
+ if (opts.security) {
+ await setAuthParams({
+ ...opts,
+ security: opts.security,
+ });
+ }
+
+ if (opts.requestValidator) {
+ await opts.requestValidator(opts);
+ }
+
+ if (opts.body !== undefined && opts.bodySerializer) {
+ opts.serializedBody = opts.bodySerializer(opts.body);
+ }
+
+ // remove Content-Type header if body is empty to avoid sending invalid requests
+ if (opts.body === undefined || opts.serializedBody === '') {
+ opts.headers.delete('Content-Type');
+ }
+
+ const url = buildUrl(opts);
+
+ return { opts, url };
+ };
+
+ const request: Client['request'] = async (options) => {
+ // @ts-expect-error
+ const { opts, url } = await beforeRequest(options);
+ const requestInit: ReqInit = {
+ redirect: 'follow',
+ ...opts,
+ body: getValidRequestBody(opts),
+ };
+
+ let request = new Request(url, requestInit);
+
+ for (const fn of interceptors.request.fns) {
+ if (fn) {
+ request = await fn(request, opts);
+ }
+ }
+
+ // fetch must be assigned here, otherwise it would throw the error:
+ // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
+ const _fetch = opts.fetch!;
+ let response = await _fetch(request);
+
+ for (const fn of interceptors.response.fns) {
+ if (fn) {
+ response = await fn(response, request, opts);
+ }
+ }
+
+ const result = {
+ request,
+ response,
+ };
+
+ if (response.ok) {
+ const parseAs =
+ (opts.parseAs === 'auto'
+ ? getParseAs(response.headers.get('Content-Type'))
+ : opts.parseAs) ?? 'json';
+
+ if (
+ response.status === 204 ||
+ response.headers.get('Content-Length') === '0'
+ ) {
+ let emptyData: any;
+ switch (parseAs) {
+ case 'arrayBuffer':
+ case 'blob':
+ case 'text':
+ emptyData = await response[parseAs]();
+ break;
+ case 'formData':
+ emptyData = new FormData();
+ break;
+ case 'stream':
+ emptyData = response.body;
+ break;
+ case 'json':
+ default:
+ emptyData = {};
+ break;
+ }
+ return opts.responseStyle === 'data'
+ ? emptyData
+ : {
+ data: emptyData,
+ ...result,
+ };
+ }
+
+ let data: any;
+ switch (parseAs) {
+ case 'arrayBuffer':
+ case 'blob':
+ case 'formData':
+ case 'json':
+ case 'text':
+ data = await response[parseAs]();
+ break;
+ case 'stream':
+ return opts.responseStyle === 'data'
+ ? response.body
+ : {
+ data: response.body,
+ ...result,
+ };
+ }
+
+ if (parseAs === 'json') {
+ if (opts.responseValidator) {
+ await opts.responseValidator(data);
+ }
+
+ if (opts.responseTransformer) {
+ data = await opts.responseTransformer(data);
+ }
+ }
+
+ return opts.responseStyle === 'data'
+ ? data
+ : {
+ data,
+ ...result,
+ };
+ }
+
+ const textError = await response.text();
+ let jsonError: unknown;
+
+ try {
+ jsonError = JSON.parse(textError);
+ } catch {
+ // noop
+ }
+
+ const error = jsonError ?? textError;
+ let finalError = error;
+
+ for (const fn of interceptors.error.fns) {
+ if (fn) {
+ finalError = (await fn(error, response, request, opts)) as string;
+ }
+ }
+
+ finalError = finalError || ({} as string);
+
+ if (opts.throwOnError) {
+ throw finalError;
+ }
+
+ // TODO: we probably want to return error and improve types
+ return opts.responseStyle === 'data'
+ ? undefined
+ : {
+ error: finalError,
+ ...result,
+ };
+ };
+
+ const makeMethodFn =
+ (method: Uppercase) => (options: RequestOptions) =>
+ request({ ...options, method });
+
+ const makeSseFn =
+ (method: Uppercase) => async (options: RequestOptions) => {
+ const { opts, url } = await beforeRequest(options);
+ return createSseClient({
+ ...opts,
+ body: opts.body as BodyInit | null | undefined,
+ headers: opts.headers as unknown as Record,
+ method,
+ onRequest: async (url, init) => {
+ let request = new Request(url, init);
+ for (const fn of interceptors.request.fns) {
+ if (fn) {
+ request = await fn(request, opts);
+ }
+ }
+ return request;
+ },
+ url,
+ });
+ };
+
+ return {
+ buildUrl,
+ connect: makeMethodFn('CONNECT'),
+ delete: makeMethodFn('DELETE'),
+ get: makeMethodFn('GET'),
+ getConfig,
+ head: makeMethodFn('HEAD'),
+ interceptors,
+ options: makeMethodFn('OPTIONS'),
+ patch: makeMethodFn('PATCH'),
+ post: makeMethodFn('POST'),
+ put: makeMethodFn('PUT'),
+ request,
+ setConfig,
+ sse: {
+ connect: makeSseFn('CONNECT'),
+ delete: makeSseFn('DELETE'),
+ get: makeSseFn('GET'),
+ head: makeSseFn('HEAD'),
+ options: makeSseFn('OPTIONS'),
+ patch: makeSseFn('PATCH'),
+ post: makeSseFn('POST'),
+ put: makeSseFn('PUT'),
+ trace: makeSseFn('TRACE'),
+ },
+ trace: makeMethodFn('TRACE'),
+ } as Client;
+};
diff --git a/frontend/src/api/generated/client/index.ts b/frontend/src/api/generated/client/index.ts
new file mode 100644
index 00000000..cbf8dfee
--- /dev/null
+++ b/frontend/src/api/generated/client/index.ts
@@ -0,0 +1,26 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+export type { Auth } from '../core/auth.gen';
+export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
+export {
+ formDataBodySerializer,
+ jsonBodySerializer,
+ urlSearchParamsBodySerializer,
+} from '../core/bodySerializer.gen';
+export { buildClientParams } from '../core/params.gen';
+export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
+export { createClient } from './client.gen';
+export type {
+ Client,
+ ClientOptions,
+ Config,
+ CreateClientConfig,
+ Options,
+ OptionsLegacyParser,
+ RequestOptions,
+ RequestResult,
+ ResolvedRequestOptions,
+ ResponseStyle,
+ TDataShape,
+} from './types.gen';
+export { createConfig, mergeHeaders } from './utils.gen';
diff --git a/frontend/src/api/generated/client/types.gen.ts b/frontend/src/api/generated/client/types.gen.ts
new file mode 100644
index 00000000..5c70971f
--- /dev/null
+++ b/frontend/src/api/generated/client/types.gen.ts
@@ -0,0 +1,268 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+import type { Auth } from '../core/auth.gen';
+import type {
+ ServerSentEventsOptions,
+ ServerSentEventsResult,
+} from '../core/serverSentEvents.gen';
+import type {
+ Client as CoreClient,
+ Config as CoreConfig,
+} from '../core/types.gen';
+import type { Middleware } from './utils.gen';
+
+export type ResponseStyle = 'data' | 'fields';
+
+export interface Config
+ extends Omit,
+ CoreConfig {
+ /**
+ * Base URL for all requests made by this client.
+ */
+ baseUrl?: T['baseUrl'];
+ /**
+ * Fetch API implementation. You can use this option to provide a custom
+ * fetch instance.
+ *
+ * @default globalThis.fetch
+ */
+ fetch?: typeof fetch;
+ /**
+ * Please don't use the Fetch client for Next.js applications. The `next`
+ * options won't have any effect.
+ *
+ * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
+ */
+ next?: never;
+ /**
+ * Return the response data parsed in a specified format. By default, `auto`
+ * will infer the appropriate method from the `Content-Type` response header.
+ * You can override this behavior with any of the {@link Body} methods.
+ * Select `stream` if you don't want to parse response data at all.
+ *
+ * @default 'auto'
+ */
+ parseAs?:
+ | 'arrayBuffer'
+ | 'auto'
+ | 'blob'
+ | 'formData'
+ | 'json'
+ | 'stream'
+ | 'text';
+ /**
+ * Should we return only data or multiple fields (data, error, response, etc.)?
+ *
+ * @default 'fields'
+ */
+ responseStyle?: ResponseStyle;
+ /**
+ * Throw an error instead of returning it in the response?
+ *
+ * @default false
+ */
+ throwOnError?: T['throwOnError'];
+}
+
+export interface RequestOptions<
+ TData = unknown,
+ TResponseStyle extends ResponseStyle = 'fields',
+ ThrowOnError extends boolean = boolean,
+ Url extends string = string,
+> extends Config<{
+ responseStyle: TResponseStyle;
+ throwOnError: ThrowOnError;
+ }>,
+ Pick<
+ ServerSentEventsOptions,
+ | 'onSseError'
+ | 'onSseEvent'
+ | 'sseDefaultRetryDelay'
+ | 'sseMaxRetryAttempts'
+ | 'sseMaxRetryDelay'
+ > {
+ /**
+ * Any body that you want to add to your request.
+ *
+ * {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
+ */
+ body?: unknown;
+ path?: Record;
+ query?: Record;
+ /**
+ * Security mechanism(s) to use for the request.
+ */
+ security?: ReadonlyArray;
+ url: Url;
+}
+
+export interface ResolvedRequestOptions<
+ TResponseStyle extends ResponseStyle = 'fields',
+ ThrowOnError extends boolean = boolean,
+ Url extends string = string,
+> extends RequestOptions {
+ serializedBody?: string;
+}
+
+export type RequestResult<
+ TData = unknown,
+ TError = unknown,
+ ThrowOnError extends boolean = boolean,
+ TResponseStyle extends ResponseStyle = 'fields',
+> = ThrowOnError extends true
+ ? Promise<
+ TResponseStyle extends 'data'
+ ? TData extends Record
+ ? TData[keyof TData]
+ : TData
+ : {
+ data: TData extends Record
+ ? TData[keyof TData]
+ : TData;
+ request: Request;
+ response: Response;
+ }
+ >
+ : Promise<
+ TResponseStyle extends 'data'
+ ?
+ | (TData extends Record
+ ? TData[keyof TData]
+ : TData)
+ | undefined
+ : (
+ | {
+ data: TData extends Record
+ ? TData[keyof TData]
+ : TData;
+ error: undefined;
+ }
+ | {
+ data: undefined;
+ error: TError extends Record
+ ? TError[keyof TError]
+ : TError;
+ }
+ ) & {
+ request: Request;
+ response: Response;
+ }
+ >;
+
+export interface ClientOptions {
+ baseUrl?: string;
+ responseStyle?: ResponseStyle;
+ throwOnError?: boolean;
+}
+
+type MethodFn = <
+ TData = unknown,
+ TError = unknown,
+ ThrowOnError extends boolean = false,
+ TResponseStyle extends ResponseStyle = 'fields',
+>(
+ options: Omit, 'method'>,
+) => RequestResult;
+
+type SseFn = <
+ TData = unknown,
+ TError = unknown,
+ ThrowOnError extends boolean = false,
+ TResponseStyle extends ResponseStyle = 'fields',
+>(
+ options: Omit, 'method'>,
+) => Promise>;
+
+type RequestFn = <
+ TData = unknown,
+ TError = unknown,
+ ThrowOnError extends boolean = false,
+ TResponseStyle extends ResponseStyle = 'fields',
+>(
+ options: Omit, 'method'> &
+ Pick<
+ Required>,
+ 'method'
+ >,
+) => RequestResult;
+
+type BuildUrlFn = <
+ TData extends {
+ body?: unknown;
+ path?: Record;
+ query?: Record;
+ url: string;
+ },
+>(
+ options: TData & Options,
+) => string;
+
+export type Client = CoreClient<
+ RequestFn,
+ Config,
+ MethodFn,
+ BuildUrlFn,
+ SseFn
+> & {
+ interceptors: Middleware;
+};
+
+/**
+ * The `createClientConfig()` function will be called on client initialization
+ * and the returned object will become the client's initial configuration.
+ *
+ * You may want to initialize your client this way instead of calling
+ * `setConfig()`. This is useful for example if you're using Next.js
+ * to ensure your client always has the correct values.
+ */
+export type CreateClientConfig = (
+ override?: Config,
+) => Config & T>;
+
+export interface TDataShape {
+ body?: unknown;
+ headers?: unknown;
+ path?: unknown;
+ query?: unknown;
+ url: string;
+}
+
+type OmitKeys = Pick>;
+
+export type Options<
+ TData extends TDataShape = TDataShape,
+ ThrowOnError extends boolean = boolean,
+ TResponse = unknown,
+ TResponseStyle extends ResponseStyle = 'fields',
+> = OmitKeys<
+ RequestOptions,
+ 'body' | 'path' | 'query' | 'url'
+> &
+ ([TData] extends [never] ? unknown : Omit);
+
+export type OptionsLegacyParser<
+ TData = unknown,
+ ThrowOnError extends boolean = boolean,
+ TResponseStyle extends ResponseStyle = 'fields',
+> = TData extends { body?: any }
+ ? TData extends { headers?: any }
+ ? OmitKeys<
+ RequestOptions,
+ 'body' | 'headers' | 'url'
+ > &
+ TData
+ : OmitKeys<
+ RequestOptions,
+ 'body' | 'url'
+ > &
+ TData &
+ Pick, 'headers'>
+ : TData extends { headers?: any }
+ ? OmitKeys<
+ RequestOptions,
+ 'headers' | 'url'
+ > &
+ TData &
+ Pick, 'body'>
+ : OmitKeys, 'url'> &
+ TData;
diff --git a/frontend/src/api/generated/client/utils.gen.ts b/frontend/src/api/generated/client/utils.gen.ts
new file mode 100644
index 00000000..4c48a9ee
--- /dev/null
+++ b/frontend/src/api/generated/client/utils.gen.ts
@@ -0,0 +1,332 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+import { getAuthToken } from '../core/auth.gen';
+import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
+import { jsonBodySerializer } from '../core/bodySerializer.gen';
+import {
+ serializeArrayParam,
+ serializeObjectParam,
+ serializePrimitiveParam,
+} from '../core/pathSerializer.gen';
+import { getUrl } from '../core/utils.gen';
+import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
+
+export const createQuerySerializer = ({
+ parameters = {},
+ ...args
+}: QuerySerializerOptions = {}) => {
+ const querySerializer = (queryParams: T) => {
+ const search: string[] = [];
+ if (queryParams && typeof queryParams === 'object') {
+ for (const name in queryParams) {
+ const value = queryParams[name];
+
+ if (value === undefined || value === null) {
+ continue;
+ }
+
+ const options = parameters[name] || args;
+
+ if (Array.isArray(value)) {
+ const serializedArray = serializeArrayParam({
+ allowReserved: options.allowReserved,
+ explode: true,
+ name,
+ style: 'form',
+ value,
+ ...options.array,
+ });
+ if (serializedArray) search.push(serializedArray);
+ } else if (typeof value === 'object') {
+ const serializedObject = serializeObjectParam({
+ allowReserved: options.allowReserved,
+ explode: true,
+ name,
+ style: 'deepObject',
+ value: value as Record,
+ ...options.object,
+ });
+ if (serializedObject) search.push(serializedObject);
+ } else {
+ const serializedPrimitive = serializePrimitiveParam({
+ allowReserved: options.allowReserved,
+ name,
+ value: value as string,
+ });
+ if (serializedPrimitive) search.push(serializedPrimitive);
+ }
+ }
+ }
+ return search.join('&');
+ };
+ return querySerializer;
+};
+
+/**
+ * Infers parseAs value from provided Content-Type header.
+ */
+export const getParseAs = (
+ contentType: string | null,
+): Exclude => {
+ if (!contentType) {
+ // If no Content-Type header is provided, the best we can do is return the raw response body,
+ // which is effectively the same as the 'stream' option.
+ return 'stream';
+ }
+
+ const cleanContent = contentType.split(';')[0]?.trim();
+
+ if (!cleanContent) {
+ return;
+ }
+
+ if (
+ cleanContent.startsWith('application/json') ||
+ cleanContent.endsWith('+json')
+ ) {
+ return 'json';
+ }
+
+ if (cleanContent === 'multipart/form-data') {
+ return 'formData';
+ }
+
+ if (
+ ['application/', 'audio/', 'image/', 'video/'].some((type) =>
+ cleanContent.startsWith(type),
+ )
+ ) {
+ return 'blob';
+ }
+
+ if (cleanContent.startsWith('text/')) {
+ return 'text';
+ }
+
+ return;
+};
+
+const checkForExistence = (
+ options: Pick & {
+ headers: Headers;
+ },
+ name?: string,
+): boolean => {
+ if (!name) {
+ return false;
+ }
+ if (
+ options.headers.has(name) ||
+ options.query?.[name] ||
+ options.headers.get('Cookie')?.includes(`${name}=`)
+ ) {
+ return true;
+ }
+ return false;
+};
+
+export const setAuthParams = async ({
+ security,
+ ...options
+}: Pick, 'security'> &
+ Pick & {
+ headers: Headers;
+ }) => {
+ for (const auth of security) {
+ if (checkForExistence(options, auth.name)) {
+ continue;
+ }
+
+ const token = await getAuthToken(auth, options.auth);
+
+ if (!token) {
+ continue;
+ }
+
+ const name = auth.name ?? 'Authorization';
+
+ switch (auth.in) {
+ case 'query':
+ if (!options.query) {
+ options.query = {};
+ }
+ options.query[name] = token;
+ break;
+ case 'cookie':
+ options.headers.append('Cookie', `${name}=${token}`);
+ break;
+ case 'header':
+ default:
+ options.headers.set(name, token);
+ break;
+ }
+ }
+};
+
+export const buildUrl: Client['buildUrl'] = (options) =>
+ getUrl({
+ baseUrl: options.baseUrl as string,
+ path: options.path,
+ query: options.query,
+ querySerializer:
+ typeof options.querySerializer === 'function'
+ ? options.querySerializer
+ : createQuerySerializer(options.querySerializer),
+ url: options.url,
+ });
+
+export const mergeConfigs = (a: Config, b: Config): Config => {
+ const config = { ...a, ...b };
+ if (config.baseUrl?.endsWith('/')) {
+ config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
+ }
+ config.headers = mergeHeaders(a.headers, b.headers);
+ return config;
+};
+
+const headersEntries = (headers: Headers): Array<[string, string]> => {
+ const entries: Array<[string, string]> = [];
+ headers.forEach((value, key) => {
+ entries.push([key, value]);
+ });
+ return entries;
+};
+
+export const mergeHeaders = (
+ ...headers: Array['headers'] | undefined>
+): Headers => {
+ const mergedHeaders = new Headers();
+ for (const header of headers) {
+ if (!header) {
+ continue;
+ }
+
+ const iterator =
+ header instanceof Headers
+ ? headersEntries(header)
+ : Object.entries(header);
+
+ for (const [key, value] of iterator) {
+ if (value === null) {
+ mergedHeaders.delete(key);
+ } else if (Array.isArray(value)) {
+ for (const v of value) {
+ mergedHeaders.append(key, v as string);
+ }
+ } else if (value !== undefined) {
+ // assume object headers are meant to be JSON stringified, i.e. their
+ // content value in OpenAPI specification is 'application/json'
+ mergedHeaders.set(
+ key,
+ typeof value === 'object' ? JSON.stringify(value) : (value as string),
+ );
+ }
+ }
+ }
+ return mergedHeaders;
+};
+
+type ErrInterceptor = (
+ error: Err,
+ response: Res,
+ request: Req,
+ options: Options,
+) => Err | Promise;
+
+type ReqInterceptor = (
+ request: Req,
+ options: Options,
+) => Req | Promise;
+
+type ResInterceptor = (
+ response: Res,
+ request: Req,
+ options: Options,
+) => Res | Promise;
+
+class Interceptors {
+ fns: Array = [];
+
+ clear(): void {
+ this.fns = [];
+ }
+
+ eject(id: number | Interceptor): void {
+ const index = this.getInterceptorIndex(id);
+ if (this.fns[index]) {
+ this.fns[index] = null;
+ }
+ }
+
+ exists(id: number | Interceptor): boolean {
+ const index = this.getInterceptorIndex(id);
+ return Boolean(this.fns[index]);
+ }
+
+ getInterceptorIndex(id: number | Interceptor): number {
+ if (typeof id === 'number') {
+ return this.fns[id] ? id : -1;
+ }
+ return this.fns.indexOf(id);
+ }
+
+ update(
+ id: number | Interceptor,
+ fn: Interceptor,
+ ): number | Interceptor | false {
+ const index = this.getInterceptorIndex(id);
+ if (this.fns[index]) {
+ this.fns[index] = fn;
+ return id;
+ }
+ return false;
+ }
+
+ use(fn: Interceptor): number {
+ this.fns.push(fn);
+ return this.fns.length - 1;
+ }
+}
+
+export interface Middleware {
+ error: Interceptors>;
+ request: Interceptors>;
+ response: Interceptors>;
+}
+
+export const createInterceptors = (): Middleware<
+ Req,
+ Res,
+ Err,
+ Options
+> => ({
+ error: new Interceptors>(),
+ request: new Interceptors>(),
+ response: new Interceptors>(),
+});
+
+const defaultQuerySerializer = createQuerySerializer({
+ allowReserved: false,
+ array: {
+ explode: true,
+ style: 'form',
+ },
+ object: {
+ explode: true,
+ style: 'deepObject',
+ },
+});
+
+const defaultHeaders = {
+ 'Content-Type': 'application/json',
+};
+
+export const createConfig = (
+ override: Config & T> = {},
+): Config & T> => ({
+ ...jsonBodySerializer,
+ headers: defaultHeaders,
+ parseAs: 'auto',
+ querySerializer: defaultQuerySerializer,
+ ...override,
+});
diff --git a/frontend/src/api/generated/core/auth.gen.ts b/frontend/src/api/generated/core/auth.gen.ts
new file mode 100644
index 00000000..f8a73266
--- /dev/null
+++ b/frontend/src/api/generated/core/auth.gen.ts
@@ -0,0 +1,42 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+export type AuthToken = string | undefined;
+
+export interface Auth {
+ /**
+ * Which part of the request do we use to send the auth?
+ *
+ * @default 'header'
+ */
+ in?: 'header' | 'query' | 'cookie';
+ /**
+ * Header or query parameter name.
+ *
+ * @default 'Authorization'
+ */
+ name?: string;
+ scheme?: 'basic' | 'bearer';
+ type: 'apiKey' | 'http';
+}
+
+export const getAuthToken = async (
+ auth: Auth,
+ callback: ((auth: Auth) => Promise | AuthToken) | AuthToken,
+): Promise => {
+ const token =
+ typeof callback === 'function' ? await callback(auth) : callback;
+
+ if (!token) {
+ return;
+ }
+
+ if (auth.scheme === 'bearer') {
+ return `Bearer ${token}`;
+ }
+
+ if (auth.scheme === 'basic') {
+ return `Basic ${btoa(token)}`;
+ }
+
+ return token;
+};
diff --git a/frontend/src/api/generated/core/bodySerializer.gen.ts b/frontend/src/api/generated/core/bodySerializer.gen.ts
new file mode 100644
index 00000000..552b50f7
--- /dev/null
+++ b/frontend/src/api/generated/core/bodySerializer.gen.ts
@@ -0,0 +1,100 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+import type {
+ ArrayStyle,
+ ObjectStyle,
+ SerializerOptions,
+} from './pathSerializer.gen';
+
+export type QuerySerializer = (query: Record) => string;
+
+export type BodySerializer = (body: any) => any;
+
+type QuerySerializerOptionsObject = {
+ allowReserved?: boolean;
+ array?: Partial>;
+ object?: Partial>;
+};
+
+export type QuerySerializerOptions = QuerySerializerOptionsObject & {
+ /**
+ * Per-parameter serialization overrides. When provided, these settings
+ * override the global array/object settings for specific parameter names.
+ */
+ parameters?: Record;
+};
+
+const serializeFormDataPair = (
+ data: FormData,
+ key: string,
+ value: unknown,
+): void => {
+ if (typeof value === 'string' || value instanceof Blob) {
+ data.append(key, value);
+ } else if (value instanceof Date) {
+ data.append(key, value.toISOString());
+ } else {
+ data.append(key, JSON.stringify(value));
+ }
+};
+
+const serializeUrlSearchParamsPair = (
+ data: URLSearchParams,
+ key: string,
+ value: unknown,
+): void => {
+ if (typeof value === 'string') {
+ data.append(key, value);
+ } else {
+ data.append(key, JSON.stringify(value));
+ }
+};
+
+export const formDataBodySerializer = {
+ bodySerializer: | Array>>(
+ body: T,
+ ): FormData => {
+ const data = new FormData();
+
+ Object.entries(body).forEach(([key, value]) => {
+ if (value === undefined || value === null) {
+ return;
+ }
+ if (Array.isArray(value)) {
+ value.forEach((v) => serializeFormDataPair(data, key, v));
+ } else {
+ serializeFormDataPair(data, key, value);
+ }
+ });
+
+ return data;
+ },
+};
+
+export const jsonBodySerializer = {
+ bodySerializer: (body: T): string =>
+ JSON.stringify(body, (_key, value) =>
+ typeof value === 'bigint' ? value.toString() : value,
+ ),
+};
+
+export const urlSearchParamsBodySerializer = {
+ bodySerializer: | Array>>(
+ body: T,
+ ): string => {
+ const data = new URLSearchParams();
+
+ Object.entries(body).forEach(([key, value]) => {
+ if (value === undefined || value === null) {
+ return;
+ }
+ if (Array.isArray(value)) {
+ value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
+ } else {
+ serializeUrlSearchParamsPair(data, key, value);
+ }
+ });
+
+ return data.toString();
+ },
+};
diff --git a/frontend/src/api/generated/core/params.gen.ts b/frontend/src/api/generated/core/params.gen.ts
new file mode 100644
index 00000000..602715c4
--- /dev/null
+++ b/frontend/src/api/generated/core/params.gen.ts
@@ -0,0 +1,176 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+type Slot = 'body' | 'headers' | 'path' | 'query';
+
+export type Field =
+ | {
+ in: Exclude;
+ /**
+ * Field name. This is the name we want the user to see and use.
+ */
+ key: string;
+ /**
+ * Field mapped name. This is the name we want to use in the request.
+ * If omitted, we use the same value as `key`.
+ */
+ map?: string;
+ }
+ | {
+ in: Extract;
+ /**
+ * Key isn't required for bodies.
+ */
+ key?: string;
+ map?: string;
+ }
+ | {
+ /**
+ * Field name. This is the name we want the user to see and use.
+ */
+ key: string;
+ /**
+ * Field mapped name. This is the name we want to use in the request.
+ * If `in` is omitted, `map` aliases `key` to the transport layer.
+ */
+ map: Slot;
+ };
+
+export interface Fields {
+ allowExtra?: Partial>;
+ args?: ReadonlyArray;
+}
+
+export type FieldsConfig = ReadonlyArray;
+
+const extraPrefixesMap: Record = {
+ $body_: 'body',
+ $headers_: 'headers',
+ $path_: 'path',
+ $query_: 'query',
+};
+const extraPrefixes = Object.entries(extraPrefixesMap);
+
+type KeyMap = Map<
+ string,
+ | {
+ in: Slot;
+ map?: string;
+ }
+ | {
+ in?: never;
+ map: Slot;
+ }
+>;
+
+const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
+ if (!map) {
+ map = new Map();
+ }
+
+ for (const config of fields) {
+ if ('in' in config) {
+ if (config.key) {
+ map.set(config.key, {
+ in: config.in,
+ map: config.map,
+ });
+ }
+ } else if ('key' in config) {
+ map.set(config.key, {
+ map: config.map,
+ });
+ } else if (config.args) {
+ buildKeyMap(config.args, map);
+ }
+ }
+
+ return map;
+};
+
+interface Params {
+ body: unknown;
+ headers: Record;
+ path: Record;
+ query: Record;
+}
+
+const stripEmptySlots = (params: Params) => {
+ for (const [slot, value] of Object.entries(params)) {
+ if (value && typeof value === 'object' && !Object.keys(value).length) {
+ delete params[slot as Slot];
+ }
+ }
+};
+
+export const buildClientParams = (
+ args: ReadonlyArray,
+ fields: FieldsConfig,
+) => {
+ const params: Params = {
+ body: {},
+ headers: {},
+ path: {},
+ query: {},
+ };
+
+ const map = buildKeyMap(fields);
+
+ let config: FieldsConfig[number] | undefined;
+
+ for (const [index, arg] of args.entries()) {
+ if (fields[index]) {
+ config = fields[index];
+ }
+
+ if (!config) {
+ continue;
+ }
+
+ if ('in' in config) {
+ if (config.key) {
+ const field = map.get(config.key)!;
+ const name = field.map || config.key;
+ if (field.in) {
+ (params[field.in] as Record)[name] = arg;
+ }
+ } else {
+ params.body = arg;
+ }
+ } else {
+ for (const [key, value] of Object.entries(arg ?? {})) {
+ const field = map.get(key);
+
+ if (field) {
+ if (field.in) {
+ const name = field.map || key;
+ (params[field.in] as Record)[name] = value;
+ } else {
+ params[field.map] = value;
+ }
+ } else {
+ const extra = extraPrefixes.find(([prefix]) =>
+ key.startsWith(prefix),
+ );
+
+ if (extra) {
+ const [prefix, slot] = extra;
+ (params[slot] as Record)[
+ key.slice(prefix.length)
+ ] = value;
+ } else if ('allowExtra' in config && config.allowExtra) {
+ for (const [slot, allowed] of Object.entries(config.allowExtra)) {
+ if (allowed) {
+ (params[slot as Slot] as Record)[key] = value;
+ break;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ stripEmptySlots(params);
+
+ return params;
+};
diff --git a/frontend/src/api/generated/core/pathSerializer.gen.ts b/frontend/src/api/generated/core/pathSerializer.gen.ts
new file mode 100644
index 00000000..8d999310
--- /dev/null
+++ b/frontend/src/api/generated/core/pathSerializer.gen.ts
@@ -0,0 +1,181 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+interface SerializeOptions
+ extends SerializePrimitiveOptions,
+ SerializerOptions {}
+
+interface SerializePrimitiveOptions {
+ allowReserved?: boolean;
+ name: string;
+}
+
+export interface SerializerOptions {
+ /**
+ * @default true
+ */
+ explode: boolean;
+ style: T;
+}
+
+export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
+export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
+type MatrixStyle = 'label' | 'matrix' | 'simple';
+export type ObjectStyle = 'form' | 'deepObject';
+type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
+
+interface SerializePrimitiveParam extends SerializePrimitiveOptions {
+ value: string;
+}
+
+export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
+ switch (style) {
+ case 'label':
+ return '.';
+ case 'matrix':
+ return ';';
+ case 'simple':
+ return ',';
+ default:
+ return '&';
+ }
+};
+
+export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
+ switch (style) {
+ case 'form':
+ return ',';
+ case 'pipeDelimited':
+ return '|';
+ case 'spaceDelimited':
+ return '%20';
+ default:
+ return ',';
+ }
+};
+
+export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
+ switch (style) {
+ case 'label':
+ return '.';
+ case 'matrix':
+ return ';';
+ case 'simple':
+ return ',';
+ default:
+ return '&';
+ }
+};
+
+export const serializeArrayParam = ({
+ allowReserved,
+ explode,
+ name,
+ style,
+ value,
+}: SerializeOptions & {
+ value: unknown[];
+}) => {
+ if (!explode) {
+ const joinedValues = (
+ allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
+ ).join(separatorArrayNoExplode(style));
+ switch (style) {
+ case 'label':
+ return `.${joinedValues}`;
+ case 'matrix':
+ return `;${name}=${joinedValues}`;
+ case 'simple':
+ return joinedValues;
+ default:
+ return `${name}=${joinedValues}`;
+ }
+ }
+
+ const separator = separatorArrayExplode(style);
+ const joinedValues = value
+ .map((v) => {
+ if (style === 'label' || style === 'simple') {
+ return allowReserved ? v : encodeURIComponent(v as string);
+ }
+
+ return serializePrimitiveParam({
+ allowReserved,
+ name,
+ value: v as string,
+ });
+ })
+ .join(separator);
+ return style === 'label' || style === 'matrix'
+ ? separator + joinedValues
+ : joinedValues;
+};
+
+export const serializePrimitiveParam = ({
+ allowReserved,
+ name,
+ value,
+}: SerializePrimitiveParam) => {
+ if (value === undefined || value === null) {
+ return '';
+ }
+
+ if (typeof value === 'object') {
+ throw new Error(
+ 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.',
+ );
+ }
+
+ return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
+};
+
+export const serializeObjectParam = ({
+ allowReserved,
+ explode,
+ name,
+ style,
+ value,
+ valueOnly,
+}: SerializeOptions & {
+ value: Record | Date;
+ valueOnly?: boolean;
+}) => {
+ if (value instanceof Date) {
+ return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
+ }
+
+ if (style !== 'deepObject' && !explode) {
+ let values: string[] = [];
+ Object.entries(value).forEach(([key, v]) => {
+ values = [
+ ...values,
+ key,
+ allowReserved ? (v as string) : encodeURIComponent(v as string),
+ ];
+ });
+ const joinedValues = values.join(',');
+ switch (style) {
+ case 'form':
+ return `${name}=${joinedValues}`;
+ case 'label':
+ return `.${joinedValues}`;
+ case 'matrix':
+ return `;${name}=${joinedValues}`;
+ default:
+ return joinedValues;
+ }
+ }
+
+ const separator = separatorObjectExplode(style);
+ const joinedValues = Object.entries(value)
+ .map(([key, v]) =>
+ serializePrimitiveParam({
+ allowReserved,
+ name: style === 'deepObject' ? `${name}[${key}]` : key,
+ value: v as string,
+ }),
+ )
+ .join(separator);
+ return style === 'label' || style === 'matrix'
+ ? separator + joinedValues
+ : joinedValues;
+};
diff --git a/frontend/src/api/generated/core/queryKeySerializer.gen.ts b/frontend/src/api/generated/core/queryKeySerializer.gen.ts
new file mode 100644
index 00000000..d3bb6839
--- /dev/null
+++ b/frontend/src/api/generated/core/queryKeySerializer.gen.ts
@@ -0,0 +1,136 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+/**
+ * JSON-friendly union that mirrors what Pinia Colada can hash.
+ */
+export type JsonValue =
+ | null
+ | string
+ | number
+ | boolean
+ | JsonValue[]
+ | { [key: string]: JsonValue };
+
+/**
+ * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
+ */
+export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
+ if (
+ value === undefined ||
+ typeof value === 'function' ||
+ typeof value === 'symbol'
+ ) {
+ return undefined;
+ }
+ if (typeof value === 'bigint') {
+ return value.toString();
+ }
+ if (value instanceof Date) {
+ return value.toISOString();
+ }
+ return value;
+};
+
+/**
+ * Safely stringifies a value and parses it back into a JsonValue.
+ */
+export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
+ try {
+ const json = JSON.stringify(input, queryKeyJsonReplacer);
+ if (json === undefined) {
+ return undefined;
+ }
+ return JSON.parse(json) as JsonValue;
+ } catch {
+ return undefined;
+ }
+};
+
+/**
+ * Detects plain objects (including objects with a null prototype).
+ */
+const isPlainObject = (value: unknown): value is Record => {
+ if (value === null || typeof value !== 'object') {
+ return false;
+ }
+ const prototype = Object.getPrototypeOf(value as object);
+ return prototype === Object.prototype || prototype === null;
+};
+
+/**
+ * Turns URLSearchParams into a sorted JSON object for deterministic keys.
+ */
+const serializeSearchParams = (params: URLSearchParams): JsonValue => {
+ const entries = Array.from(params.entries()).sort(([a], [b]) =>
+ a.localeCompare(b),
+ );
+ const result: Record = {};
+
+ for (const [key, value] of entries) {
+ const existing = result[key];
+ if (existing === undefined) {
+ result[key] = value;
+ continue;
+ }
+
+ if (Array.isArray(existing)) {
+ (existing as string[]).push(value);
+ } else {
+ result[key] = [existing, value];
+ }
+ }
+
+ return result;
+};
+
+/**
+ * Normalizes any accepted value into a JSON-friendly shape for query keys.
+ */
+export const serializeQueryKeyValue = (
+ value: unknown,
+): JsonValue | undefined => {
+ if (value === null) {
+ return null;
+ }
+
+ if (
+ typeof value === 'string' ||
+ typeof value === 'number' ||
+ typeof value === 'boolean'
+ ) {
+ return value;
+ }
+
+ if (
+ value === undefined ||
+ typeof value === 'function' ||
+ typeof value === 'symbol'
+ ) {
+ return undefined;
+ }
+
+ if (typeof value === 'bigint') {
+ return value.toString();
+ }
+
+ if (value instanceof Date) {
+ return value.toISOString();
+ }
+
+ if (Array.isArray(value)) {
+ return stringifyToJsonValue(value);
+ }
+
+ if (
+ typeof URLSearchParams !== 'undefined' &&
+ value instanceof URLSearchParams
+ ) {
+ return serializeSearchParams(value);
+ }
+
+ if (isPlainObject(value)) {
+ return stringifyToJsonValue(value);
+ }
+
+ return undefined;
+};
diff --git a/frontend/src/api/generated/core/serverSentEvents.gen.ts b/frontend/src/api/generated/core/serverSentEvents.gen.ts
new file mode 100644
index 00000000..f8fd78e2
--- /dev/null
+++ b/frontend/src/api/generated/core/serverSentEvents.gen.ts
@@ -0,0 +1,264 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+import type { Config } from './types.gen';
+
+export type ServerSentEventsOptions = Omit<
+ RequestInit,
+ 'method'
+> &
+ Pick & {
+ /**
+ * Fetch API implementation. You can use this option to provide a custom
+ * fetch instance.
+ *
+ * @default globalThis.fetch
+ */
+ fetch?: typeof fetch;
+ /**
+ * Implementing clients can call request interceptors inside this hook.
+ */
+ onRequest?: (url: string, init: RequestInit) => Promise;
+ /**
+ * Callback invoked when a network or parsing error occurs during streaming.
+ *
+ * This option applies only if the endpoint returns a stream of events.
+ *
+ * @param error The error that occurred.
+ */
+ onSseError?: (error: unknown) => void;
+ /**
+ * Callback invoked when an event is streamed from the server.
+ *
+ * This option applies only if the endpoint returns a stream of events.
+ *
+ * @param event Event streamed from the server.
+ * @returns Nothing (void).
+ */
+ onSseEvent?: (event: StreamEvent) => void;
+ serializedBody?: RequestInit['body'];
+ /**
+ * Default retry delay in milliseconds.
+ *
+ * This option applies only if the endpoint returns a stream of events.
+ *
+ * @default 3000
+ */
+ sseDefaultRetryDelay?: number;
+ /**
+ * Maximum number of retry attempts before giving up.
+ */
+ sseMaxRetryAttempts?: number;
+ /**
+ * Maximum retry delay in milliseconds.
+ *
+ * Applies only when exponential backoff is used.
+ *
+ * This option applies only if the endpoint returns a stream of events.
+ *
+ * @default 30000
+ */
+ sseMaxRetryDelay?: number;
+ /**
+ * Optional sleep function for retry backoff.
+ *
+ * Defaults to using `setTimeout`.
+ */
+ sseSleepFn?: (ms: number) => Promise;
+ url: string;
+ };
+
+export interface StreamEvent {
+ data: TData;
+ event?: string;
+ id?: string;
+ retry?: number;
+}
+
+export type ServerSentEventsResult<
+ TData = unknown,
+ TReturn = void,
+ TNext = unknown,
+> = {
+ stream: AsyncGenerator<
+ TData extends Record ? TData[keyof TData] : TData,
+ TReturn,
+ TNext
+ >;
+};
+
+export const createSseClient = ({
+ onRequest,
+ onSseError,
+ onSseEvent,
+ responseTransformer,
+ responseValidator,
+ sseDefaultRetryDelay,
+ sseMaxRetryAttempts,
+ sseMaxRetryDelay,
+ sseSleepFn,
+ url,
+ ...options
+}: ServerSentEventsOptions): ServerSentEventsResult => {
+ let lastEventId: string | undefined;
+
+ const sleep =
+ sseSleepFn ??
+ ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
+
+ const createStream = async function* () {
+ let retryDelay: number = sseDefaultRetryDelay ?? 3000;
+ let attempt = 0;
+ const signal = options.signal ?? new AbortController().signal;
+
+ while (true) {
+ if (signal.aborted) break;
+
+ attempt++;
+
+ const headers =
+ options.headers instanceof Headers
+ ? options.headers
+ : new Headers(options.headers as Record | undefined);
+
+ if (lastEventId !== undefined) {
+ headers.set('Last-Event-ID', lastEventId);
+ }
+
+ try {
+ const requestInit: RequestInit = {
+ redirect: 'follow',
+ ...options,
+ body: options.serializedBody,
+ headers,
+ signal,
+ };
+ let request = new Request(url, requestInit);
+ if (onRequest) {
+ request = await onRequest(url, requestInit);
+ }
+ // fetch must be assigned here, otherwise it would throw the error:
+ // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
+ const _fetch = options.fetch ?? globalThis.fetch;
+ const response = await _fetch(request);
+
+ if (!response.ok)
+ throw new Error(
+ `SSE failed: ${response.status} ${response.statusText}`,
+ );
+
+ if (!response.body) throw new Error('No body in SSE response');
+
+ const reader = response.body
+ .pipeThrough(new TextDecoderStream())
+ .getReader();
+
+ let buffer = '';
+
+ const abortHandler = () => {
+ try {
+ reader.cancel();
+ } catch {
+ // noop
+ }
+ };
+
+ signal.addEventListener('abort', abortHandler);
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += value;
+
+ const chunks = buffer.split('\n\n');
+ buffer = chunks.pop() ?? '';
+
+ for (const chunk of chunks) {
+ const lines = chunk.split('\n');
+ const dataLines: Array = [];
+ let eventName: string | undefined;
+
+ for (const line of lines) {
+ if (line.startsWith('data:')) {
+ dataLines.push(line.replace(/^data:\s*/, ''));
+ } else if (line.startsWith('event:')) {
+ eventName = line.replace(/^event:\s*/, '');
+ } else if (line.startsWith('id:')) {
+ lastEventId = line.replace(/^id:\s*/, '');
+ } else if (line.startsWith('retry:')) {
+ const parsed = Number.parseInt(
+ line.replace(/^retry:\s*/, ''),
+ 10,
+ );
+ if (!Number.isNaN(parsed)) {
+ retryDelay = parsed;
+ }
+ }
+ }
+
+ let data: unknown;
+ let parsedJson = false;
+
+ if (dataLines.length) {
+ const rawData = dataLines.join('\n');
+ try {
+ data = JSON.parse(rawData);
+ parsedJson = true;
+ } catch {
+ data = rawData;
+ }
+ }
+
+ if (parsedJson) {
+ if (responseValidator) {
+ await responseValidator(data);
+ }
+
+ if (responseTransformer) {
+ data = await responseTransformer(data);
+ }
+ }
+
+ onSseEvent?.({
+ data,
+ event: eventName,
+ id: lastEventId,
+ retry: retryDelay,
+ });
+
+ if (dataLines.length) {
+ yield data as any;
+ }
+ }
+ }
+ } finally {
+ signal.removeEventListener('abort', abortHandler);
+ reader.releaseLock();
+ }
+
+ break; // exit loop on normal completion
+ } catch (error) {
+ // connection failed or aborted; retry after delay
+ onSseError?.(error);
+
+ if (
+ sseMaxRetryAttempts !== undefined &&
+ attempt >= sseMaxRetryAttempts
+ ) {
+ break; // stop after firing error
+ }
+
+ // exponential backoff: double retry each attempt, cap at 30s
+ const backoff = Math.min(
+ retryDelay * 2 ** (attempt - 1),
+ sseMaxRetryDelay ?? 30000,
+ );
+ await sleep(backoff);
+ }
+ }
+ };
+
+ const stream = createStream();
+
+ return { stream };
+};
diff --git a/frontend/src/api/generated/core/types.gen.ts b/frontend/src/api/generated/core/types.gen.ts
new file mode 100644
index 00000000..643c070c
--- /dev/null
+++ b/frontend/src/api/generated/core/types.gen.ts
@@ -0,0 +1,118 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+import type { Auth, AuthToken } from './auth.gen';
+import type {
+ BodySerializer,
+ QuerySerializer,
+ QuerySerializerOptions,
+} from './bodySerializer.gen';
+
+export type HttpMethod =
+ | 'connect'
+ | 'delete'
+ | 'get'
+ | 'head'
+ | 'options'
+ | 'patch'
+ | 'post'
+ | 'put'
+ | 'trace';
+
+export type Client<
+ RequestFn = never,
+ Config = unknown,
+ MethodFn = never,
+ BuildUrlFn = never,
+ SseFn = never,
+> = {
+ /**
+ * Returns the final request URL.
+ */
+ buildUrl: BuildUrlFn;
+ getConfig: () => Config;
+ request: RequestFn;
+ setConfig: (config: Config) => Config;
+} & {
+ [K in HttpMethod]: MethodFn;
+} & ([SseFn] extends [never]
+ ? { sse?: never }
+ : { sse: { [K in HttpMethod]: SseFn } });
+
+export interface Config {
+ /**
+ * Auth token or a function returning auth token. The resolved value will be
+ * added to the request payload as defined by its `security` array.
+ */
+ auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken;
+ /**
+ * A function for serializing request body parameter. By default,
+ * {@link JSON.stringify()} will be used.
+ */
+ bodySerializer?: BodySerializer | null;
+ /**
+ * An object containing any HTTP headers that you want to pre-populate your
+ * `Headers` object with.
+ *
+ * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
+ */
+ headers?:
+ | RequestInit['headers']
+ | Record<
+ string,
+ | string
+ | number
+ | boolean
+ | (string | number | boolean)[]
+ | null
+ | undefined
+ | unknown
+ >;
+ /**
+ * The request method.
+ *
+ * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
+ */
+ method?: Uppercase;
+ /**
+ * A function for serializing request query parameters. By default, arrays
+ * will be exploded in form style, objects will be exploded in deepObject
+ * style, and reserved characters are percent-encoded.
+ *
+ * This method will have no effect if the native `paramsSerializer()` Axios
+ * API function is used.
+ *
+ * {@link https://swagger.io/docs/specification/serialization/#query View examples}
+ */
+ querySerializer?: QuerySerializer | QuerySerializerOptions;
+ /**
+ * A function validating request data. This is useful if you want to ensure
+ * the request conforms to the desired shape, so it can be safely sent to
+ * the server.
+ */
+ requestValidator?: (data: unknown) => Promise;
+ /**
+ * A function transforming response data before it's returned. This is useful
+ * for post-processing data, e.g. converting ISO strings into Date objects.
+ */
+ responseTransformer?: (data: unknown) => Promise;
+ /**
+ * A function validating response data. This is useful if you want to ensure
+ * the response conforms to the desired shape, so it can be safely passed to
+ * the transformers and returned to the user.
+ */
+ responseValidator?: (data: unknown) => Promise;
+}
+
+type IsExactlyNeverOrNeverUndefined = [T] extends [never]
+ ? true
+ : [T] extends [never | undefined]
+ ? [undefined] extends [T]
+ ? false
+ : true
+ : false;
+
+export type OmitNever> = {
+ [K in keyof T as IsExactlyNeverOrNeverUndefined extends true
+ ? never
+ : K]: T[K];
+};
diff --git a/frontend/src/api/generated/core/utils.gen.ts b/frontend/src/api/generated/core/utils.gen.ts
new file mode 100644
index 00000000..0b5389d0
--- /dev/null
+++ b/frontend/src/api/generated/core/utils.gen.ts
@@ -0,0 +1,143 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
+import {
+ type ArraySeparatorStyle,
+ serializeArrayParam,
+ serializeObjectParam,
+ serializePrimitiveParam,
+} from './pathSerializer.gen';
+
+export interface PathSerializer {
+ path: Record;
+ url: string;
+}
+
+export const PATH_PARAM_RE = /\{[^{}]+\}/g;
+
+export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
+ let url = _url;
+ const matches = _url.match(PATH_PARAM_RE);
+ if (matches) {
+ for (const match of matches) {
+ let explode = false;
+ let name = match.substring(1, match.length - 1);
+ let style: ArraySeparatorStyle = 'simple';
+
+ if (name.endsWith('*')) {
+ explode = true;
+ name = name.substring(0, name.length - 1);
+ }
+
+ if (name.startsWith('.')) {
+ name = name.substring(1);
+ style = 'label';
+ } else if (name.startsWith(';')) {
+ name = name.substring(1);
+ style = 'matrix';
+ }
+
+ const value = path[name];
+
+ if (value === undefined || value === null) {
+ continue;
+ }
+
+ if (Array.isArray(value)) {
+ url = url.replace(
+ match,
+ serializeArrayParam({ explode, name, style, value }),
+ );
+ continue;
+ }
+
+ if (typeof value === 'object') {
+ url = url.replace(
+ match,
+ serializeObjectParam({
+ explode,
+ name,
+ style,
+ value: value as Record,
+ valueOnly: true,
+ }),
+ );
+ continue;
+ }
+
+ if (style === 'matrix') {
+ url = url.replace(
+ match,
+ `;${serializePrimitiveParam({
+ name,
+ value: value as string,
+ })}`,
+ );
+ continue;
+ }
+
+ const replaceValue = encodeURIComponent(
+ style === 'label' ? `.${value as string}` : (value as string),
+ );
+ url = url.replace(match, replaceValue);
+ }
+ }
+ return url;
+};
+
+export const getUrl = ({
+ baseUrl,
+ path,
+ query,
+ querySerializer,
+ url: _url,
+}: {
+ baseUrl?: string;
+ path?: Record;
+ query?: Record;
+ querySerializer: QuerySerializer;
+ url: string;
+}) => {
+ const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
+ let url = (baseUrl ?? '') + pathUrl;
+ if (path) {
+ url = defaultPathSerializer({ path, url });
+ }
+ let search = query ? querySerializer(query) : '';
+ if (search.startsWith('?')) {
+ search = search.substring(1);
+ }
+ if (search) {
+ url += `?${search}`;
+ }
+ return url;
+};
+
+export function getValidRequestBody(options: {
+ body?: unknown;
+ bodySerializer?: BodySerializer | null;
+ serializedBody?: unknown;
+}) {
+ const hasBody = options.body !== undefined;
+ const isSerializedBody = hasBody && options.bodySerializer;
+
+ if (isSerializedBody) {
+ if ('serializedBody' in options) {
+ const hasSerializedBody =
+ options.serializedBody !== undefined && options.serializedBody !== '';
+
+ return hasSerializedBody ? options.serializedBody : null;
+ }
+
+ // not all clients implement a serializedBody property (i.e. client-axios)
+ return options.body !== '' ? options.body : null;
+ }
+
+ // plain/text body
+ if (hasBody) {
+ return options.body;
+ }
+
+ // no body was provided
+ return undefined;
+}
diff --git a/frontend/src/api/generated/index.ts b/frontend/src/api/generated/index.ts
new file mode 100644
index 00000000..c352c104
--- /dev/null
+++ b/frontend/src/api/generated/index.ts
@@ -0,0 +1,4 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+export type * from './types.gen';
+export * from './sdk.gen';
diff --git a/frontend/src/api/generated/sdk.gen.ts b/frontend/src/api/generated/sdk.gen.ts
new file mode 100644
index 00000000..9aacce77
--- /dev/null
+++ b/frontend/src/api/generated/sdk.gen.ts
@@ -0,0 +1,1619 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+import type { Client, Options as Options2, TDataShape } from './client';
+import { client } from './client.gen';
+import type { AddPluginInstanceApiPluginsAddPostData, AddPluginInstanceApiPluginsAddPostErrors, AddPluginInstanceApiPluginsAddPostResponses, AddTaskApiDispatchStartPostData, AddTaskApiDispatchStartPostErrors, AddTaskApiDispatchStartPostResponses, CancelPowerTaskApiDispatchCancelPowerPostData, CancelPowerTaskApiDispatchCancelPowerPostResponses, CheckImageAllApiOcrCheckImageAllPostData, CheckImageAllApiOcrCheckImageAllPostErrors, CheckImageAllApiOcrCheckImageAllPostResponses, CheckImageAnyApiOcrCheckImageAnyPostData, CheckImageAnyApiOcrCheckImageAnyPostErrors, CheckImageAnyApiOcrCheckImageAnyPostResponses, CheckImageApiOcrCheckImagePostData, CheckImageApiOcrCheckImagePostErrors, CheckImageApiOcrCheckImagePostResponses, CheckUpdateApiUpdateCheckPostData, CheckUpdateApiUpdateCheckPostErrors, CheckUpdateApiUpdateCheckPostResponses, CheckUpdateRestApiUpdateCheckGetData, CheckUpdateRestApiUpdateCheckGetErrors, CheckUpdateRestApiUpdateCheckGetResponses, ClearHistoryApiWsDebugHistoryClearPostData, ClearHistoryApiWsDebugHistoryClearPostErrors, ClearHistoryApiWsDebugHistoryClearPostResponses, ClickImageApiOcrClickImagePostData, ClickImageApiOcrClickImagePostErrors, ClickImageApiOcrClickImagePostResponses, ClickTextApiOcrClickTextPostData, ClickTextApiOcrClickTextPostErrors, ClickTextApiOcrClickTextPostResponses, CloseApiCoreClosePostData, CloseApiCoreClosePostResponses, ConfirmNoticeApiInfoNoticeConfirmPostData, ConfirmNoticeApiInfoNoticeConfirmPostResponses, ConnectClientApiWsDebugClientConnectPostData, ConnectClientApiWsDebugClientConnectPostErrors, ConnectClientApiWsDebugClientConnectPostResponses, CreateClientApiWsDebugClientCreatePostData, CreateClientApiWsDebugClientCreatePostErrors, CreateClientApiWsDebugClientCreatePostResponses, CreateEmulatorApiEmulatorPostData, CreateEmulatorApiEmulatorPostResponses, CreatePlanApiPlanPostData, CreatePlanApiPlanPostErrors, CreatePlanApiPlanPostResponses, CreateQueueApiQueuePostData, CreateQueueApiQueuePostResponses, CreateQueueItemApiQueueQueueIdItemsPostData, CreateQueueItemApiQueueQueueIdItemsPostErrors, CreateQueueItemApiQueueQueueIdItemsPostResponses, CreateScriptApiScriptsPostData, CreateScriptApiScriptsPostErrors, CreateScriptApiScriptsPostResponses, CreateTimeSetApiQueueQueueIdTimesPostData, CreateTimeSetApiQueueQueueIdTimesPostErrors, CreateTimeSetApiQueueQueueIdTimesPostResponses, CreateUserApiScriptsScriptIdUsersPostData, CreateUserApiScriptsScriptIdUsersPostErrors, CreateUserApiScriptsScriptIdUsersPostResponses, CreateUserWebhookApiScriptsScriptIdUsersUserIdWebhooksPostData, CreateUserWebhookApiScriptsScriptIdUsersUserIdWebhooksPostErrors, CreateUserWebhookApiScriptsScriptIdUsersUserIdWebhooksPostResponses, CreateWebhookApiSettingWebhooksPostData, CreateWebhookApiSettingWebhooksPostResponses, DeleteEmulatorApiEmulatorEmulatorIdDeleteData, DeleteEmulatorApiEmulatorEmulatorIdDeleteErrors, DeleteEmulatorApiEmulatorEmulatorIdDeleteResponses, DeletePlanApiPlanPlanIdDeleteData, DeletePlanApiPlanPlanIdDeleteErrors, DeletePlanApiPlanPlanIdDeleteResponses, DeletePluginInstanceApiPluginsDeletePostData, DeletePluginInstanceApiPluginsDeletePostErrors, DeletePluginInstanceApiPluginsDeletePostResponses, DeleteQueueApiQueueQueueIdDeleteData, DeleteQueueApiQueueQueueIdDeleteErrors, DeleteQueueApiQueueQueueIdDeleteResponses, DeleteQueueItemApiQueueQueueIdItemsQueueItemIdDeleteData, DeleteQueueItemApiQueueQueueIdItemsQueueItemIdDeleteErrors, DeleteQueueItemApiQueueQueueIdItemsQueueItemIdDeleteResponses, DeleteScriptApiScriptsScriptIdDeleteData, DeleteScriptApiScriptsScriptIdDeleteErrors, DeleteScriptApiScriptsScriptIdDeleteResponses, DeleteTimeSetApiQueueQueueIdTimesTimeSetIdDeleteData, DeleteTimeSetApiQueueQueueIdTimesTimeSetIdDeleteErrors, DeleteTimeSetApiQueueQueueIdTimesTimeSetIdDeleteResponses, DeleteUserApiScriptsScriptIdUsersUserIdDeleteData, DeleteUserApiScriptsScriptIdUsersUserIdDeleteErrors, DeleteUserApiScriptsScriptIdUsersUserIdDeleteResponses, DeleteUserWebhookApiScriptsScriptIdUsersUserIdWebhooksWebhookIdDeleteData, DeleteUserWebhookApiScriptsScriptIdUsersUserIdWebhooksWebhookIdDeleteErrors, DeleteUserWebhookApiScriptsScriptIdUsersUserIdWebhooksWebhookIdDeleteResponses, DeleteWebhookApiSettingWebhooksWebhookIdDeleteData, DeleteWebhookApiSettingWebhooksWebhookIdDeleteErrors, DeleteWebhookApiSettingWebhooksWebhookIdDeleteResponses, DetectEmulatorsApiEmulatorDetectedGetData, DetectEmulatorsApiEmulatorDetectedGetResponses, DisconnectClientApiWsDebugClientDisconnectPostData, DisconnectClientApiWsDebugClientDisconnectPostErrors, DisconnectClientApiWsDebugClientDisconnectPostResponses, DownloadUpdateApiUpdateDownloadPostData, DownloadUpdateApiUpdateDownloadPostResponses, ExportScriptToFileApiScriptsScriptIdActionsExportFilePostData, ExportScriptToFileApiScriptsScriptIdActionsExportFilePostErrors, ExportScriptToFileApiScriptsScriptIdActionsExportFilePostResponses, GetClientStatusApiWsDebugClientStatusPostData, GetClientStatusApiWsDebugClientStatusPostErrors, GetClientStatusApiWsDebugClientStatusPostResponses, GetCommandsApiWsDebugCommandsGetData, GetCommandsApiWsDebugCommandsGetResponses, GetEmulatorApiEmulatorEmulatorIdGetData, GetEmulatorApiEmulatorEmulatorIdGetErrors, GetEmulatorApiEmulatorEmulatorIdGetResponses, GetEmulatorComboxApiInfoComboxEmulatorPostData, GetEmulatorComboxApiInfoComboxEmulatorPostResponses, GetEmulatorDevicesComboxApiInfoComboxEmulatorDevicesPostData, GetEmulatorDevicesComboxApiInfoComboxEmulatorDevicesPostErrors, GetEmulatorDevicesComboxApiInfoComboxEmulatorDevicesPostResponses, GetEmulatorStatusApiEmulatorEmulatorIdStatusGetData, GetEmulatorStatusApiEmulatorEmulatorIdStatusGetErrors, GetEmulatorStatusApiEmulatorEmulatorIdStatusGetResponses, GetEmulatorStatusesApiEmulatorStatusGetData, GetEmulatorStatusesApiEmulatorStatusGetResponses, GetGitVersionApiInfoVersionPostData, GetGitVersionApiInfoVersionPostResponses, GetHistoryApiWsDebugHistoryGetData, GetHistoryApiWsDebugHistoryGetErrors, GetHistoryApiWsDebugHistoryGetResponses, GetHistoryDataApiHistoryDataPostData, GetHistoryDataApiHistoryDataPostErrors, GetHistoryDataApiHistoryDataPostResponses, GetNoticeInfoApiInfoNoticeGetPostData, GetNoticeInfoApiInfoNoticeGetPostResponses, GetOverviewApiInfoGetOverviewPostData, GetOverviewApiInfoGetOverviewPostResponses, GetPlanApiPlanPlanIdGetData, GetPlanApiPlanPlanIdGetErrors, GetPlanApiPlanPlanIdGetResponses, GetPlanComboxApiInfoComboxPlanPostData, GetPlanComboxApiInfoComboxPlanPostResponses, GetPluginsApiPluginsGetPostData, GetPluginsApiPluginsGetPostResponses, GetPowerApiDispatchGetPowerPostData, GetPowerApiDispatchGetPowerPostResponses, GetQueueApiQueueQueueIdGetData, GetQueueApiQueueQueueIdGetErrors, GetQueueApiQueueQueueIdGetResponses, GetQueueItemApiQueueQueueIdItemsQueueItemIdGetData, GetQueueItemApiQueueQueueIdItemsQueueItemIdGetErrors, GetQueueItemApiQueueQueueIdItemsQueueItemIdGetResponses, GetScreenshotAdbApiOcrScreenshotAdbPostData, GetScreenshotAdbApiOcrScreenshotAdbPostErrors, GetScreenshotAdbApiOcrScreenshotAdbPostResponses, GetScreenshotApiOcrScreenshotPostData, GetScreenshotApiOcrScreenshotPostErrors, GetScreenshotApiOcrScreenshotPostResponses, GetScriptApiScriptsScriptIdGetData, GetScriptApiScriptsScriptIdGetErrors, GetScriptApiScriptsScriptIdGetResponses, GetScriptComboxApiInfoComboxScriptPostData, GetScriptComboxApiInfoComboxScriptPostResponses, GetSettingApiSettingGetData, GetSettingApiSettingGetResponses, GetStageComboxApiInfoComboxStagePostData, GetStageComboxApiInfoComboxStagePostErrors, GetStageComboxApiInfoComboxStagePostResponses, GetTaskComboxApiInfoComboxTaskPostData, GetTaskComboxApiInfoComboxTaskPostResponses, GetTimeSetApiQueueQueueIdTimesTimeSetIdGetData, GetTimeSetApiQueueQueueIdTimesTimeSetIdGetErrors, GetTimeSetApiQueueQueueIdTimesTimeSetIdGetResponses, GetToolsApiToolsGetData, GetToolsApiToolsGetResponses, GetUserApiScriptsScriptIdUsersUserIdGetData, GetUserApiScriptsScriptIdUsersUserIdGetErrors, GetUserApiScriptsScriptIdUsersUserIdGetResponses, GetUserInfrastructureOptionsApiScriptsScriptIdUsersUserIdInfrastructureOptionsGetData, GetUserInfrastructureOptionsApiScriptsScriptIdUsersUserIdInfrastructureOptionsGetErrors, GetUserInfrastructureOptionsApiScriptsScriptIdUsersUserIdInfrastructureOptionsGetResponses, GetUserWebhookApiScriptsScriptIdUsersUserIdWebhooksWebhookIdGetData, GetUserWebhookApiScriptsScriptIdUsersUserIdWebhooksWebhookIdGetErrors, GetUserWebhookApiScriptsScriptIdUsersUserIdWebhooksWebhookIdGetResponses, GetWebConfigApiInfoWebconfigPostData, GetWebConfigApiInfoWebconfigPostResponses, GetWebhookApiSettingWebhooksWebhookIdGetData, GetWebhookApiSettingWebhooksWebhookIdGetErrors, GetWebhookApiSettingWebhooksWebhookIdGetResponses, ImportInfrastructureApiScriptsScriptIdUsersUserIdActionsImportInfrastructurePostData, ImportInfrastructureApiScriptsScriptIdUsersUserIdActionsImportInfrastructurePostErrors, ImportInfrastructureApiScriptsScriptIdUsersUserIdActionsImportInfrastructurePostResponses, ImportScriptFromFileApiScriptsScriptIdActionsImportFilePostData, ImportScriptFromFileApiScriptsScriptIdActionsImportFilePostErrors, ImportScriptFromFileApiScriptsScriptIdActionsImportFilePostResponses, ImportScriptFromWebApiScriptsScriptIdActionsImportWebPostData, ImportScriptFromWebApiScriptsScriptIdActionsImportWebPostErrors, ImportScriptFromWebApiScriptsScriptIdActionsImportWebPostResponses, InstallPluginPackageApiPluginsInstallPackagePostData, InstallPluginPackageApiPluginsInstallPackagePostErrors, InstallPluginPackageApiPluginsInstallPackagePostResponses, InstallUpdateApiUpdateInstallPostData, InstallUpdateApiUpdateInstallPostResponses, ListClientsApiWsDebugClientListGetData, ListClientsApiWsDebugClientListGetResponses, ListEmulatorsApiEmulatorGetData, ListEmulatorsApiEmulatorGetResponses, ListPlansApiPlanGetData, ListPlansApiPlanGetResponses, ListQueueItemsApiQueueQueueIdItemsGetData, ListQueueItemsApiQueueQueueIdItemsGetErrors, ListQueueItemsApiQueueQueueIdItemsGetResponses, ListQueuesApiQueueGetData, ListQueuesApiQueueGetResponses, ListScriptsApiScriptsGetData, ListScriptsApiScriptsGetResponses, ListTimeSetsApiQueueQueueIdTimesGetData, ListTimeSetsApiQueueQueueIdTimesGetErrors, ListTimeSetsApiQueueQueueIdTimesGetResponses, ListUsersApiScriptsScriptIdUsersGetData, ListUsersApiScriptsScriptIdUsersGetErrors, ListUsersApiScriptsScriptIdUsersGetResponses, ListUserWebhooksApiScriptsScriptIdUsersUserIdWebhooksGetData, ListUserWebhooksApiScriptsScriptIdUsersUserIdWebhooksGetErrors, ListUserWebhooksApiScriptsScriptIdUsersUserIdWebhooksGetResponses, ListWebhooksApiSettingWebhooksGetData, ListWebhooksApiSettingWebhooksGetResponses, OperateEmulatorApiEmulatorEmulatorIdActionsActionPostData, OperateEmulatorApiEmulatorEmulatorIdActionsActionPostErrors, OperateEmulatorApiEmulatorEmulatorIdActionsActionPostResponses, RebuildPluginCtxStubApiPluginsDevRebuildCtxStubPostData, RebuildPluginCtxStubApiPluginsDevRebuildCtxStubPostErrors, RebuildPluginCtxStubApiPluginsDevRebuildCtxStubPostResponses, ReloadPluginByNameApiPluginsReloadPluginPostData, ReloadPluginByNameApiPluginsReloadPluginPostErrors, ReloadPluginByNameApiPluginsReloadPluginPostResponses, ReloadPluginInstanceApiPluginsReloadInstancePostData, ReloadPluginInstanceApiPluginsReloadInstancePostErrors, ReloadPluginInstanceApiPluginsReloadInstancePostResponses, ReloadPluginsApiPluginsReloadPostData, ReloadPluginsApiPluginsReloadPostResponses, RemoveClientApiWsDebugClientRemovePostData, RemoveClientApiWsDebugClientRemovePostErrors, RemoveClientApiWsDebugClientRemovePostResponses, ReorderEmulatorApiEmulatorOrderPatchData, ReorderEmulatorApiEmulatorOrderPatchErrors, ReorderEmulatorApiEmulatorOrderPatchResponses, ReorderPlanApiPlanOrderPatchData, ReorderPlanApiPlanOrderPatchErrors, ReorderPlanApiPlanOrderPatchResponses, ReorderQueueApiQueueOrderPatchData, ReorderQueueApiQueueOrderPatchErrors, ReorderQueueApiQueueOrderPatchResponses, ReorderQueueItemsApiQueueQueueIdItemsOrderPatchData, ReorderQueueItemsApiQueueQueueIdItemsOrderPatchErrors, ReorderQueueItemsApiQueueQueueIdItemsOrderPatchResponses, ReorderScriptsApiScriptsOrderPatchData, ReorderScriptsApiScriptsOrderPatchErrors, ReorderScriptsApiScriptsOrderPatchResponses, ReorderTimeSetsApiQueueQueueIdTimesOrderPatchData, ReorderTimeSetsApiQueueQueueIdTimesOrderPatchErrors, ReorderTimeSetsApiQueueQueueIdTimesOrderPatchResponses, ReorderUsersApiScriptsScriptIdUsersOrderPatchData, ReorderUsersApiScriptsScriptIdUsersOrderPatchErrors, ReorderUsersApiScriptsScriptIdUsersOrderPatchResponses, ReorderUserWebhooksApiScriptsScriptIdUsersUserIdWebhooksOrderPatchData, ReorderUserWebhooksApiScriptsScriptIdUsersUserIdWebhooksOrderPatchErrors, ReorderUserWebhooksApiScriptsScriptIdUsersUserIdWebhooksOrderPatchResponses, ReorderWebhooksApiSettingWebhooksOrderPatchData, ReorderWebhooksApiSettingWebhooksOrderPatchErrors, ReorderWebhooksApiSettingWebhooksOrderPatchResponses, SearchHistoryApiHistorySearchPostData, SearchHistoryApiHistorySearchPostErrors, SearchHistoryApiHistorySearchPostResponses, SendAuthApiWsDebugMessageAuthPostData, SendAuthApiWsDebugMessageAuthPostErrors, SendAuthApiWsDebugMessageAuthPostResponses, SendJsonMessageApiWsDebugMessageSendJsonPostData, SendJsonMessageApiWsDebugMessageSendJsonPostErrors, SendJsonMessageApiWsDebugMessageSendJsonPostResponses, SendMessageApiWsDebugMessageSendPostData, SendMessageApiWsDebugMessageSendPostErrors, SendMessageApiWsDebugMessageSendPostResponses, SetPowerApiDispatchSetPowerPostData, SetPowerApiDispatchSetPowerPostErrors, SetPowerApiDispatchSetPowerPostResponses, StopTaskApiDispatchStopPostData, StopTaskApiDispatchStopPostErrors, StopTaskApiDispatchStopPostResponses, TestNotifyApiSettingActionsTestNotifyPostData, TestNotifyApiSettingActionsTestNotifyPostResponses, TestWebhookApiSettingWebhooksTestPostData, TestWebhookApiSettingWebhooksTestPostErrors, TestWebhookApiSettingWebhooksTestPostResponses, UninstallPluginPackageApiPluginsUninstallPackagePostData, UninstallPluginPackageApiPluginsUninstallPackagePostErrors, UninstallPluginPackageApiPluginsUninstallPackagePostResponses, UpdateEmulatorApiEmulatorEmulatorIdPatchData, UpdateEmulatorApiEmulatorEmulatorIdPatchErrors, UpdateEmulatorApiEmulatorEmulatorIdPatchResponses, UpdatePlanApiPlanPlanIdPatchData, UpdatePlanApiPlanPlanIdPatchErrors, UpdatePlanApiPlanPlanIdPatchResponses, UpdatePluginInstanceApiPluginsUpdatePostData, UpdatePluginInstanceApiPluginsUpdatePostErrors, UpdatePluginInstanceApiPluginsUpdatePostResponses, UpdateQueueApiQueueQueueIdPatchData, UpdateQueueApiQueueQueueIdPatchErrors, UpdateQueueApiQueueQueueIdPatchResponses, UpdateQueueItemApiQueueQueueIdItemsQueueItemIdPatchData, UpdateQueueItemApiQueueQueueIdItemsQueueItemIdPatchErrors, UpdateQueueItemApiQueueQueueIdItemsQueueItemIdPatchResponses, UpdateScriptApiScriptsScriptIdPatchData, UpdateScriptApiScriptsScriptIdPatchErrors, UpdateScriptApiScriptsScriptIdPatchResponses, UpdateSettingApiSettingPatchData, UpdateSettingApiSettingPatchErrors, UpdateSettingApiSettingPatchResponses, UpdateTimeSetApiQueueQueueIdTimesTimeSetIdPatchData, UpdateTimeSetApiQueueQueueIdTimesTimeSetIdPatchErrors, UpdateTimeSetApiQueueQueueIdTimesTimeSetIdPatchResponses, UpdateToolsApiToolsPatchData, UpdateToolsApiToolsPatchErrors, UpdateToolsApiToolsPatchResponses, UpdateUserApiScriptsScriptIdUsersUserIdPatchData, UpdateUserApiScriptsScriptIdUsersUserIdPatchErrors, UpdateUserApiScriptsScriptIdUsersUserIdPatchResponses, UpdateUserWebhookApiScriptsScriptIdUsersUserIdWebhooksWebhookIdPatchData, UpdateUserWebhookApiScriptsScriptIdUsersUserIdWebhooksWebhookIdPatchErrors, UpdateUserWebhookApiScriptsScriptIdUsersUserIdWebhooksWebhookIdPatchResponses, UpdateWebhookApiSettingWebhooksWebhookIdPatchData, UpdateWebhookApiSettingWebhooksWebhookIdPatchErrors, UpdateWebhookApiSettingWebhooksWebhookIdPatchResponses, UploadScriptToWebApiScriptsScriptIdActionsUploadWebPostData, UploadScriptToWebApiScriptsScriptIdActionsUploadWebPostErrors, UploadScriptToWebApiScriptsScriptIdActionsUploadWebPostResponses } from './types.gen';
+
+export type Options = Options2 & {
+ /**
+ * You can provide a client instance returned by `createClient()` instead of
+ * individual options. This might be also useful if you want to implement a
+ * custom client.
+ */
+ client?: Client;
+ /**
+ * You can pass arbitrary values through the `meta` object. This can be
+ * used to access values that aren't defined as part of the SDK function.
+ */
+ meta?: Record;
+};
+
+/**
+ * 关闭后端程序
+ *
+ * 关闭后端程序
+ */
+export const closeApiCoreClosePost = (options?: Options) => {
+ return (options?.client ?? client).post({
+ url: '/api/core/close',
+ ...options
+ });
+};
+
+/**
+ * 获取后端git版本信息
+ */
+export const getGitVersionApiInfoVersionPost = (options?: Options) => {
+ return (options?.client ?? client).post({
+ url: '/api/info/version',
+ ...options
+ });
+};
+
+/**
+ * 获取关卡号下拉框信息
+ */
+export const getStageComboxApiInfoComboxStagePost = (options: Options) => {
+ return (options.client ?? client).post({
+ url: '/api/info/combox/stage',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers
+ }
+ });
+};
+
+/**
+ * 获取脚本下拉框信息
+ */
+export const getScriptComboxApiInfoComboxScriptPost = (options?: Options) => {
+ return (options?.client ?? client).post({
+ url: '/api/info/combox/script',
+ ...options
+ });
+};
+
+/**
+ * 获取可选任务下拉框信息
+ */
+export const getTaskComboxApiInfoComboxTaskPost = (options?: Options) => {
+ return (options?.client ?? client).post({
+ url: '/api/info/combox/task',
+ ...options
+ });
+};
+
+/**
+ * 获取可选计划下拉框信息
+ */
+export const getPlanComboxApiInfoComboxPlanPost = (options?: Options) => {
+ return (options?.client ?? client).post({
+ url: '/api/info/combox/plan',
+ ...options
+ });
+};
+
+/**
+ * 获取可选模拟器下拉框信息
+ */
+export const getEmulatorComboxApiInfoComboxEmulatorPost = (options?: Options) => {
+ return (options?.client ?? client).post({
+ url: '/api/info/combox/emulator',
+ ...options
+ });
+};
+
+/**
+ * 获取可选模拟器多开实例下拉框信息
+ */
+export const getEmulatorDevicesComboxApiInfoComboxEmulatorDevicesPost = (options: Options) => {
+ return (options.client ?? client).post({
+ url: '/api/info/combox/emulator/devices',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers
+ }
+ });
+};
+
+/**
+ * 获取通知信息
+ */
+export const getNoticeInfoApiInfoNoticeGetPost = (options?: Options) => {
+ return (options?.client ?? client).post({
+ url: '/api/info/notice/get',
+ ...options
+ });
+};
+
+/**
+ * 确认通知
+ */
+export const confirmNoticeApiInfoNoticeConfirmPost = (options?: Options) => {
+ return (options?.client ?? client).post({
+ url: '/api/info/notice/confirm',
+ ...options
+ });
+};
+
+/**
+ * 获取配置分享中心的配置信息
+ */
+export const getWebConfigApiInfoWebconfigPost = (options?: Options) => {
+ return (options?.client ?? client).post({
+ url: '/api/info/webconfig',
+ ...options
+ });
+};
+
+/**
+ * 信息总览
+ */
+export const getOverviewApiInfoGetOverviewPost = (options?: Options) => {
+ return (options?.client ?? client).post({
+ url: '/api/info/get/overview',
+ ...options
+ });
+};
+
+/**
+ * 查询全部脚本
+ */
+export const listScriptsApiScriptsGet = (options?: Options) => {
+ return (options?.client ?? client).get({
+ url: '/api/scripts',
+ ...options
+ });
+};
+
+/**
+ * 创建脚本
+ */
+export const createScriptApiScriptsPost = (options: Options) => {
+ return (options.client ?? client).post({
+ url: '/api/scripts',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers
+ }
+ });
+};
+
+/**
+ * 重新排序脚本
+ */
+export const reorderScriptsApiScriptsOrderPatch = (options: Options) => {
+ return (options.client ?? client).patch({
+ url: '/api/scripts/order',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers
+ }
+ });
+};
+
+/**
+ * 删除脚本
+ */
+export const deleteScriptApiScriptsScriptIdDelete = (options: Options) => {
+ return (options.client ?? client).delete({
+ url: '/api/scripts/{script_id}',
+ ...options
+ });
+};
+
+/**
+ * 查询单个脚本
+ */
+export const getScriptApiScriptsScriptIdGet = (options: Options) => {
+ return (options.client ?? client).get({
+ url: '/api/scripts/{script_id}',
+ ...options
+ });
+};
+
+/**
+ * 更新脚本配置
+ */
+export const updateScriptApiScriptsScriptIdPatch = (options: Options) => {
+ return (options.client ?? client).patch({
+ url: '/api/scripts/{script_id}',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers
+ }
+ });
+};
+
+/**
+ * 从文件导入脚本配置
+ */
+export const importScriptFromFileApiScriptsScriptIdActionsImportFilePost = (options: Options) => {
+ return (options.client ?? client).post({
+ url: '/api/scripts/{script_id}/actions/import-file',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers
+ }
+ });
+};
+
+/**
+ * 导出脚本配置到文件
+ */
+export const exportScriptToFileApiScriptsScriptIdActionsExportFilePost = (options: Options) => {
+ return (options.client ?? client).post({
+ url: '/api/scripts/{script_id}/actions/export-file',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers
+ }
+ });
+};
+
+/**
+ * 从网络导入脚本配置
+ */
+export const importScriptFromWebApiScriptsScriptIdActionsImportWebPost = (options: Options) => {
+ return (options.client ?? client).post