diff --git a/.agents/skills/README.md b/.agents/skills/README.md index e1f26a716..261e9e654 100644 --- a/.agents/skills/README.md +++ b/.agents/skills/README.md @@ -25,7 +25,7 @@ - `mas-schema-naming`:用于统一后端 schema 的命名方式,减少字段语义漂移。 - `mas-script-specialized-adapter`:用于新增或维护专项脚本适配,按脚本前端架构线完成问诊、前端表面与后端任务接入。 - `mas-plan-schedule`:用于新增、重构或审查计划表类型与调度配置。 -- `mas-game-sign`:用于新增、重构或审查游戏社区签到,涵盖平台注册表、凭据加密与登录路由、签到锁与触发路径、结果与通知契约。 +- `mas-community`:用于新增、重构或审查社区工具,涵盖凭据、角色、日常便笺、签到调度、通知与历史兼容。 - `grill-me`:用于对方案做高强度问诊,不属于 AUTO-MAS 工程规则 hub 的默认路由。 ## 使用方式 diff --git a/.agents/skills/mas-community/SKILL.md b/.agents/skills/mas-community/SKILL.md new file mode 100644 index 000000000..33a517c60 --- /dev/null +++ b/.agents/skills/mas-community/SKILL.md @@ -0,0 +1,130 @@ +--- +name: mas-community +description: >- + Define, add, refactor, or review AUTO-MAS community tools, including + credential parsing, role discovery, daily activity, sign scheduling, + notifications, provider capability evidence, and compatibility migration. +--- + +# 游戏社区工具 + +## 目标 + +将不同游戏社区的凭据、角色、日常活动、签到和通知纳入统一的社区工具语义, +为后续新增社区提供稳定边界。`game_sign`、`GameSign` 和历史路由/配置键是兼容 +标识,不作为新增业务的首选语义;不得为了改名批量迁移旧字段或破坏旧客户端。 + +## 术语 + +新增代码和用户可见文案优先使用: + +| 语义 | 首选名称 | 兼容说明 | +| --- | --- | --- | +| 总能力 | 游戏社区 / 社区工具 | 旧“游戏签到”只在兼容文案或历史记录中保留 | +| 外部平台 | 社区 provider / 社区平台 | 例如森空岛、米游社、库街区、塔吉多 | +| 凭据 | 社区凭据 | 继续读写旧 `GameSignAccount` 字段 | +| 角色 | 社区角色 / 游戏角色 | 由角色发现层归一化,不把 UID 当账号组 ID | +| 查询 | 日常活跃度 / 社区便笺 | 只读查询不能触发签到或任务提交 | +| 动作 | 社区签到 | 仍受既有签到锁、日期标记和结果契约约束 | +| 消费 | 社区通知 / 任务报告 | 复用同一结构化结果,不重复请求上游 | + +不要在新模块中用 `game_sign` 表达整个社区领域,也不要为同一语义同时新增 +`CommunityToken`、`GameSignToken` 等平行凭据字段。 + +## 模块边界 + +遵守 AUTO-MAS 的分层方向: + +| 层 | 归属 | 责任 | +| --- | --- | --- | +| schema | `app/models/schema.py` | API 输入/输出类型和字段描述 | +| api | `app/api/*.py` | 输入校验、调用 core、输出映射 | +| core | `app/core/community_*.py` | 账号组编排、锁、调度和状态协调 | +| tools | `app/tools/community*.py`、`app/tools/game_sign.py` | provider 注册与执行、角色归一化、请求规格、解析 | +| legacy | `app/tools/game_sign*.py` 中的旧导出 | 旧签到入口、旧符号和兼容结果 | + +provider 不得导入签到编排入口;schema 不得访问配置、网络或文件;API 不得持有 +角色遍历、重试或上游协议细节。社区 facade 可以提供新语义别名,但不能复制一份 +平台清单或绕过旧注册表。 + +## Provider 契约 + +新增社区时先明确以下能力,不得把参考项目中“存在接口”直接当作已接入: + +1. 凭据格式、字段完整性、刷新方式和失效表现。 +2. 角色发现路径、游戏标识、区服字段和角色 UID 的归一化规则。 +3. 日常活动的确认接口、请求头/设备要求、角色隔离和可识别响应字段。 +4. 签到路径、请求间隔、重试、已完成判据和结果错误码。 +5. 通知需要的结构化字段以及失败、受限、空数据和不可用状态。 + +请求规格可以包含公开 URL、角色参数、请求头配置和签名 profile,但不得包含 +Cookie、Token、Authorization、完整请求体或设备敏感值。认证值只在 provider 内存 +执行时注入,失败文案和日志不得回显原始响应。 + +参考项目只作为能力证据和协议线索。`CommunityPluginReference` 中的能力表示 +参考项目展示过该能力,不表示 AUTO-MAS 已接入、接口当前可用或可以跳过风控审查。 + +## 凭据规则 + +1. 旧账号组字段继续使用既有加密、校验和保存契约;不得因 UI 输入宽度截断长 Token。 +2. 解析器只返回格式、字段名和本地校验状态,不返回或持久化 Token 内容。 +3. Cookie、Token、设备信息和一次性登录值只在必要的内存作用域存在,日志只记录脱敏状态。 +4. provider 刷新后的凭据只能通过编排层的统一回写契约发布;查询失败不得覆盖原凭据。 +5. 缺少 `stoken_v2`、设备登记失败、上游风控和真实过期必须分别表达,不能统称为网络失败。 +6. Token 时效没有时间样本或官方依据时,不得承诺长时效;需要保留用户重新登录入口。 + +## 角色与日常活动 + +角色发现和日常查询必须与签到分离: + +1. 角色发现先按 provider 的游戏标识分流,再构造每个游戏的独立目标。 +2. 未明确游戏标识的绑定列表不得猜测归属;宁可返回不可用状态,也不能交叉映射。 +3. 每款游戏返回独立快照,包含账号、角色、完成数/目标数、任务、资源、状态、原因和来源。 +4. 活动查询使用独立的活动锁和并发上限,不获取签到锁,不修改签到日期,不触发签到通知。 +5. `success`、`empty`、`limited`、`unavailable`、`failed` 的含义必须稳定;上游 HTTP 200 不等于业务成功。 +6. 未确认的实时接口不得构造请求;应返回可见的 `limited` 状态并说明限制原因。 + +## 签到与调度 + +实际签到由 `app/core/community_sign.py` 统一编排,社区 facade 和旧入口都转发到 +该实现;provider 注册与平台执行仍以 `app/tools/game_sign.py` 的唯一注册表为来源: + +1. 平台凭据字段、平台日志名、runner、平台解析和错误游戏继续以既有注册表为单一来源。 +2. 社区日常锁与签到流程锁不可合并;签到请求和结果落盘在签到锁内,通知在锁外。 +3. 快速失败锁不得静默排队;API 返回冲突,定时器按既有策略跳过。 +4. 保留账号级/全局 `LastSignDate` 语义、跨零点重新取日期、重试和排序行为。 +5. 新平台的成功/失败状态必须进入既有结果兼容层,不得为通知单独重新请求上游。 +6. `game_sign.py`、`GameSignAccount`、`GameSign` 和 `/api/tools/sign` 属于历史兼容协议; + 新调用使用社区语义,但不得为了改名复制状态、迁移配置或破坏旧客户端。 + +## 通知与日志 + +只吸收日志采集配置 PR 中已验证的结果消费思路:先聚合结构化签到结果, +再复用到文本通知、Markdown、HTML 和任务报告。不要搬运完整日志采集配置、 +编辑器、采集器或规则 UI。 + +结构化结果必须保留平台、游戏、账号、状态、奖励和失败原因;业务失败不能被 +格式化成成功。Markdown 自动识别属于通知消费层能力,不能改变签到执行结果。 + +## 新社区接入顺序 + +1. 先补 provider 能力证据和脱敏合同,再决定是否支持角色发现或日常活动。 +2. 在 `tools` 层实现凭据解析、角色归一化、请求规格和响应解析。 +3. 在 `core` 层接入账号组编排和独立锁;复用旧注册表读取兼容凭据。 +4. 在 `schema`/`api` 层完成类型化契约;禁止手改 OpenAPI 生成客户端。 +5. 由维护者在隔离开发服务中重新生成客户端,再接入前端;原有 Token 保存和 UI 未经批准不得迁移。 +6. 补充纯逻辑、脱敏 Mock、锁隔离和结果映射测试;真实账号 E2E 单独说明,不用 Mock 冒充。 +7. 更新 `res/version.json` 的下一未发布版本,并在开发报告中更新阶段清单。 + +## 审查清单 + +- [ ] 新增代码使用社区工具/社区 provider 语义,旧 `game_sign` 仅为兼容入口。 +- [ ] 平台和凭据字段没有重复注册表;历史配置字段可继续读取。 +- [ ] 角色按明确游戏标识分流,未标识响应不猜测。 +- [ ] 日常活动不获取签到锁、不改签到日期、不触发签到通知。 +- [ ] 每个游戏独立返回空、成功、受限、不可用和失败状态。 +- [ ] Token 保持完整、不进入日志/API 错误、不因输入框宽度截断。 +- [ ] 上游 HTTP 200 与业务成功已分离,风控和失效状态可见。 +- [ ] 文本、Markdown、HTML 和任务报告复用同一结构化签到结果。 +- [ ] 没有手改 `frontend/src/api/**`;生成客户端由维护者在隔离服务中重新生成。 +- [ ] 测试、错误、真实 E2E 缺口和版本记录均已留痕。 diff --git a/.agents/skills/mas-game-sign/SKILL.md b/.agents/skills/mas-game-sign/SKILL.md deleted file mode 100644 index 8e83e07d4..000000000 --- a/.agents/skills/mas-game-sign/SKILL.md +++ /dev/null @@ -1,203 +0,0 @@ ---- -name: mas-game-sign -description: >- - Add, refactor, or review AUTO-MAS game community sign-in (game sign) code, - including the provider registry in app/tools/game_sign.py, platform adapters - for Skland/Miyoushe/Kuro/Taygedo, credential encryption and one-time login - routes, sign-in locks and trigger paths, result and notification contracts, - the GameSign account-group config, and the frontend gamesign views. ---- - -# 游戏社区签到 - -## 核心要义 - -签到是**凭据驱动的批量外部请求**,不是脚本适配。它的风险集中在三处:**用户凭据**、**上游风控**、**重复触发**。改动时先确认属于哪一层: - -1. **平台适配层**(`app/tools/.py`):单个社区的登录、角色发现、签到请求、风控退避。上游 API 知识来自被致谢的第三方项目,文件头的 AGPL/致谢声明必须保留。 -2. **编排层**(`app/tools/game_sign.py`):账号遍历、平台注册表、并发、锁、日期标记、凭据回写、结果归一。 -3. **消费层**(`app/tools/game_sign_notify.py`、`game_sign_result.py`、API、前端):结果展示与通知。 - -新增或修改平台时,**只应动第 1 层加注册表一行**。若发现必须改编排层才能容纳新平台,先停下来确认是不是抽象放错了位置。 - -## 当前架构 - -### 平台注册表是唯一入口 - -`app/tools/game_sign.py` 的 `_GAME_SIGN_PROVIDERS` 是不可变元组,每项 `_GameSignProvider(frozen=True)` 声明五件事: - -| 字段 | 含义 | -| --- | --- | -| `token_field` | `GameSignAccountGroup` 上的凭据字段名,同时是「该平台是否已配置」的判据 | -| `log_name` | 日志与错误文案中的平台名 | -| `runner` | `async (token, account_name, account_uid) -> _ProviderRun` | -| `resolve_platforms` | 由凭据推导本次涉及的平台名元组 | -| `error_game` | 平台名 → 失败结果里的 `game` 字段 | - -当前四项:`SklandToken`/森空岛、`MiyousheToken`/米游社、`KuroToken`/库街区、`TaygedoToken`/塔吉多。 - -`GAME_SIGN_TOKEN_FIELDS` 由注册表派生,`has_game_sign_credentials()` 又由它派生。**不要另写一份平台或字段清单**:调度台跳过账号、前端判空、通知分组都应回到注册表。 - -只有塔吉多使用动态 `resolve_platforms`(`_resolve_taygedo_platforms`):一份凭据可能同时覆盖**塔吉多**与**云异环**两个平台名。其余三家用 `_fixed_platforms(...)` 返回单元素元组。新增「一份凭据对多平台」的社区时照塔吉多写,不要在 `runner` 里硬编码平台名。 - -### 后端文件职责 - -| 文件 | 职责 | -| --- | --- | -| `app/tools/game_sign.py` | 注册表、锁、编排、结果归一、`format_sign_results` / `merge_sign_results` | -| `app/tools/skland.py` | 森空岛:凭据校验、角色发现、签到、账号密码登录 | -| `app/tools/skland_response.py` | 森空岛响应形状判定(如 `is_skland_already_signed`),与请求逻辑分离 | -| `app/tools/miyoushe.py` | 米游社:DS 签名、多游戏签到 | -| `app/tools/miyoushe_qr.py` | 米游社扫码登录状态机 | -| `app/tools/kuro.py` | 库街区 | -| `app/tools/taygedo.py` | 塔吉多 + 云异环:账号密码登录、Token 刷新、凭据序列化 | -| `app/tools/game_sign_result.py` | 结果构建辅助(`SKLAND_GAME_MAPPING`、`build_skland_sign_results`) | -| `app/tools/game_sign_notify.py` | 通知文案格式化、平台排序、任务摘要挂载、多渠道推送与重试 | -| `app/api/tools.py` | 手动签到、账号组 CRUD、塔吉多/森空岛一次性登录 | -| `app/api/qr_login.py` | 米游社扫码登录三步路由 | -| `app/core/timer.py` | 自动签到触发、任务前置签到、全局日期标记 | - -`app/tools/__init__.py` 的 `__all__` 只有四项:`run_all_sign_in`、`format_sign_results`、`login_skland_with_password`、`skland_sign_in`。其余符号按需从具体模块导入,且多数调用点使用**函数内延迟导入**以避免启动期循环依赖;沿用这个写法,不要为了「统一出口」把签到模块提到包顶层导入。 - -### 前端表面 - -| 位置 | 内容 | -| --- | --- | -| `frontend/src/views/gamesign/index.vue` | 页面容器 | -| `frontend/src/views/gamesign/TabGameSign.vue` | 账号组列表、凭据录入、扫码弹窗、结果展示 | -| `frontend/src/views/gamesign/useGameSignApi.ts` | 仅包装生成的 `Service.*`,无业务判断 | - -`useGameSignApi.ts` 是薄封装:每个方法一行转发到 `@/api` 的生成客户端。新增路由时在此加一行并从返回对象导出,**不要**在此写状态、缓存或错误吞掉逻辑。 - -## 新增一个签到平台 - -按此顺序,每步都能独立自查: - -1. **写平台适配模块** `app/tools/.py`。对外只暴露一个「跑一次签到」的入口与必要的凭据解析/校验函数。若参考了第三方项目的接口知识,按现有模块的写法在文件头加致谢与许可声明。 -2. **加凭据字段**:在 `app/models/config.py` 的 `GameSignAccountGroup` 增加 `Token`,section 固定为 `"GameSignAccount"`,validator 必须是 `EncryptValidator()`,并在字段上方写 `## GameSignAccount - <说明> (DPAPI 加密)` 注释。 -3. **加 schema 字段**:`app/models/schema.py` 的 `GameSignAccountGroupConfig` 增加同名可选字段。这是 API 出入口,字段名与配置项保持一致。 -4. **写 provider runner**:在 `game_sign.py` 加 `_run__provider()`,返回 `_ProviderRun(results=..., platforms=..., credential_updates=...)`。 -5. **注册**:在 `_GAME_SIGN_PROVIDERS` 追加一项。`GAME_SIGN_TOKEN_FIELDS`、`has_game_sign_credentials()`、并发执行、凭据回写、日期标记全部自动生效。 -6. **通知排序**:在 `game_sign_notify.py` 的 `_PLATFORM_ORDER` 追加平台名。缺失时该平台会落到排序尾部而不是报错,容易漏掉。 -7. **重新生成 OpenAPI 客户端**,再在 `TabGameSign.vue` 加录入入口。禁止手改 `frontend/src/api/**`。 - -## 凭据与安全 - -签到是本仓少数直接持有**用户账号密码**的路径,规则从严: - -1. 所有凭据字段用 `EncryptValidator()`(DPAPI 加密落盘)。新增凭据字段没有例外。 -2. 一次性登录请求的密码字段用 `SecretStr`,用 `.get_secret_value()` 取值,**不落盘、不进日志**。 -3. 登录路由的 `except Exception` 分支**禁止** `exc_info` / `logger.exception` / 回显上游响应,只返回固定文案。参见 `login_taygedo` / `login_skland` 的注释:避免密码、请求对象或上游响应进入日志。 -4. `ValueError` 才允许把消息带给用户(凭据不完整、格式错误等可预期原因);其他异常统一返回泛化文案。 -5. 登录成功后必须**校验凭据完整性**再落盘(塔吉多校验 `accessToken`/`refreshToken`/`uid`,森空岛校验 `oauthToken`/`token`/`cred`),缺字段视为登录失败。 -6. 凭据刷新走 `_ProviderRun.credential_updates`:编排层在并发完成后统一比对新旧值并写回,值未变则跳过。**不要**在 runner 内部直接写 `account.set()`。 -7. 读取凭据统一用 `_read_game_sign_token()`:它容忍旧账号对象缺少新增字段(`AttributeError` / `KeyError` 均返回空串)。直接 `account.get(...)` 会在旧配置上炸。 - -## 并发与重复触发 - -两把锁,语义不同,不要合并: - -| 锁 | 覆盖范围 | 违反时 | -| --- | --- | --- | -| `_game_sign_flow_lock`(`game_sign_flow()` 上下文管理器) | 签到请求 **加** 结果落盘。通知在锁外发送。 | 直接抛 `GameSignInProgressError`,不排队等待 | -| `_game_sign_lock`(`run_all_sign_in` 内部) | 全局签到执行,配合 `_game_sign_lock_owner` ContextVar 支持**同任务嵌套重入** | 同上 | - -两把锁都是**快速失败**而非等待:`if lock.locked(): raise`。API 层把 `GameSignInProgressError` 映射为 `code=409`;`timer` 层降级为 `logger.info` 后跳过本次触发。新增触发入口时必须选一种处理,不要静默 `await` 到锁释放。 - -通知**必须在流程锁外**发送。慢渠道会阻塞后续操作,这是锁边界画在落盘之后的原因。 - -**日期标记有两层**,不要混用: - -- 账号级 `GameSignAccount.LastSignDate`:`_run_all_sign_in` 内按账号写。自动模式(`force=False`)即使失败也标记当天,避免后续 MAS 任务反复请求上游;手动模式(`force=True`)仅在该账号所有已配置平台完成后才标记。 -- 全局 `GameSign.LastSignDate`:由调用方在**所有**启用且有凭据的账号都完成后才写。 - -多账号串行签到可能跨越 0 点,写入时**重新取当前日期**而不是复用循环开始时的 `today`。改这段逻辑要保留这个行为。 - -## 结果契约 - -编排层的 `results` 是 `list[dict]`,键固定: - -```text -account 账号名,失败结果为 "账号名/平台名" -account_uid 账号组 UUID 字符串,由编排层统一覆盖 -game 游戏名 -platform 平台名 -status "成功" / "已签到" / "失败" -reward 奖励文案 -reason 失败原因 -``` - -两个内部标记: - -- `_notification_only`:占位结果(如「未获取到可签到角色」),进通知但被 `format_sign_results` 过滤,不进前端列表。 -- `_completed`:完成态旁路标记,参与「是否全部完成」判定。 - -**成功判据在三处重复出现**(`_all_enabled_platforms_signed`、`manual_game_sign`、`game_sign_notify._SUCCESS_STATUSES`):`status in ("成功", "已签到") or _completed`。改成功语义时必须同步这三处。 - -`_decorate_provider_run()` 负责归一:补 `account` 与 `account_uid`、为没有结果的平台补占位。runner 返回的结果**不必**自己填 `account_uid`。 - -`format_sign_results()` 输出 `{platform: [{account_alias, account_uid, games: [...]}]}`;`merge_sign_results()` 按 `account_uid` 替换受影响账号,避免旧成功状态遮蔽新失败结果(`replace` 参数已退化为兼容占位,两条路径行为相同)。 - -## 日志分级约定 - -`_is_expected_provider_exception()` 是**唯一**的分级依据,`_run_provider()` 据此选择 `logger.warning` 还是 `logger.exception`: - -- 可预期(`warning` + 带消息给用户):`ValueError`、`httpx.HTTPError`、`TimeoutError`、`ConnectionError`;以及消息中含 `token`/`cookie`/`凭据`/`登录`/`风控`/`请求`/`接口`/`网络`/`offline`/`timeout`/`timed out` 的 `RuntimeError`。 -- 非预期(`exception` + 只给用户泛化文案):其余全部。 - -凭据失效和上游风控是**正常运行状态**,不应刷栈。新增平台若抛自定义异常,让它继承 `ValueError` 或在消息里带上述关键词,不要扩大 `_is_expected_provider_exception` 的类型白名单来将就实现。 - -## 触发路径 - -| 入口 | 特点 | -| --- | --- | -| `POST /api/tools/sign`(`manual_game_sign`) | `force=True`;通知走 `_dispatch_game_sign_notification`,`asyncio.shield` + 0.1s 超时后转后台,不阻塞响应 | -| `timer._execute_game_sign(source=...)` | `force=False`;`source` 在 `_TASK_GAME_SIGN_SOURCES` 内时结果交由**任务完成通知**消费,其余自动来源单独推送 | -| `timer.try_game_sign_for_task` | MAS 任务前置签到 | - -`GameSign.NotifyEnabled` 是通知总开关,两条路径都要检查。后台通知任务必须持有强引用(`_PENDING_GAME_SIGN_NOTIFICATIONS`)并挂 `add_done_callback` 记录失败,否则任务可能被 GC。 - -`_check_system_time()` 只告警不阻断,且**不占签到锁**(在 `run_all_sign_in` 里以独立 task 起、finally 里取消)。时间源不可用时静默 `debug`。不要把它改成阻断条件。 - -## 已知不一致(改动时留意,不要照抄) - -1. `app/api/qr_login.py` 把 `QrCreateOut` / `QrCheckIn` / `QrCheckOut` / `QrSaveIn` **定义在路由文件内**,其余签到 schema 都在 `app/models/schema.py`。新增签到 schema 放 `schema.py`。 -2. 路由 tag 不统一:`tools.py` 的账号组路由用 `tags=["GameSign"]`,手动签到用 `tags=["Action"]`,`qr_login.py` 用中文 `tags=["扫码登录"]`。沿用所在文件的既有 tag,不要顺手改动已生成客户端的方法名。 -3. `qr_login.py` 的 `QrSaveIn` 用 snake_case(`account_uid`),`tools.py` 的账号组请求用 camelCase(`accountId`)。这是既有契约,改名会破坏生成客户端。 -4. `GameSign_WindowStart` / `WindowEnd` / `ScheduledRun` / `ScheduledTime` 是**保留读取历史配置**的旧字段,不参与调度。不要据此推断当前调度行为。 - -## 审查清单 - -- [ ] 新平台只改了适配模块 + 注册表一项,未在编排层加平台分支 -- [ ] 凭据字段使用 `EncryptValidator()`,schema 侧同名字段已加 -- [ ] 一次性登录密码用 `SecretStr`,异常分支未记录堆栈或上游响应 -- [ ] 登录成功后校验了凭据完整性才落盘 -- [ ] 凭据刷新经 `credential_updates` 回写,runner 内未直接写配置 -- [ ] 读凭据走 `_read_game_sign_token()`,兼容缺字段的旧账号 -- [ ] 新增触发入口显式处理 `GameSignInProgressError`(409 或跳过),未静默等待锁 -- [ ] 通知在流程锁**外**发送,后台通知任务持有强引用 -- [ ] 成功判据三处(`_all_enabled_platforms_signed`、`manual_game_sign`、`_SUCCESS_STATUSES`)保持一致 -- [ ] 账号级与全局 `LastSignDate` 语义未混用;跨 0 点重取日期的行为保留 -- [ ] 新平台已加入 `_PLATFORM_ORDER` -- [ ] 可预期失败走 `warning`,未因新异常类型扩大白名单 -- [ ] `_notification_only` 结果未进前端列表 -- [ ] 未手改 `frontend/src/api/**`;后端 schema 变更后已提示重新生成 - -## 最小验证 - -按 `tests/AGENTS.md`,开发时在 `tests/tools/` 下编写签到测试用于本地验证(`test_game_sign.py`、`test_game_sign_notification.py`、`test_miyoushe_qr.py`、`test_miyoushe_retry.py`、`test_contracts.py` 等)。 - -提交或提 PR 时,功能/bug 边界测试不提交,仅提交重要公共测试或纯逻辑测试(如 `test_skland_response.py`)。 - -前端改动只运行实际受影响的 `*.test.ts`。有缺口就在结果里说明,不编造验证结果。 - -## 避免 - -1. 不要为签到引入通用「平台配置引擎」或字段元数据表。四个平台的差异在**请求协议**,不在字段形状。 -2. 不要在 `useGameSignApi.ts` 里写业务判断或错误吞掉逻辑。 -3. 不要把 `_check_system_time()` 变成阻断条件,也不要让它占用签到锁。 -4. 不要在编排层按平台名写 `if`。需要平台差异时加 provider 字段。 -5. 不要新增第二份平台清单或凭据字段清单。 -6. 不要把凭据、密码或上游原始响应写进日志或 API 错误消息。 - - diff --git a/.agents/skills/mas-game-sign/agents/openai.yaml b/.agents/skills/mas-game-sign/agents/openai.yaml deleted file mode 100644 index ca49b4672..000000000 --- a/.agents/skills/mas-game-sign/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "MAS Game Sign" - short_description: "Maintain game community sign-in" - default_prompt: "Add, refactor, or review AUTO-MAS game community sign-in code, including the platform provider registry, credential encryption and one-time login routes, sign-in locks and trigger paths, and result/notification contracts." diff --git a/.agents/skills/mas-skills/SKILL.md b/.agents/skills/mas-skills/SKILL.md index 3f11d1b11..ee189566f 100644 --- a/.agents/skills/mas-skills/SKILL.md +++ b/.agents/skills/mas-skills/SKILL.md @@ -40,7 +40,7 @@ Use these skills as needed: 8. `mas-data-model`: modeling standards for schema/config/task layers and compatibility evolution. 9. `mas-script-specialized-adapter`: specialized script integration by script frontend architecture line; requires intake before implementation. 10. `mas-plan-schedule`: plan schedule type registration, backend/frontend plan dispatch, plan combobox consumers, and per-type table integration. -11. `mas-game-sign`: game community sign-in providers, credential encryption and login routes, sign-in locks and trigger paths, and result/notification contracts. +11. `mas-community`: community-tool semantics across credentials, role discovery, daily activity, sign scheduling, notifications, provider capability evidence, and compatibility migration. ## Global Constraints Apply these constraints before selecting or combining sub-skills. @@ -79,8 +79,8 @@ Use `mas-script-specialized-adapter` first, then combine `mas-module-boundary`, Use `mas-code-standards` for code-style decisions; use the docs site for contribution-process wording. 11. Task mentions plan schedules, schedule types, `PlanConfig`, `PLAN_BOOK`, plan comboboxes, or adding a new plan table: Use `mas-plan-schedule`. -12. Task mentions game community sign-in, `app/tools/game_sign*`, a sign-in platform such as Skland/Miyoushe/Kuro/Taygedo, sign-in credentials or QR login, or sign-in result and notification behavior: -Use `mas-game-sign`. +12. Task mentions community plugins/tools, role discovery, daily activity or memo, provider capability references, community notification aggregation, community sign-in flows, or migration from `game_sign` semantics: +Use `mas-community`. ## Combined Execution Order When multiple concerns appear, apply this order: @@ -95,9 +95,9 @@ When multiple concerns appear, apply this order: 8. `mas-api-contract` 9. `mas-script-specialized-adapter` after architecture intake, when the task is a specialized adapter. 10. `mas-plan-schedule` -11. `mas-game-sign` +11. `mas-community` -Reason: frontend tasks need their engineering and UI constraints loaded before implementation decisions; then establish local conventions, place code correctly, stabilize model structure, then naming, function behavior, and transport contract. Specialized adapters add a mandatory architecture-intake step, plan schedule rules apply when the task touches scheduler registration, and game sign rules apply last because they constrain credential handling and external request behavior inside an already-placed module. +Reason: frontend tasks need their engineering and UI constraints loaded before implementation decisions; then establish local conventions, place code correctly, stabilize model structure, then naming, function behavior, and transport contract. Specialized adapters add a mandatory architecture-intake step, plan schedule rules apply when the task touches scheduler registration, and community rules apply last because they constrain credentials, external requests, scheduling, and compatibility inside an already-placed module. ## Output Requirements When using this hub: diff --git a/.gitignore b/.gitignore index 0e1c28420..848b7e498 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,9 @@ list/ /skills/ .ace-tool/ +# Local-only game community protocol and UI references +/local/community-reference-projects/ + # Plugin workspace (external plugin sources stay ignored) /plugins/* !/plugins/auto_mas_core/ diff --git a/app/api/__init__.py b/app/api/__init__.py index 3d8db26a8..0bf4af076 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -35,6 +35,7 @@ from .queue import router as queue_router from .scripts import router as scripts_router from .setting import router as setting_router +from .skland_qr import router as skland_qr_router from .tools import router as tools_router from .update import router as update_router @@ -55,4 +56,5 @@ "openclaw_qq_router", "openclaw_weixin_router", "qr_login_router", + "skland_qr_router", ] diff --git a/app/api/qr_login.py b/app/api/qr_login.py index 69287ef45..d9ef3bbcd 100644 --- a/app/api/qr_login.py +++ b/app/api/qr_login.py @@ -25,9 +25,9 @@ 可安全删除本文件,不会影响任何已有功能。 删除后同时移除 main.py 中的 include_router 调用。 -流程(Passport 模式): +流程(Passport App 优先,Passport Web 兼容回退;旧 GameToken ticket 仅兼容): 1. /create → 生成二维码 (ticket, qr_url, device) - 2. /check → 轮询状态 (Init/Scanned/Confirmed),确认后返回 cookies_str + 2. /check → 轮询状态 (Init/Scanned/Confirmed),确认后返回完整 cookies_str 3. /save → 将 cookies 保存到账号配置 """ @@ -36,8 +36,19 @@ from app.core import Config from app.models.schema import OutBase +from app.utils.logger import get_logger +from app.utils.security import format_exception_reason router = APIRouter(prefix="/api/tools/sign/miyoushe/qr", tags=["扫码登录"]) +logger = get_logger("米游社扫码登录 API") + + +def _log_qr_error(stage: str, error: Exception) -> None: + """记录脱敏诊断,二维码接口不向前端透传异常细节。""" + + logger.warning( + format_exception_reason(error, stage=stage, include_message=False) + ) # ---- 请求/响应模型 ---- @@ -76,7 +87,12 @@ async def qr_create() -> QrCreateOut: result = await create_qr_login() except Exception as e: - return QrCreateOut(code=500, status="error", message=str(e)) + _log_qr_error("创建米游社二维码失败", e) + return QrCreateOut( + code=500, + status="error", + message="二维码创建失败,请稍后重试", + ) if not isinstance(result, dict): return QrCreateOut(code=500, status="error", message="二维码服务返回格式无效") error = result.get("error") @@ -84,7 +100,7 @@ async def qr_create() -> QrCreateOut: return QrCreateOut( code=500, status="error", - message=error if isinstance(error, str) else "创建二维码失败", + message="二维码创建失败,请稍后重试", ) if not all( isinstance(result.get(key), str) and result[key] @@ -98,13 +114,18 @@ async def qr_create() -> QrCreateOut: @router.post("/check", summary="轮询扫码状态", response_model=QrCheckOut) async def qr_check(body: QrCheckIn = Body(...)) -> QrCheckOut: - """轮询状态,确认后返回从 Passport 响应提取的 cookies。""" + """轮询状态,确认后返回 Passport App 或 Web 链路的完整 cookies。""" try: from app.tools.miyoushe_qr import check_qr_status result = await check_qr_status(body.ticket, body.device) except Exception as e: - return QrCheckOut(code=500, status="error", message=str(e)) + _log_qr_error("查询米游社二维码状态失败", e) + return QrCheckOut( + code=500, + status="error", + message="二维码状态查询失败,请稍后重试", + ) if not isinstance(result, dict): return QrCheckOut(code=500, status="error", message="二维码状态响应格式无效") error = result.get("error") @@ -115,7 +136,11 @@ async def qr_check(body: QrCheckIn = Body(...)) -> QrCheckOut: return QrCheckOut( code=500, status=error_status, - message=error if isinstance(error, str) else "查询二维码状态失败", + message=( + "二维码已失效,请重新获取" + if error_status == "expired" + else "二维码状态查询失败,请稍后重试" + ), ) status = result.get("status") if not isinstance(status, str): @@ -138,12 +163,23 @@ async def qr_save(body: QrSaveIn = Body(...)) -> OutBase: try: from app.tools.miyoushe import validate_miyoushe_cookie - cookie = body.cookie.strip() + # 全量 Cookie 可能包含服务端兼容字段,保存路径不得重建或裁剪。 + cookie = body.cookie validate_miyoushe_cookie(cookie) data = {"GameSignAccount": {"MiyousheToken": cookie}} await Config.update_game_sign_account(body.account_uid, data) except ValueError as e: - return OutBase(code=400, status="error", message=f"米游社 Token 无效:{e}") + _log_qr_error("保存米游社 Token 校验失败", e) + return OutBase( + code=400, + status="error", + message="米游社 Token 格式无效,请检查后重试", + ) except Exception as e: - return OutBase(code=500, status="error", message=str(e)) + _log_qr_error("保存米游社 Token 失败", e) + return OutBase( + code=500, + status="error", + message="米游社 Token 保存失败,请稍后重试", + ) return OutBase(message="米游社 Token 已保存") diff --git a/app/api/skland_qr.py b/app/api/skland_qr.py new file mode 100644 index 000000000..b34c65fbe --- /dev/null +++ b/app/api/skland_qr.py @@ -0,0 +1,181 @@ +# 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 + + +""" +森空岛扫码登录 API 路由(可选补丁) + +可安全删除本文件,不会影响任何已有功能。 +删除后同时移除 main.py 和 app/api/__init__.py 中的 include 调用。 + +流程: + 1. /create -> 生成鹰角通行证扫码链接 (ticket, qr_url, device) + 2. /check -> 轮询状态 (Init/Scanned/Confirmed/Expired/Canceled) + 3. /save -> 用 scanCode 换取完整森空岛凭据并保存到账号组 SklandToken +""" + +from fastapi import APIRouter, Body +from pydantic import BaseModel, Field + +from app.core import Config +from app.models.schema import OutBase +from app.utils.logger import get_logger +from app.utils.security import format_exception_reason + +router = APIRouter(prefix="/api/tools/sign/skland/qr", tags=["扫码登录"]) +logger = get_logger("森空岛扫码登录 API") + + +def _log_qr_error(stage: str, error: Exception) -> None: + """记录脱敏诊断,扫码接口不向前端透传异常细节。""" + + logger.warning( + format_exception_reason(error, stage=stage, include_message=False) + ) + + +class SklandQrCreateOut(OutBase): + ticket: str = Field(default="", description="森空岛扫码 ticket/scanId") + qr_url: str = Field(default="", description="用于生成二维码的跳转链接") + device: str = Field(default="", description="本地设备 ID") + + +class SklandQrCheckIn(BaseModel): + ticket: str + device: str = Field(default="") + + +class SklandQrCheckOut(OutBase): + status: str = Field( + default="", + description="Init/Scanned/Confirmed/Expired/Canceled/Error", + ) + scan_code: str = Field(default="", description="确认后返回的短时 scanCode") + + +class SklandQrSaveIn(BaseModel): + account_uid: str = Field(..., description="MAS 账号组 UUID") + scan_code: str + + +@router.post("/create", summary="创建二维码", response_model=SklandQrCreateOut) +async def qr_create() -> SklandQrCreateOut: + try: + from app.tools.skland import create_skland_qr_login + + result = await create_skland_qr_login(proxy=Config.proxy) + except Exception as e: + _log_qr_error("创建森空岛二维码失败", e) + return SklandQrCreateOut( + code=500, + status="error", + message="二维码创建失败,请稍后重试", + ) + if not isinstance(result, dict): + return SklandQrCreateOut( + code=500, status="error", message="二维码服务返回格式无效" + ) + if not all( + isinstance(result.get(key), str) and result[key] + for key in ("ticket", "qr_url", "device") + ): + return SklandQrCreateOut( + code=500, status="error", message="二维码服务返回数据不完整" + ) + return SklandQrCreateOut( + ticket=result["ticket"], + qr_url=result["qr_url"], + device=result["device"], + ) + + +@router.post("/check", summary="轮询扫码状态", response_model=SklandQrCheckOut) +async def qr_check(body: SklandQrCheckIn = Body(...)) -> SklandQrCheckOut: + """确认后返回短时 scanCode,由前端随后提交保存。""" + try: + from app.tools.skland import check_skland_qr_status + + result = await check_skland_qr_status( + body.ticket, + body.device, + proxy=Config.proxy, + ) + except Exception as e: + _log_qr_error("查询森空岛二维码状态失败", e) + return SklandQrCheckOut( + code=500, + status="error", + message="二维码状态查询失败,请稍后重试", + ) + if not isinstance(result, dict): + return SklandQrCheckOut( + code=500, status="error", message="二维码状态响应格式无效" + ) + status = result.get("status") + if not isinstance(status, str) or not status: + status = "error" + scan_code = result.get("scan_code") + if not isinstance(scan_code, str): + scan_code = "" + return SklandQrCheckOut( + status=status, + scan_code=scan_code, + message=str(result.get("message") or ""), + ) + + +@router.post("/save", summary="保存森空岛 Token", response_model=OutBase) +async def qr_save(body: SklandQrSaveIn = Body(...)) -> OutBase: + try: + from app.tools.skland import ( + finalize_skland_qr_login, + validate_skland_credential, + ) + + serialized = await finalize_skland_qr_login( + body.scan_code, + proxy=Config.proxy, + ) + parsed = validate_skland_credential(serialized) + if any( + not str(parsed.get(field) or "").strip() + for field in ("oauthToken", "token", "cred") + ): + raise ValueError("森空岛扫码登录未返回完整凭据") + await Config.update_game_sign_account( + body.account_uid, + {"GameSignAccount": {"SklandToken": serialized}}, + ) + except ValueError as e: + _log_qr_error("保存森空岛 Token 校验失败", e) + return OutBase( + code=400, + status="error", + message="森空岛扫码登录失败,请重新获取二维码", + ) + except Exception as e: + _log_qr_error("保存森空岛 Token 失败", e) + return OutBase( + code=500, + status="error", + message="森空岛 Token 保存失败,请稍后重试", + ) + return OutBase(message="森空岛 Token 已保存") diff --git a/app/api/tools.py b/app/api/tools.py index 6a72d0f68..c0460d824 100644 --- a/app/api/tools.py +++ b/app/api/tools.py @@ -22,6 +22,7 @@ import asyncio +import traceback from datetime import datetime from inspect import isawaitable from uuid import UUID @@ -29,8 +30,21 @@ from fastapi import APIRouter, Body from app.core import Config -from app.core.notify import DispatchResult +from app.core.community_scheduler import ( + CommunityActivityInProgressError, + community_activity_flow, +) +from app.core.community_sign import ( + CommunitySignInProgressError, + community_sign_flow, + run_community_sign_in, +) from app.models.schema import ( + CommunityActivityOut, + CommunityActivityQueryIn, + CommunityActivityResourceOut, + CommunityActivitySnapshotOut, + CommunityActivityTaskOut, GameSignAccountCreateOut, GameSignAccountDeleteIn, GameSignAccountGetIn, @@ -39,7 +53,6 @@ GameSignAccountsListOut, GameSignAccountUpdateIn, OutBase, - SklandLoginIn, TaygedoLoginIn, ToolsConfig, ToolsGetOut, @@ -47,49 +60,80 @@ ) from app.utils.constants import UTC8 from app.utils.logger import get_logger +from app.utils.security import format_exception_reason router = APIRouter(prefix="/api/tools", tags=["工具设置"]) -logger = get_logger("游戏签到 API") -_PENDING_GAME_SIGN_NOTIFICATIONS: set[asyncio.Task] = set() +logger = get_logger("游戏社区 API") +_PENDING_COMMUNITY_NOTIFICATIONS: set[asyncio.Task[list[str] | None]] = set() +_MIYOUSHE_DEVICE_FIELDS = ("MiyousheDeviceId", "MiyousheDeviceFp") + + +def _log_community_api_error(stage: str, error: Exception) -> None: + """记录脱敏诊断,社区 API 不把异常细节直接返回给前端。""" + + logger.warning(format_exception_reason(error, stage=stage, include_message=False)) + if error.__traceback__ is not None: + # 保留定位信息,不记录局部变量、源码行或可能含凭据的异常原文。 + frames = traceback.extract_tb(error.__traceback__) + logger.warning( + "社区调用位置: " + + " -> ".join( + f"{frame.filename}:{frame.lineno} in {frame.name}" for frame in frames + ) + ) + + +def _normalize_miyoushe_device_fields(data: dict[str, object]) -> None: + """规范历史设备字段;残缺旧值由 provider 自动设备链路接管。""" + for field in _MIYOUSHE_DEVICE_FIELDS: + if field not in data: + continue + value = data.get(field) + data[field] = value.strip() if isinstance(value, str) else "" -def _track_game_sign_notification(task: asyncio.Task) -> None: + +def _track_community_notification( + task: asyncio.Task[list[str] | None], +) -> None: """保留后台通知任务引用,并消费完成后的异常。""" - _PENDING_GAME_SIGN_NOTIFICATIONS.discard(task) + _PENDING_COMMUNITY_NOTIFICATIONS.discard(task) if task.cancelled(): return try: - result = task.result() + failed_channels = task.result() except Exception as exc: - logger.warning(f"后台游戏签到通知发送失败: {exc}") + _log_community_api_error("后台游戏社区通知发送失败", exc) return - if result.failed: - logger.warning(f"后台游戏签到通知部分失败: {'、'.join(result.failed)}") + if failed_channels: + logger.warning( + f"后台游戏社区通知部分失败: {'、'.join(failed_channels)}" + ) -async def _dispatch_game_sign_notification( - results: list[dict], -) -> DispatchResult | None: +async def _dispatch_community_notification( + results: list[dict[str, object]], +) -> list[str] | None: """发送签到通知;慢渠道转后台,避免阻塞签到完成响应。""" - from app.tools.game_sign_notify import push_game_sign_notification + from app.tools.community_notify import push_community_notification - task = asyncio.create_task(push_game_sign_notification(results)) - _PENDING_GAME_SIGN_NOTIFICATIONS.add(task) - task.add_done_callback(_track_game_sign_notification) + task = asyncio.create_task(push_community_notification(results)) + _PENDING_COMMUNITY_NOTIFICATIONS.add(task) + task.add_done_callback(_track_community_notification) try: return await asyncio.wait_for(asyncio.shield(task), timeout=0.1) except asyncio.TimeoutError: logger.info("签到结果已落盘,通知渠道仍在后台发送") return None except Exception as exc: - logger.warning(f"签到完成,但通知服务异常: {exc}") - return DispatchResult(failed=("通知服务",)) + _log_community_api_error("游戏社区通知服务异常", exc) + return ["通知服务"] -def _get_game_sign_field(account: object, field: str, default=None): - """读取签到账号字段时兼容未包含新增字段的旧账号对象。""" +def _get_community_account_field(account: object, field: str, default=None): + """读取社区账号字段时兼容未包含新增字段的旧账号对象。""" try: return account.get("GameSignAccount", field) # type: ignore[attr-defined] @@ -97,6 +141,9 @@ def _get_game_sign_field(account: object, field: str, default=None): return default +_get_game_sign_field = _get_community_account_field + + @router.post( "/get", tags=["Get"], @@ -110,10 +157,11 @@ async def get_tools() -> ToolsGetOut: try: data = await Config.get_tools() except Exception as e: + _log_community_api_error("读取工具配置失败", e) return ToolsGetOut( code=500, status="error", - message=f"{type(e).__name__}: {str(e)}", + message="读取工具配置失败,请稍后重试", data=ToolsConfig(**{}), ) return ToolsGetOut(data=ToolsConfig(**data)) @@ -134,8 +182,11 @@ async def update_tools(script: ToolsUpdateIn = Body(...)) -> OutBase: await Config.update_tools(data) except Exception as e: + _log_community_api_error("更新工具配置失败", e) return OutBase( - code=500, status="error", message=f"{type(e).__name__}: {str(e)}" + code=500, + status="error", + message="更新工具配置失败,请稍后重试", ) return OutBase() @@ -151,22 +202,21 @@ async def manual_game_sign() -> OutBase: """手动触发游戏社区签到""" try: - from app.tools.game_sign import ( - GameSignInProgressError, - format_sign_results, - game_sign_flow, - has_game_sign_credentials, - run_all_sign_in, + from app.tools.community import ( + format_community_sign_results, + has_community_credentials, ) # 流程锁覆盖签到和结果落盘,通知在锁外发送,避免慢渠道阻塞后续操作。 - async with game_sign_flow(): - results = await run_all_sign_in(force=True) + async with community_sign_flow(): + results = await run_community_sign_in(force=True) # 格式化并存储结果 - formatted = format_sign_results(results) + formatted = format_community_sign_results(results) # 合并结果(手动签到按 account_uid 替换旧数据) - result_update = Config.update_game_sign_results(formatted, replace=True) + result_update = Config.update_community_results( + formatted, replace=True + ) if isawaitable(result_update): await result_update @@ -174,9 +224,14 @@ async def manual_game_sign() -> OutBase: today = datetime.now(tz=UTC8).strftime("%Y-%m-%d") all_signed = True for uid, account in Config.ToolsConfig.GameSign_Accounts.items(): - has_credentials = has_game_sign_credentials(account) - if _get_game_sign_field(account, "Enabled") and has_credentials: - if _get_game_sign_field(account, "LastSignDate") != today: + has_credentials = has_community_credentials(account) + if ( + _get_community_account_field(account, "Enabled") + and has_credentials + ): + if _get_community_account_field( + account, "LastSignDate" + ) != today: all_signed = False break if all_signed: @@ -189,43 +244,138 @@ async def manual_game_sign() -> OutBase: ): warning_messages.append("签到未全部完成,请查看签到结果") if results and Config.ToolsConfig.get("GameSign", "NotifyEnabled"): - notify_result = await _dispatch_game_sign_notification(results) - if notify_result and notify_result.failed: + failed_channels = await _dispatch_community_notification(results) + if failed_channels: warning_messages.append( - f"部分通知发送失败:{'、'.join(notify_result.failed)}" + f"部分通知发送失败:{'、'.join(failed_channels)}" ) if warning_messages: return OutBase(status="warning", message=";".join(warning_messages)) - except GameSignInProgressError as e: - return OutBase(code=409, status="error", message=str(e)) + except CommunitySignInProgressError as e: + _log_community_api_error("游戏社区签到并发冲突", e) + return OutBase( + code=409, + status="error", + message="游戏社区签到正在执行,请稍后重试", + ) except Exception as e: + _log_community_api_error("游戏社区签到失败", e) return OutBase( - code=500, status="error", message=f"{type(e).__name__}: {str(e)}" + code=500, + status="error", + message="游戏社区签到失败,请稍后重试", ) return OutBase(message="签到完成") -# ==================== 游戏签到账号组 CRUD ==================== +@router.post( + "/community/activity/query", + tags=["Community"], + summary="查询游戏社区日常活跃度", + response_model=CommunityActivityOut, + status_code=200, +) +async def query_community_activity( + query: CommunityActivityQueryIn = Body(...), +) -> CommunityActivityOut: + """查询游戏社区日常活动,不占用签到流程锁。""" + + if Config.ToolsConfig.get("GameSign", "ActivityEnabled") is False: + return CommunityActivityOut(status="success", data=[]) + + snapshots = () + try: + from app.core.community_activity import ( + build_configured_community_activity_failures, + collect_configured_community_activity, + ) + + async with community_activity_flow(): + snapshots = await collect_configured_community_activity( + query.accountIds, + proxy=Config.proxy, + ) + except CommunityActivityInProgressError as exc: + _log_community_api_error("游戏社区日常查询并发冲突", exc) + return CommunityActivityOut( + code=409, + status="error", + message="游戏社区日常查询正在执行,请稍后重试", + ) + except ValueError as exc: + _log_community_api_error("游戏社区日常查询参数或凭据无效", exc) + return CommunityActivityOut( + code=400, + status="error", + message="游戏社区日常查询参数或凭据无效", + ) + except Exception as exc: + _log_community_api_error("游戏社区日常查询失败", exc) + snapshots = build_configured_community_activity_failures( + query.accountIds, + ) + if not snapshots: + return CommunityActivityOut( + code=500, + status="error", + message="游戏社区日常查询失败,请稍后重试", + ) + + return CommunityActivityOut( + status="warning" if any( + snapshot.status != "success" for snapshot in snapshots + ) else "success", + message="部分游戏日常查询失败" if any( + snapshot.status == "failed" for snapshot in snapshots + ) else "", + data=[ + CommunityActivitySnapshotOut( + account=snapshot.account, + accountUid=snapshot.account_uid, + game=snapshot.game, + platform=snapshot.platform, + status=snapshot.status, + completed=snapshot.completed, + target=snapshot.target, + tasks=[CommunityActivityTaskOut(**dict(task)) for task in snapshot.tasks], + resources=[ + CommunityActivityResourceOut(**dict(resource)) + for resource in snapshot.resources + ], + reason=snapshot.reason, + updatedAt=snapshot.updated_at, + roleName=snapshot.role_name, + roleUid=snapshot.role_uid, + server=snapshot.server, + source=snapshot.source, + ) + for snapshot in snapshots + ] + ) + + +# ==================== 游戏社区账号组 CRUD ==================== @router.post( "/sign/account/list", tags=["GameSign"], - summary="获取所有游戏签到账号组", + summary="获取所有游戏社区账号组", response_model=GameSignAccountsListOut, status_code=200, ) async def list_game_sign_accounts() -> GameSignAccountsListOut: - """获取所有游戏签到账号组""" + """获取所有游戏社区账号组""" try: data = await Config.get_game_sign_accounts() except Exception as e: + _log_community_api_error("获取游戏社区账号组失败", e) return GameSignAccountsListOut( code=500, status="error", - message=f"{type(e).__name__}: {str(e)}", + message="获取游戏社区账号组失败,请稍后重试", data={}, ) return GameSignAccountsListOut(data=data) @@ -234,12 +384,12 @@ async def list_game_sign_accounts() -> GameSignAccountsListOut: @router.post( "/sign/account/add", tags=["GameSign"], - summary="添加游戏签到账号组", + summary="添加游戏社区账号组", response_model=GameSignAccountCreateOut, status_code=200, ) async def add_game_sign_account() -> GameSignAccountCreateOut: - """添加游戏签到账号组""" + """添加游戏社区账号组""" try: uid, config = await Config.add_game_sign_account() @@ -249,10 +399,11 @@ async def add_game_sign_account() -> GameSignAccountCreateOut: data = GameSignAccountGroupConfig(**flat) # 新增账号无需清空结果,因为新账号没有历史结果 except Exception as e: + _log_community_api_error("添加游戏社区账号组失败", e) return GameSignAccountCreateOut( code=500, status="error", - message=f"{type(e).__name__}: {str(e)}", + message="添加游戏社区账号组失败,请稍后重试", accountId="", data=GameSignAccountGroupConfig(**{}), ) @@ -262,14 +413,14 @@ async def add_game_sign_account() -> GameSignAccountCreateOut: @router.post( "/sign/account/get", tags=["GameSign"], - summary="获取游戏签到账号组详情", + summary="获取游戏社区账号组详情", response_model=GameSignAccountCreateOut, status_code=200, ) async def get_game_sign_account( account: GameSignAccountGetIn = Body(...), ) -> GameSignAccountCreateOut: - """获取游戏签到账号组详情""" + """获取游戏社区账号组详情""" try: raw = await Config.get_game_sign_account(account.accountId) @@ -277,10 +428,11 @@ async def get_game_sign_account( flat = raw.get("GameSignAccount", raw) account_data = GameSignAccountGroupConfig(**flat) except Exception as e: + _log_community_api_error("获取游戏社区账号组详情失败", e) return GameSignAccountCreateOut( code=500, status="error", - message=f"{type(e).__name__}: {str(e)}", + message="获取游戏社区账号组详情失败,请稍后重试", accountId=account.accountId, data=GameSignAccountGroupConfig(**{}), ) @@ -290,18 +442,19 @@ async def get_game_sign_account( @router.post( "/sign/account/update", tags=["GameSign"], - summary="更新游戏签到账号组配置", + summary="更新游戏社区账号组配置", response_model=OutBase, status_code=200, ) async def update_game_sign_account( account: GameSignAccountUpdateIn = Body(...), ) -> OutBase: - """更新游戏签到账号组配置""" + """更新游戏社区账号组配置""" try: # GameSignAccountGroupConfig 是扁平格式,需包装为 {group: {name: value}} 传给 ConfigBase.set flat_data = account.data.model_dump(exclude_unset=True) + _normalize_miyoushe_device_fields(flat_data) skland_token = flat_data.get("SklandToken") if isinstance(skland_token, str) and skland_token.strip(): from app.tools.skland import validate_skland_credential @@ -309,16 +462,35 @@ async def update_game_sign_account( try: validate_skland_credential(skland_token) except ValueError as exc: + _log_community_api_error("森空岛凭据校验失败", exc) return OutBase( code=400, status="error", - message=f"森空岛凭据无效:{exc}", + message="森空岛凭据格式无效,请检查后重试", + ) + cloud_genshin_token = flat_data.get("CloudGenshinToken") + if isinstance(cloud_genshin_token, str) and cloud_genshin_token.strip(): + from app.tools.cloud_genshin import validate_cloud_genshin_token + + try: + flat_data["CloudGenshinToken"] = validate_cloud_genshin_token( + cloud_genshin_token + ) + except ValueError as exc: + _log_community_api_error("云原神凭据校验失败", exc) + return OutBase( + code=400, + status="error", + message="云原神 combo token 格式无效,请检查后重试", ) data = {"GameSignAccount": flat_data} await Config.update_game_sign_account(account.accountId, data) except Exception as e: + _log_community_api_error("更新游戏社区账号组失败", e) return OutBase( - code=500, status="error", message=f"{type(e).__name__}: {str(e)}" + code=500, + status="error", + message="更新游戏社区账号组失败,请稍后重试", ) return OutBase() @@ -326,20 +498,23 @@ async def update_game_sign_account( @router.post( "/sign/account/delete", tags=["GameSign"], - summary="删除游戏签到账号组", + summary="删除游戏社区账号组", response_model=OutBase, status_code=200, ) async def delete_game_sign_account( account: GameSignAccountDeleteIn = Body(...), ) -> OutBase: - """删除游戏签到账号组""" + """删除游戏社区账号组""" try: await Config.delete_game_sign_account(account.accountId) except Exception as e: + _log_community_api_error("删除游戏社区账号组失败", e) return OutBase( - code=500, status="error", message=f"{type(e).__name__}: {str(e)}" + code=500, + status="error", + message="删除游戏社区账号组失败,请稍后重试", ) return OutBase() @@ -347,20 +522,23 @@ async def delete_game_sign_account( @router.post( "/sign/account/reorder", tags=["GameSign"], - summary="调整游戏签到账号组顺序", + summary="调整游戏社区账号组顺序", response_model=OutBase, status_code=200, ) async def reorder_game_sign_accounts( account: GameSignAccountReorderIn = Body(...), ) -> OutBase: - """调整游戏签到账号组顺序""" + """调整游戏社区账号组顺序""" try: await Config.reorder_game_sign_accounts(account.order) except Exception as e: + _log_community_api_error("调整游戏社区账号组顺序失败", e) return OutBase( - code=500, status="error", message=f"{type(e).__name__}: {str(e)}" + code=500, + status="error", + message="调整游戏社区账号组顺序失败,请稍后重试", ) return OutBase() @@ -410,59 +588,18 @@ async def login_taygedo( }, ) except ValueError as e: - return OutBase(code=400, status="error", message=f"塔吉多登录失败:{e}") - except Exception: - # 不把请求对象、异常堆栈或上游响应内容写入日志,避免泄露密码。 + _log_community_api_error("塔吉多登录校验失败", e) return OutBase( - code=500, + code=400, status="error", - message="塔吉多登录失败,请检查账号、密码、网络或风控状态", - ) - - return OutBase(message="塔吉多登录成功,Token 已保存") - - -@router.post( - "/sign/account/skland/login", - tags=["GameSign"], - summary="森空岛手机号密码登录", - response_model=OutBase, - status_code=200, -) -async def login_skland( - credential: SklandLoginIn = Body(...), -) -> OutBase: - """一次性使用手机号和密码换取并保存森空岛凭据,不保存密码。""" - - try: - from app.tools.skland import ( - login_skland_with_password, - validate_skland_credential, - ) - - serialized = await login_skland_with_password( - credential.phone.strip(), - credential.password.get_secret_value(), - proxy=Config.proxy, + message="塔吉多登录失败,请检查账号、密码或凭据格式", ) - parsed = validate_skland_credential(serialized) - if any( - not str(parsed.get(field) or "").strip() - for field in ("oauthToken", "token", "cred") - ): - raise ValueError("森空岛登录未返回完整凭据") - await Config.update_game_sign_account( - credential.accountId, - {"GameSignAccount": {"SklandToken": serialized}}, - ) - except ValueError as e: - return OutBase(code=400, status="error", message=f"森空岛登录失败:{e}") - except Exception: - # 不把请求对象、异常堆栈或上游响应内容写入日志,避免泄露密码。 + except Exception as e: + _log_community_api_error("塔吉多登录失败", e) return OutBase( code=500, status="error", - message="森空岛登录失败,请检查手机号、密码、网络或风控状态", + message="塔吉多登录失败,请检查账号、密码、网络或风控状态", ) - return OutBase(message="森空岛登录成功,Token 已保存") + return OutBase(message="塔吉多登录成功,Token 已保存") diff --git a/app/core/community_activity.py b/app/core/community_activity.py new file mode 100644 index 000000000..67356de1b --- /dev/null +++ b/app/core/community_activity.py @@ -0,0 +1,324 @@ +# 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 + + +"""游戏社区账号组到活动快照的核心消费层。""" + +from __future__ import annotations + +from collections.abc import Sequence +from datetime import datetime + +from app.utils.constants import UTC8 +from app.utils.logger import get_logger + +from app.tools.community_activity_provider import CommunityActivityProvider +from app.tools.community_activity_roles import ( + ACTIVITY_COMMUNITY_DEFINITIONS, + CommunityActivityProviderDefinition, + CommunityActivityRole, +) +from app.tools.community_activity_transport import ( + CommunityActivityTarget, + CommunityActivityTransportError, + collect_community_activity, +) +from app.tools.community_contract import ActivityState, CommunityActivitySnapshot + +__all__ = [ + "build_configured_community_activity_failures", + "collect_configured_community_activity", +] + + +logger = get_logger("游戏社区活动") + + +def _account_value(account: object, name: str, default: object = "") -> object: + try: + return account.get("GameSignAccount", name) # type: ignore[attr-defined] + except (AttributeError, KeyError): + return default + + +def _failure_state(error: Exception) -> tuple[ActivityState, str]: + if isinstance(error, CommunityActivityTransportError): + return error.status, error.reason + if isinstance(error, ValueError): + text = str(error) + if any(marker in text for marker in ("风控", "限制", "非 JSON")): + return "limited", "社区角色列表受到上游限制" + return "unavailable", "社区角色列表响应无法识别" + return "failed", "社区角色列表请求失败" + + +def _empty_game_snapshot( + *, + account_uid: str, + account_name: str, + definition: CommunityActivityProviderDefinition, + game: str, + status: ActivityState = "empty", + reason: str = "未发现已绑定角色", + role: CommunityActivityRole | None = None, +) -> CommunityActivitySnapshot: + return CommunityActivitySnapshot( + account=account_name, + account_uid=account_uid, + game=game, + platform=definition.platform, + status=status, + reason=reason, + updated_at=datetime.now(tz=UTC8).isoformat(), + role_name=role.role_name if role is not None else "", + role_uid=role.role_uid if role is not None else "", + server=role.server if role is not None else "", + ) + + +def _targets_for_roles( + *, + roles: Sequence[CommunityActivityRole], + account_uid: str, + account_name: str, + miyoushe_device_id: str = "", + miyoushe_device_fp: str = "", +) -> tuple[CommunityActivityTarget, ...]: + return tuple( + role.to_target( + account_uid=account_uid, + account_name=account_name, + device_id=( + miyoushe_device_id + if role.platform == "米游社" and role.game == "绝区零" + else "" + ), + device_fp=( + miyoushe_device_fp + if role.platform == "米游社" and role.game == "绝区零" + else "" + ), + ) + for role in roles + ) + + +def _selected_accounts( + account_ids: Sequence[str] | None, +) -> tuple[tuple[str, object], ...]: + from app.core import Config + + requested_ids = { + str(account_id).strip() + for account_id in (account_ids or ()) + if account_id + } + selected: list[tuple[str, object]] = [] + matched_ids: set[str] = set() + for raw_uid, account in Config.ToolsConfig.GameSign_Accounts.items(): + account_uid = str(raw_uid) + if requested_ids and account_uid not in requested_ids: + continue + selected.append((account_uid, account)) + matched_ids.add(account_uid) + if requested_ids and matched_ids != requested_ids: + raise ValueError("未找到指定的游戏社区账号组") + return tuple(selected) + + +def build_configured_community_activity_failures( + account_ids: Sequence[str] | None = None, + *, + reason: str = "游戏社区日常查询失败", +) -> tuple[CommunityActivitySnapshot, ...]: + """为已配置社区构造分游戏失败结果,不执行任何上游请求。""" + + from app.tools.community_sign_provider import ( + get_community_token_field, + read_community_token, + ) + + snapshots: list[CommunityActivitySnapshot] = [] + for account_uid, account in _selected_accounts(account_ids): + if not _account_value(account, "Enabled", False): + continue + account_name = str(_account_value(account, "Name", "用户") or "用户") + for definition in ACTIVITY_COMMUNITY_DEFINITIONS: + token_field = get_community_token_field(definition.platform) + try: + configured = bool(read_community_token(account, token_field)) + except Exception: + configured = True + if not configured: + continue + snapshots.extend( + _empty_game_snapshot( + account_uid=account_uid, + account_name=account_name, + definition=definition, + game=game, + status="failed", + reason=reason, + ) + for game in definition.games + ) + return tuple(snapshots) + + +async def collect_configured_community_activity( + account_ids: Sequence[str] | None = None, + *, + proxy: str | None = None, + max_concurrency: int = 4, +) -> tuple[CommunityActivitySnapshot, ...]: + """读取已配置账号组并查询已登记社区的日常活动。""" + + from app.core import Config + from app.tools.community_sign_provider import ( + get_community_token_field, + read_community_token, + ) + + resolved_proxy = proxy if proxy is not None else Config.proxy + snapshots: list[CommunityActivitySnapshot] = [] + + for account_uid, account in _selected_accounts(account_ids): + account_name = str(_account_value(account, "Name", "用户") or "用户") + if not _account_value(account, "Enabled", False): + continue + try: + miyoushe_device_id = str( + _account_value(account, "MiyousheDeviceId", "") or "" + ).strip() + miyoushe_device_fp = str( + _account_value(account, "MiyousheDeviceFp", "") or "" + ).strip() + except Exception: + # 设备字段读取失败只限制绝区零便笺,不影响同账号的其他游戏。 + miyoushe_device_id = "" + miyoushe_device_fp = "" + + for definition in ACTIVITY_COMMUNITY_DEFINITIONS: + token_field = get_community_token_field(definition.platform) + try: + token = read_community_token(account, token_field) + except Exception: + snapshots.extend( + _empty_game_snapshot( + account_uid=account_uid, + account_name=account_name, + definition=definition, + game=game, + status="failed", + reason="社区凭据无法读取", + ) + for game in definition.games + ) + continue + if not token: + continue + + provider = CommunityActivityProvider( + platform=definition.platform, + raw_credential=token, + proxy=resolved_proxy, + ) + try: + discovered = await provider.discover_roles( + account_uid=account_uid, + account_name=account_name, + ) + except Exception as error: + status, reason = _failure_state(error) + logger.warning(f"{account_name} {definition.platform}角色发现失败: {reason}") + snapshots.extend( + _empty_game_snapshot( + account_uid=account_uid, + account_name=account_name, + definition=definition, + game=game, + status=status, + reason=reason, + ) + for game in definition.games + ) + continue + + capability = discovered.activity_capability + if capability.status == "limited": + for game in definition.games: + game_roles = discovered.roles_for_game(game) + if game_roles: + snapshots.extend( + _empty_game_snapshot( + account_uid=account_uid, + account_name=account_name, + definition=definition, + game=game, + status="limited", + reason=capability.reason, + role=role, + ) + for role in game_roles + ) + else: + snapshots.append( + _empty_game_snapshot( + account_uid=account_uid, + account_name=account_name, + definition=definition, + game=game, + ) + ) + continue + + targets = _targets_for_roles( + roles=discovered.roles, + account_uid=account_uid, + account_name=account_name, + miyoushe_device_id=miyoushe_device_id, + miyoushe_device_fp=miyoushe_device_fp, + ) + queried: tuple[CommunityActivitySnapshot, ...] = () + if targets: + queried = await collect_community_activity( + targets, + provider.request, + max_concurrency=max_concurrency, + ) + + for game in definition.games: + game_snapshots = tuple( + snapshot for snapshot in queried if snapshot.game == game + ) + if game_snapshots: + snapshots.extend(game_snapshots) + else: + snapshots.append( + _empty_game_snapshot( + account_uid=account_uid, + account_name=account_name, + definition=definition, + game=game, + ) + ) + + return tuple(snapshots) diff --git a/app/core/community_scheduler.py b/app/core/community_scheduler.py new file mode 100644 index 000000000..24c7efad4 --- /dev/null +++ b/app/core/community_scheduler.py @@ -0,0 +1,127 @@ +# 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 + + +"""社区工具调度策略,明确隔离签到和日常查询的执行边界。""" + +import asyncio +from collections.abc import Callable, Mapping +from contextlib import asynccontextmanager +from typing import Literal + + +CommunityTriggerSource = Literal[ + "scheduled", + "startup", + "task_scheduled", + "task_manual", + "task_startup", +] +TASK_COMMUNITY_SOURCES = frozenset( + {"task_scheduled", "task_manual", "task_startup"} +) +AccountCredentialChecker = Callable[[object], bool] + + +def _default_credential_checker(account: object) -> bool: + from app.tools.community import has_community_credentials + + return has_community_credentials(account) + + +def has_pending_community_account( + account: object, + today: str, + *, + has_credentials: AccountCredentialChecker | None = None, +) -> bool: + """判断账号是否启用、配置凭据且尚未完成今日社区签到。""" + + if not account.get("GameSignAccount", "Enabled"): # type: ignore[attr-defined] + return False + checker = has_credentials or _default_credential_checker + if not checker(account): + return False + return account.get("GameSignAccount", "LastSignDate") != today # type: ignore[attr-defined] + + +def all_community_accounts_signed( + accounts: Mapping[object, object], + today: str, + *, + has_credentials: AccountCredentialChecker | None = None, +) -> bool: + """判断所有具备凭据的启用账号是否已完成今日社区签到。""" + + return not any( + has_pending_community_account( + account, + today, + has_credentials=has_credentials, + ) + for _, account in accounts.items() + ) + + +def should_run_community_for_source( + *, + enabled: bool, + run_on_startup: bool, + source: CommunityTriggerSource, +) -> bool: + """判断当前配置是否允许指定触发来源执行社区签到。""" + + if not enabled: + return False + if source == "startup": + return bool(run_on_startup) + return source in TASK_COMMUNITY_SOURCES + +__all__ = [ + "TASK_COMMUNITY_SOURCES", + "CommunityActivityInProgressError", + "CommunityTriggerSource", + "all_community_accounts_signed", + "community_activity_flow", + "has_pending_community_account", + "should_run_community_for_source", +] + + +class CommunityActivityInProgressError(RuntimeError): + """社区日常查询已在执行。""" + + +_community_activity_lock = asyncio.Lock() + + +@asynccontextmanager +async def community_activity_flow(): + """保护社区日常查询自身,不占用签到流程锁。""" + + if _community_activity_lock.locked(): + raise CommunityActivityInProgressError("社区日常查询正在执行,请稍后重试") + + await _community_activity_lock.acquire() + try: + yield + finally: + _community_activity_lock.release() diff --git a/app/core/community_sign.py b/app/core/community_sign.py new file mode 100644 index 000000000..891655f2f --- /dev/null +++ b/app/core/community_sign.py @@ -0,0 +1,309 @@ +# 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 + + +"""游戏社区签到核心编排,平台请求仍由唯一 provider 注册表承载。""" + +import asyncio +from contextlib import asynccontextmanager +from contextvars import ContextVar +from datetime import datetime + +from app.tools.community_sign_provider import ( + check_community_system_time, + get_community_sign_providers, + read_community_token, + run_community_provider, +) +from app.tools.community_contract import CommunitySignInProgressError, CommunitySignResult +from app.tools.community_credentials import is_community_credential_configured +from app.utils.constants import UTC8 +from app.utils.logger import get_logger + +from .config import Config + +__all__ = [ + "CommunitySignInProgressError", + "all_enabled_community_platforms_signed", + "community_sign_flow", + "run_community_sign_in", +] + + +logger = get_logger("游戏社区签到") + +_community_sign_lock = asyncio.Lock() +_community_sign_flow_lock = asyncio.Lock() +_community_sign_lock_owner: ContextVar[asyncio.Task | None] = ContextVar( + "community_sign_lock_owner", default=None +) + + +@asynccontextmanager +async def community_sign_flow(): + """保护签到请求及结果落盘,通知由调用方在锁外发送。""" + + if _community_sign_flow_lock.locked(): + raise CommunitySignInProgressError("游戏社区签到正在执行,请稍后重试") + + await _community_sign_flow_lock.acquire() + try: + yield + finally: + _community_sign_flow_lock.release() + + +async def _enter_community_sign_lock() -> bool: + """获取全局签到锁;同一任务嵌套调用时复用已持有的锁。""" + + current_task = asyncio.current_task() + if current_task is not None and _community_sign_lock_owner.get() is current_task: + return False + + if _community_sign_lock.locked(): + raise CommunitySignInProgressError("游戏社区签到正在执行,请稍后重试") + + await _community_sign_lock.acquire() + _community_sign_lock_owner.set(current_task) + return True + + +def _exit_community_sign_lock(acquired: bool) -> None: + if not acquired: + return + _community_sign_lock_owner.set(None) + _community_sign_lock.release() + + +def all_enabled_community_platforms_signed( + results: list[dict[str, object]], + *, + account_uid: str, + enabled_platforms: list[str], +) -> bool: + """判断账号的全部已配置平台结果是否均已完成。""" + + if not enabled_platforms: + return False + + for platform in enabled_platforms: + platform_results = [ + result + for result in results + if result.get("account_uid") == account_uid + and result.get("platform") == platform + ] + if not platform_results or any( + result.get("status") not in ("成功", "已签到") + and not result.get("_completed") + for result in platform_results + ): + return False + + return True + + +async def run_community_sign_in(force: bool = False) -> list[dict[str, object]]: + """协调执行游戏社区签到,避免重复签到和重复通知。""" + + # 时间检查只提供告警,不应阻塞真实签到或占用签到锁。 + time_check_task = asyncio.create_task(check_community_system_time()) + acquired = False + try: + acquired = await _enter_community_sign_lock() + return await _run_configured_community_sign_in(force=force) + finally: + _exit_community_sign_lock(acquired) + if not time_check_task.done(): + time_check_task.cancel() + await asyncio.gather(time_check_task, return_exceptions=True) + + +async def _run_configured_community_sign_in( + force: bool = False, +) -> list[dict[str, object]]: + """执行所有已配置平台的签到。 + + 平台由凭据字段注册表驱动。同一账号的独立社区并发执行,适配器内部 + 仍自行控制请求间隔和风控策略;凭据刷新结果优先即时回写,收尾阶段再统一兜底。 + """ + + results: list[dict[str, object]] = [] + today = datetime.now(tz=UTC8).strftime("%Y-%m-%d") + + providers = get_community_sign_providers() + for uid, account in Config.ToolsConfig.GameSign_Accounts.items(): + account_name = account.get("GameSignAccount", "Name") or "默认账号" + account_enabled = account.get("GameSignAccount", "Enabled") + account_uid = str(uid) + + # 跳过已禁用的用户。 + if not account_enabled: + continue + + # 非强制模式:跳过今日已签到的用户。 + if not force: + user_last_sign = account.get("GameSignAccount", "LastSignDate") + if user_last_sign == today: + logger.debug(f"[{account_name}] 今日已签到,跳过") + continue + + tokens = { + provider.token_field: read_community_token( + account, provider.token_field + ) + for provider in providers + } + runtime_tokens = dict(tokens) + miyoushe_token = tokens.get("MiyousheToken", "") + if is_community_credential_configured("MiyousheToken", miyoushe_token): + # 云原神只在本轮从米游社 Cookie 换取临时凭据;旧字段继续作为 + # 没有米游社凭据时的兼容后备,不回写临时 token。 + runtime_tokens["CloudGenshinToken"] = miyoushe_token + configured = [ + provider + for provider in providers + if is_community_credential_configured( + provider.token_field, + runtime_tokens.get(provider.token_field, ""), + ) + ] + if not configured: + continue + + credential_update_lock = asyncio.Lock() + credential_update_failures: dict[str, str] = {} + credential_update_platforms: dict[str, tuple[str, ...]] = {} + + async def save_credential_update( + field: str, + value: str, + *, + retry: bool, + ) -> bool: + """保存轮换凭据;失败时保留旧值并返回可展示的稳定状态。""" + + updated_token = str(value or "").strip() + if not updated_token or updated_token == tokens.get(field, ""): + return True + + attempts = 2 if retry else 1 + for attempt in range(attempts): + try: + # 直接 set 不重置 LastSignDate;刷新凭据不应改变签到去重语义。 + await account.set("GameSignAccount", field, updated_token) + except Exception as error: + if attempt + 1 < attempts: + logger.warning( + f"[{account_name}] 保存{field}失败,将重试: {type(error).__name__}" + ) + continue + reason = "刷新凭据已生成,但保存失败,请稍后重试或重新登录" + credential_update_failures[field] = reason + logger.warning( + f"[{account_name}] 保存{field}失败: {type(error).__name__}" + ) + return False + else: + tokens[field] = updated_token + credential_update_failures.pop(field, None) + return True + return False + + async def persist_credential_update(field: str, value: str) -> None: + """在社区运行期间写穿轮换凭据,失败时交给收尾兜底。""" + + if not str(value or "").strip(): + return + async with credential_update_lock: + saved = await save_credential_update(field, value, retry=False) + if not saved: + # 让内置 provider 保留其既有收尾兜底,同时由本层记录失败状态。 + raise RuntimeError(f"{field}凭据保存失败") + + # 不同社区互不依赖,按注册顺序并发执行,完成后仍按固定顺序合并结果。 + provider_runs = await asyncio.gather( + *( + run_community_provider( + provider, + runtime_tokens[provider.token_field], + account_name=account_name, + account_uid=account_uid, + on_credential_update=persist_credential_update, + ) + for provider in configured + ) + ) + enabled_platforms: list[str] = [] + for provider, run in zip(configured, provider_runs): + for platform in run.platforms: + if platform not in enabled_platforms: + enabled_platforms.append(platform) + results.extend(run.results) + for field, updated_token in run.credential_updates.items(): + credential_update_platforms[field] = run.platforms or (provider.log_name,) + if not updated_token or updated_token == tokens.get(field, ""): + continue + async with credential_update_lock: + await save_credential_update(field, updated_token, retry=True) + + for field, reason in credential_update_failures.items(): + platforms = credential_update_platforms.get(field, (field,)) + provider = next( + (item for item in configured if item.token_field == field), + None, + ) + provider_name = provider.log_name if provider is not None else field + results.extend( + CommunitySignResult( + account=f"{account_name}/{platform}", + account_uid=account_uid, + game=f"{provider_name}凭据", + platform=platform, + status="失败", + reason=reason, + ).to_legacy() + for platform in platforms + ) + + # 自动签到每天只尝试一次。失败也要记住当天的尝试,避免后续 MAS 任务反复请求; + # 手动签到使用 force=True,仍只在所有已配置平台完成后更新日期。 + all_platforms_signed = all_enabled_community_platforms_signed( + results, + account_uid=account_uid, + enabled_platforms=enabled_platforms, + ) + should_mark_signed = bool(enabled_platforms) and ( + not force or all_platforms_signed + ) + if should_mark_signed: + try: + # 多账号串行签到可能跨越 0 点,写入时重新取当前日期, + # 避免把新一天的签到记成旧日期导致次日误判。 + sign_date = datetime.now(tz=UTC8).strftime("%Y-%m-%d") + await account.set("GameSignAccount", "LastSignDate", sign_date) + except Exception as error: + logger.warning(f"[{account_name}] 保存签到完成日期失败: {error}") + + if not results: + logger.info("没有配置任何社区平台") + + return results diff --git a/app/core/config.py b/app/core/config.py index a2c1974d3..82bc13685 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -91,6 +91,7 @@ UTC8, ) from app.utils.io import write_file +from app.utils.community import next_community_account_name from app.utils.paths import SOURCE_ROOT from app.utils.platform import IS_WINDOWS @@ -103,7 +104,7 @@ def _load_game_sign_result_snapshot(path: Path, *, result_date: str) -> dict[str, Any]: - """读取当天的游戏签到结果快照。""" + """读取当天的游戏社区结果快照。""" if not path.exists(): return {} @@ -111,7 +112,7 @@ def _load_game_sign_result_snapshot(path: Path, *, result_date: str) -> dict[str try: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as e: - logger.warning(f"读取游戏签到结果快照失败: {e}") + logger.warning(f"读取游戏社区结果快照失败: {e}") return {} if not isinstance(payload, dict) or payload.get("date") != result_date: @@ -119,7 +120,7 @@ def _load_game_sign_result_snapshot(path: Path, *, result_date: str) -> dict[str result = payload.get("result") if not isinstance(result, dict): - logger.warning("游戏签到结果快照格式无效,已忽略") + logger.warning("游戏社区结果快照格式无效,已忽略") return {} return result @@ -127,7 +128,7 @@ def _load_game_sign_result_snapshot(path: Path, *, result_date: str) -> dict[str def _save_game_sign_result_snapshot( path: Path | None, result: dict[str, Any], *, result_date: str ) -> None: - """原子保存游戏签到结果快照(走 ``app.utils.io.write_file``)。""" + """原子保存游戏社区结果快照(走 ``app.utils.io.write_file``)。""" if path is None: return @@ -135,7 +136,7 @@ def _save_game_sign_result_snapshot( try: write_file(path, {"date": result_date, "result": result}) except (OSError, TypeError, ValueError) as e: - logger.warning(f"保存游戏签到结果快照失败: {e}") + logger.warning(f"保存游戏社区结果快照失败: {e}") def _parse_maa_drop_statistics(logs: list[str]) -> dict[str, dict[str, int]]: @@ -295,6 +296,7 @@ def __init__(self) -> None: self.running_cycle_queue_ids: set[uuid.UUID] = set() self._stage_refresh_task: Optional[asyncio.Task] = None self._game_sign_result_date = "" + self._community_account_add_lock = asyncio.Lock() self._inject_truststore() @@ -378,12 +380,12 @@ async def init_config(self) -> None: await self.QueueConfig.connect(self.config_path / "QueueConfig.json") await self.ToolsConfig.connect(self.config_path / "ToolsConfig.json") - # 游戏签到:连接账号组 MultipleConfig + # 游戏社区:连接账号组 MultipleConfig await self.ToolsConfig.GameSign_Accounts.connect( self.config_path / "GameSignAccounts.json" ) - # 游戏签到:恢复当天的结果快照,跨日结果不继续展示 + # 游戏社区:恢复当天的结果快照,跨日结果不继续展示 today = datetime.now(tz=UTC8).strftime("%Y-%m-%d") self.ToolsConfig._game_sign_result_data = _load_game_sign_result_snapshot( self.config_path / GAME_SIGN_RESULT_FILENAME, @@ -2928,17 +2930,17 @@ async def get_tools(self) -> Dict[str, Any]: return await self.ToolsConfig.toDict() - async def update_game_sign_results( + async def update_community_results( self, formatted: dict[str, Any], *, replace: bool = False ) -> None: - """合并、持久化并广播游戏签到结果。 + """合并、持久化并广播游戏社区结果。 Args: formatted: 已按平台和账号分组的签到结果。 replace: 是否按账号 UID 替换已有结果。 """ - from app.tools.game_sign import merge_sign_results + from app.tools.community_sign_provider import merge_community_sign_results today = datetime.now(tz=UTC8).strftime("%Y-%m-%d") existing = ( @@ -2946,7 +2948,9 @@ async def update_game_sign_results( if self._game_sign_result_date == today else {} ) - result = merge_sign_results(existing, formatted, replace=replace) + result = merge_community_sign_results( + existing, formatted, replace=replace + ) self.ToolsConfig._game_sign_result_data = result self._game_sign_result_date = today _save_game_sign_result_snapshot( @@ -2967,7 +2971,14 @@ async def update_game_sign_results( ), ) except Exception as e: - logger.warning(f"广播游戏签到结果失败: {e}") + logger.warning(f"广播游戏社区结果失败: {e}") + + async def update_game_sign_results( + self, formatted: dict[str, Any], *, replace: bool = False + ) -> None: + """兼容旧调用方,转发到社区结果更新入口。""" + + await self.update_community_results(formatted, replace=replace) async def update_tools(self, data: Dict[str, Dict[str, Any]]) -> None: """更新工具设置""" @@ -2978,31 +2989,42 @@ async def update_tools(self, data: Dict[str, Dict[str, Any]]) -> None: logger.success("工具设置更新成功") - # ==================== 游戏签到账号组 CRUD ==================== + # ==================== 游戏社区账号组 CRUD ==================== async def get_game_sign_accounts( self, *, if_decrypt: bool = True ) -> Dict[str, Any]: - """获取所有游戏签到账号组""" + """获取所有游戏社区账号组""" - logger.debug("获取所有游戏签到账号组") + logger.debug("获取所有游戏社区账号组") return await self.ToolsConfig.GameSign_Accounts.toDict(if_decrypt=if_decrypt) async def add_game_sign_account(self) -> tuple[uuid.UUID, Any]: - """添加游戏签到账号组""" + """添加游戏社区账号组""" - logger.info("添加游戏签到账号组") + logger.info("添加游戏社区账号组") - uid, config = await self.ToolsConfig.GameSign_Accounts.add(GameSignAccountGroup) - return uid, config + async with self._community_account_add_lock: + existing_names = [] + for account in self.ToolsConfig.GameSign_Accounts.values(): + try: + existing_names.append(account.get("GameSignAccount", "Name")) + except (AttributeError, KeyError): + continue + account_name = next_community_account_name(existing_names) + uid, config = await self.ToolsConfig.GameSign_Accounts.add( + GameSignAccountGroup + ) + await config.set("GameSignAccount", "Name", account_name) + return uid, config async def get_game_sign_account( self, account_id: str, *, if_decrypt: bool = True ) -> Dict[str, Any]: - """获取游戏签到账号组详情""" + """获取游戏社区账号组详情""" - logger.debug(f"获取游戏签到账号组: {account_id}") + logger.debug(f"获取游戏社区账号组: {account_id}") account_uid = uuid.UUID(account_id) return await self.ToolsConfig.GameSign_Accounts[account_uid].toDict( @@ -3010,7 +3032,7 @@ async def get_game_sign_account( ) def _clear_game_sign_account_results(self, account_id: str) -> None: - """清除指定游戏签到账号的结果。""" + """清除指定游戏社区账号的结果。""" today = datetime.now(tz=UTC8).strftime("%Y-%m-%d") result = self.ToolsConfig._game_sign_result_data @@ -3042,15 +3064,15 @@ def _clear_game_sign_account_results(self, account_id: str) -> None: async def update_game_sign_account( self, account_id: str, data: Dict[str, Dict[str, Any]] ) -> None: - """更新游戏签到账号组配置""" + """更新游戏社区账号组配置""" - logger.info(f"更新游戏签到账号组: {account_id}") + logger.info(f"更新游戏社区账号组: {account_id}") account_uid = uuid.UUID(account_id) account = self.ToolsConfig.GameSign_Accounts[account_uid] - from app.tools.game_sign import GAME_SIGN_TOKEN_FIELDS + from app.tools.community_sign_provider import COMMUNITY_TOKEN_FIELDS - credential_fields = set(GAME_SIGN_TOKEN_FIELDS) + credential_fields = set(COMMUNITY_TOKEN_FIELDS) credential_changed = False for group, items in data.items(): @@ -3068,18 +3090,18 @@ async def update_game_sign_account( self._clear_game_sign_account_results(account_id) async def delete_game_sign_account(self, account_id: str) -> None: - """删除游戏签到账号组""" + """删除游戏社区账号组""" - logger.info(f"删除游戏签到账号组: {account_id}") + logger.info(f"删除游戏社区账号组: {account_id}") account_uid = uuid.UUID(account_id) await self.ToolsConfig.GameSign_Accounts.remove(account_uid) self._clear_game_sign_account_results(account_id) async def reorder_game_sign_accounts(self, order: list[str]) -> None: - """调整游戏签到账号组顺序""" + """调整游戏社区账号组顺序""" - logger.info("调整游戏签到账号组顺序") + logger.info("调整游戏社区账号组顺序") await self.ToolsConfig.GameSign_Accounts.setOrder([uuid.UUID(_) for _ in order]) diff --git a/app/core/game_sign_scheduler.py b/app/core/game_sign_scheduler.py new file mode 100644 index 000000000..ec24cdadb --- /dev/null +++ b/app/core/game_sign_scheduler.py @@ -0,0 +1,49 @@ +# 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 + + +"""旧游戏签到调度模块兼容入口,实际策略由社区调度模块承载。""" + +from .community_scheduler import ( + AccountCredentialChecker, + CommunityTriggerSource, + TASK_COMMUNITY_SOURCES, + all_community_accounts_signed, + has_pending_community_account, + should_run_community_for_source, +) + + +# Keep historical imports working while new callers use community semantics. +GameSignSource = CommunityTriggerSource +TASK_GAME_SIGN_SOURCES = TASK_COMMUNITY_SOURCES +has_pending_game_sign_account = has_pending_community_account +all_game_sign_accounts_signed = all_community_accounts_signed +should_run_game_sign_for_source = should_run_community_for_source + +__all__ = [ + "AccountCredentialChecker", + "GameSignSource", + "TASK_GAME_SIGN_SOURCES", + "all_game_sign_accounts_signed", + "has_pending_game_sign_account", + "should_run_game_sign_for_source", +] diff --git a/app/core/notify.py b/app/core/notify.py index a9cc863cc..274df22e5 100644 --- a/app/core/notify.py +++ b/app/core/notify.py @@ -26,11 +26,14 @@ """ import asyncio +import json from collections.abc import Awaitable, Callable, Iterable -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Any, Literal +from urllib.parse import urlsplit from app.core.config import Config +from app.models.config import Webhook from app.services.notification import Notify from app.utils import get_logger @@ -53,7 +56,11 @@ class NotifyPayload: append_signature: bool = True email_mode: MailMode = "网页" serverchan_text: str | None = None + webhook_text: str | None = None + markdown_text: str | None = None + webhook_image_base64: str | None = None koishi_text: str | None = None + koishi_msgtype: Literal["text", "html", "picture"] = "text" system_title: str | None = None system_message: str | None = None system_ticker: str | None = None @@ -81,6 +88,8 @@ def serverchan_content(self) -> str: if self.serverchan_text is not None: return self.serverchan_text + if self.markdown_text is not None: + return self.markdown_text text = self.text.replace("\n", "\n\n") if not self.append_signature: return text @@ -90,6 +99,35 @@ def serverchan_content(self) -> str: def webhook_content(self) -> str: """返回 Webhook 正文。""" + if self.webhook_text is not None: + return self.webhook_text + if self.markdown_text is not None: + return self.markdown_text + return self.signed_text + + def webhook_content_for(self, webhook: Webhook) -> str: + """只有明确支持 Markdown 的模板接收 Markdown 正文。""" + + if self.webhook_text is not None or self.markdown_text is None: + return self.webhook_content + try: + template = json.loads(webhook.get("Data", "Template") or "{}") + host = urlsplit(webhook.get("Data", "Url")).hostname + except (ValueError, TypeError): + return self.signed_text + if isinstance(template, dict) and ( + template.get("msgtype") == "markdown" + or template.get("template") == "markdown" + or "desp" in template + ): + return self.markdown_text + if host in { + "discord.com", + "discordapp.com", + "canary.discord.com", + "ptb.discord.com", + }: + return self.markdown_text return self.signed_text @property @@ -104,13 +142,15 @@ def koishi_content(self) -> str: def openclaw_weixin_content(self) -> str: """返回微信(iLink)正文。""" - return f"{self.title}\n\n{self.signed_text}" + text = self.signed_text + return text if text.startswith(f"【{self.title}】") else f"{self.title}\n\n{text}" @property def openclaw_qq_content(self) -> str: """返回 QQ 官方机器人正文。""" - return f"{self.title}\n\n{self.signed_text}" + text = self.signed_text + return text if text.startswith(f"【{self.title}】") else f"{self.title}\n\n{text}" @property def system_content(self) -> str: @@ -206,12 +246,18 @@ def user_target(user_config: Any) -> NotifyTarget: ) -def should_send_result(message: dict) -> bool: +def should_send_result(message: dict, *, task_info: object | None = None) -> bool: """判断代理结果是否满足全局推送时机。""" if message.get("game_sign_summary", False): return True + if task_info is not None: + from app.tools.community_notify import append_task_community_summary + + if append_task_community_summary(task_info, ""): + return True + result_time = Config.get("Notify", "SendTaskResultTime") if result_time == "任何时刻": return True @@ -419,7 +465,8 @@ def miss(channel: str) -> None: f"{target.name} Webhook {_webhook_name(uid, webhook)}", lambda w=webhook: Notify.WebhookPush( title=payload.title, - content=payload.webhook_content, + content=payload.webhook_content_for(w), + image_base64=payload.webhook_image_base64 or "", webhook=w, ), ) @@ -427,7 +474,10 @@ def miss(channel: str) -> None: if target.koishi: await attempt( f"{target.name} Koishi", - lambda: Notify.send_koishi(payload.koishi_content), + lambda: Notify.send_koishi( + payload.koishi_content, + msgtype=payload.koishi_msgtype, + ), ) if target.openclaw_weixin: @@ -455,6 +505,167 @@ def miss(channel: str) -> None: ) +async def dispatch_task_report( + payload: NotifyPayload, + targets: list[NotifyTarget], + task_info: object | None, + *, + summary_text: str = "", + attempts: int = 1, + retry_delay: float = 0, +) -> DispatchResult: + """统一附加社区结果,并按渠道维护任务报告的投递状态。 + + 专项只提供原始报告与任务信息。旧调用传入的 summary_text 继续兼容, + 已送达摘要的渠道接收后续脚本报告时不再重复摘要。 + """ + + # 社区渲染器复用 NotifyPayload,按需导入以避免模块初始化循环。 + from app.tools.community_notify import ( + append_community_summary_html, + append_task_community_summary, + detect_community_notification_format, + mark_task_community_summary_consumed, + ) + + delivered = set(getattr(task_info, "game_sign_summary_delivered", ())) + if summary_text: + clean_payload = replace( + payload, + text=payload.text.replace(summary_text, "").rstrip(), + html=( + payload.html.replace(summary_text, "").rstrip() + if payload.html is not None + else None + ), + ) + else: + clean_payload = payload + if task_info is not None: + summary_text = append_task_community_summary(task_info, "").strip() + if summary_text: + summary_markdown = append_task_community_summary( + task_info, "", output_format="markdown" + ).strip() + serverchan_body = payload.serverchan_content + serverchan_summary = summary_markdown + if payload.serverchan_text is None and payload.markdown_text is None: + # 默认签名仍放在整份报告末尾,社区摘要只负责自己的格式。 + serverchan_body = replace( + payload, append_signature=False + ).serverchan_content + if payload.append_signature: + serverchan_summary += f"{payload.signature_sep}{SIGNATURE}" + payload = replace( + payload, + text=f"{payload.text}\n\n{summary_text}", + html=( + append_community_summary_html(payload.html, summary_text) + if payload.html is not None + else None + ), + serverchan_text=f"{serverchan_body}\n\n{serverchan_summary}", + markdown_text=( + f"{payload.markdown_text}\n\n{summary_markdown}" + if payload.markdown_text is not None + else None + ), + webhook_text=( + f"{payload.webhook_text}\n\n" + + ( + summary_markdown + if detect_community_notification_format(payload.webhook_text) + == "markdown" + else summary_text + ) + if payload.webhook_text is not None + else None + ), + koishi_text=( + append_community_summary_html(payload.koishi_text, summary_text) + if payload.koishi_text is not None + and payload.koishi_msgtype == "html" + else f"{payload.koishi_text}\n\n{summary_text}" + if payload.koishi_text is not None + else None + ), + system_message=( + f"{payload.system_message}\n\n{summary_text}" + if payload.system_message is not None + else None + ), + ) + + if not summary_text: + result = await dispatch( + payload, targets, attempts=attempts, retry_delay=retry_delay + ) + else: + channels = { + channel for target in targets for channel in target_channel_names(target) + } + with_summary = await dispatch( + payload, + targets, + attempts=attempts, + retry_delay=retry_delay, + skip_channels=delivered, + ) + # 只给本轮开始前已送达摘要的渠道发原始报告,避免同一轮发送两次。 + without_summary = ( + await dispatch( + clean_payload, + targets, + attempts=attempts, + retry_delay=retry_delay, + skip_channels=channels - delivered, + ) + if delivered & channels + else DispatchResult() + ) + delivered.update(with_summary.succeeded) + if task_info is not None: + setattr(task_info, "game_sign_summary_delivered", delivered) + setattr(task_info, "game_sign_summary_pending", with_summary.failed) + if delivered & channels and not with_summary.failed: + mark_task_community_summary_consumed(task_info) + result = DispatchResult( + attempted=with_summary.attempted + without_summary.attempted, + succeeded=with_summary.succeeded + without_summary.succeeded, + failed=with_summary.failed + without_summary.failed, + ) + + await _publish_task_notification_failure(task_info, result) + return result + + +async def _publish_task_notification_failure( + task_info: object | None, result: DispatchResult +) -> None: + """把任务报告的渠道失败同步提示到当前任务页面。""" + + if not result.failed or task_info is None: + return + task_id = getattr(task_info, "task_id", None) + if not task_id: + return + + from app.core.ws import Publisher, protocol + from app.models.schema import WSTaskNoticeData + + failed_channels = tuple(dict.fromkeys(result.failed)) + title = "部分通知发送失败" if result.succeeded else "通知发送失败" + message = f"{title}:{'、'.join(failed_channels)}" + try: + await Publisher.send( + id=str(task_id), + type=protocol.TASK_NOTICE, + data=WSTaskNoticeData(level="warning", message=message), + ) + except Exception as exc: # noqa: BLE001 + logger.warning(f"发送通知失败提示到前端时出现异常: {exc}") + + async def send_test_notification() -> DispatchResult: """向全部已启用的全局渠道发送测试通知。""" diff --git a/app/core/task_manager.py b/app/core/task_manager.py index 4c8f01caa..2d4143ede 100644 --- a/app/core/task_manager.py +++ b/app/core/task_manager.py @@ -644,8 +644,8 @@ async def _run_main_task(self): "manual_task": "task_manual", "startup_task": "task_startup", }.get(self.task_info.trigger_source, "task_manual") - self.task_info.game_sign_results = await MainTimer.try_game_sign_for_task( - source=sign_source + self.task_info.community_results = ( + await MainTimer.try_community_for_task(source=sign_source) ) await self.prepare() diff --git a/app/core/timer.py b/app/core/timer.py index b9a05f246..f12e0a9a7 100644 --- a/app/core/timer.py +++ b/app/core/timer.py @@ -21,7 +21,6 @@ import asyncio from datetime import datetime -from typing import Literal from app.services import Matomo from app.utils import get_logger @@ -29,51 +28,32 @@ from app.utils.platform import IS_WINDOWS from .config import Config +from .community_scheduler import ( + TASK_COMMUNITY_SOURCES, + CommunityTriggerSource, + all_community_accounts_signed, + should_run_community_for_source, +) from .task_manager import TaskManager logger = get_logger("主业务定时器") -GameSignSource = Literal[ - "scheduled", - "startup", - "task_scheduled", - "task_manual", - "task_startup", -] -_TASK_GAME_SIGN_SOURCES = { - "task_scheduled", - "task_manual", - "task_startup", -} - - -def _has_pending_game_sign_account(account, today: str) -> bool: - """判断账号是否启用、配置了凭据且尚未完成今日签到。""" - - if not account.get("GameSignAccount", "Enabled"): - return False - from app.tools.game_sign import has_game_sign_credentials - - if not has_game_sign_credentials(account): - return False - return account.get("GameSignAccount", "LastSignDate") != today - - -def _all_game_sign_accounts_signed(accounts, today: str) -> bool: - """判断所有具备凭据的启用账号是否已完成今日签到。""" - - return not any( - _has_pending_game_sign_account(account, today) - for _, account in accounts.items() - ) - - class _MainTimer: def __init__(self): self.started = False self.second_timer: asyncio.Task[None] | None = None self.hour_timer: asyncio.Task[None] | None = None - self.game_sign_task: asyncio.Task | None = None + self.community_sign_task: asyncio.Task | None = None + + @property + def game_sign_task(self) -> asyncio.Task | None: + """兼容旧调用方,返回社区签到后台任务。""" + + return self.community_sign_task + + @game_sign_task.setter + def game_sign_task(self, task: asyncio.Task | None) -> None: + self.community_sign_task = task async def start(self): """启动定时器""" @@ -89,7 +69,7 @@ async def start(self): if Config.ToolsConfig.get("GameSign", "Enabled") and ( Config.ToolsConfig.get("GameSign", "RunOnStartup") ): - self.schedule_game_sign_for_startup() + self.schedule_community_for_startup() logger.info("主业务定时器启动") @@ -104,7 +84,7 @@ async def stop(self): for task in ( self.second_timer, self.hour_timer, - self.game_sign_task, + self.community_sign_task, ) if task is not None and not task.done() ] @@ -195,8 +175,8 @@ async def timed_start(self): ) await queue.set("Data", "LastTimedStart", curtime) - def schedule_game_sign_for_startup(self) -> None: - """Schedule one background sign-in after application startup.""" + def schedule_community_for_startup(self) -> None: + """Schedule one background community sign-in after application startup.""" if not ( Config.ToolsConfig.get("GameSign", "Enabled") @@ -204,19 +184,27 @@ def schedule_game_sign_for_startup(self) -> None: ): return - if self.game_sign_task is not None and not self.game_sign_task.done(): + if ( + self.community_sign_task is not None + and not self.community_sign_task.done() + ): logger.debug("游戏社区签到后台任务正在执行,跳过重复派发") return - task = asyncio.create_task(self.try_game_sign_for_task(source="startup")) - self.game_sign_task = task - task.add_done_callback(self._on_game_sign_check_done) + task = asyncio.create_task(self.try_community_for_task(source="startup")) + self.community_sign_task = task + task.add_done_callback(self._on_community_sign_done) - def _on_game_sign_check_done(self, task: asyncio.Task) -> None: - """清理签到任务并记录未处理异常。""" + def schedule_game_sign_for_startup(self) -> None: + """兼容旧调用方,转发到社区签到启动入口。""" + + self.schedule_community_for_startup() + + def _on_community_sign_done(self, task: asyncio.Task) -> None: + """清理社区签到任务并记录未处理异常。""" - if self.game_sign_task is task: - self.game_sign_task = None + if self.community_sign_task is task: + self.community_sign_task = None if task.cancelled(): return @@ -225,40 +213,45 @@ def _on_game_sign_check_done(self, task: asyncio.Task) -> None: except Exception as e: logger.error("游戏社区签到后台任务异常", exc_info=e) - async def _execute_game_sign( - self, *, source: GameSignSource = "scheduled" - ) -> list[dict]: - """执行游戏签到并按触发来源决定通知方式。""" - from app.tools.game_sign import ( - GameSignInProgressError, - format_sign_results, - game_sign_flow, - run_all_sign_in, + def _on_game_sign_check_done(self, task: asyncio.Task) -> None: + """兼容旧调用方,转发到社区签到完成回调。""" + + self._on_community_sign_done(task) + + async def _execute_community_sign( + self, *, source: CommunityTriggerSource = "scheduled" + ) -> list[dict[str, object]]: + """执行游戏社区签到并按触发来源决定通知方式。""" + from app.core.community_sign import ( + CommunitySignInProgressError, + community_sign_flow, + run_community_sign_in, ) + from app.tools.community import format_community_sign_results today = datetime.now(tz=UTC8).strftime("%Y-%m-%d") try: # 流程锁覆盖签到和结果落盘,通知在锁外发送。 - async with game_sign_flow(): + async with community_sign_flow(): logger.info("开始执行游戏社区签到") - results = await run_all_sign_in(force=False) + results = await run_community_sign_in(force=False) # 如果所有用户都已签到(无新结果),保留已有结果 if not results: logger.info("所有用户今日已签到,跳过") - if _all_game_sign_accounts_signed( + if all_community_accounts_signed( Config.ToolsConfig.GameSign_Accounts, today ): await Config.ToolsConfig.set("GameSign", "LastSignDate", today) return [] # 格式化并合并结果 - formatted = format_sign_results(results) - await Config.update_game_sign_results(formatted) + formatted = format_community_sign_results(results) + await Config.update_community_results(formatted) # 检查是否所有用户都已签到,更新全局 LastSignDate - if _all_game_sign_accounts_signed( + if all_community_accounts_signed( Config.ToolsConfig.GameSign_Accounts, today ): await Config.ToolsConfig.set("GameSign", "LastSignDate", today) @@ -266,23 +259,24 @@ async def _execute_game_sign( logger.success("游戏社区签到执行完成") # 任务触发的结果由任务完成通知消费;其它自动来源单独发送。 - if source not in _TASK_GAME_SIGN_SOURCES and Config.ToolsConfig.get( - "GameSign", "NotifyEnabled" + if ( + source not in TASK_COMMUNITY_SOURCES + and Config.ToolsConfig.get("GameSign", "NotifyEnabled") ): - from app.tools.game_sign_notify import push_game_sign_notification + from app.tools.community_notify import push_community_notification try: - result = await push_game_sign_notification(results) + failed_channels = await push_community_notification(results) except Exception as exc: - logger.warning(f"游戏签到完成,但通知服务异常: {exc}") + logger.warning(f"游戏社区签到完成,但通知服务异常: {exc}") else: - if result.failed: + if failed_channels: logger.warning( - f"游戏签到结果通知部分失败: {'、'.join(result.failed)}" + f"游戏社区结果通知部分失败: {'、'.join(failed_channels)}" ) return results - except GameSignInProgressError: + except CommunitySignInProgressError: logger.info("游戏社区签到正在执行,跳过本次触发") except Exception as e: logger.error(f"游戏社区签到执行失败: {e}") @@ -290,9 +284,9 @@ async def _execute_game_sign( logger.exception("游戏社区签到执行异常堆栈") return [] - async def try_game_sign_for_task( - self, *, source: GameSignSource | None = None - ) -> list[dict]: + async def try_community_for_task( + self, *, source: CommunityTriggerSource | None = None + ) -> list[dict[str, object]]: """执行 MAS 自动签到并返回结果。 ``task`` 结果由任务完成通知汇总,``startup`` 结果独立通知。 @@ -300,22 +294,29 @@ async def try_game_sign_for_task( if source is None: source = "task_manual" - if not Config.ToolsConfig.get("GameSign", "Enabled"): - return [] - - if source == "startup": - if not Config.ToolsConfig.get("GameSign", "RunOnStartup"): - return [] - elif source not in _TASK_GAME_SIGN_SOURCES: + if not should_run_community_for_source( + enabled=Config.ToolsConfig.get("GameSign", "Enabled"), + run_on_startup=Config.ToolsConfig.get("GameSign", "RunOnStartup"), + source=source, + ): return [] today = datetime.now(tz=UTC8).strftime("%Y-%m-%d") # 快速检查:是否没有待处理账号 - if _all_game_sign_accounts_signed(Config.ToolsConfig.GameSign_Accounts, today): + if all_community_accounts_signed( + Config.ToolsConfig.GameSign_Accounts, today + ): return [] - return await self._execute_game_sign(source=source) + return await self._execute_community_sign(source=source) + + async def try_game_sign_for_task( + self, *, source: CommunityTriggerSource | None = None + ) -> list[dict[str, object]]: + """兼容旧调用方,转发到社区签到任务入口。""" + + return await self.try_community_for_task(source=source) MainTimer = _MainTimer() diff --git a/app/models/config.py b/app/models/config.py index 44c1f60e6..19f5ed164 100644 --- a/app/models/config.py +++ b/app/models/config.py @@ -3952,7 +3952,7 @@ def __init__(self) -> None: class GameSignAccountGroup(ConfigBase): - """游戏签到账号组配置""" + """游戏社区账号组配置""" def __init__(self) -> None: @@ -3964,6 +3964,18 @@ def __init__(self) -> None: self.MiyousheToken = ConfigItem( "GameSignAccount", "MiyousheToken", "", EncryptValidator() ) + ## GameSignAccount - 米游社安卓设备 ID (DPAPI 加密,仅用于绝区零便笺) + self.MiyousheDeviceId = ConfigItem( + "GameSignAccount", "MiyousheDeviceId", "", EncryptValidator() + ) + ## GameSignAccount - 米游社安卓设备指纹 (DPAPI 加密,仅用于绝区零便笺) + self.MiyousheDeviceFp = ConfigItem( + "GameSignAccount", "MiyousheDeviceFp", "", EncryptValidator() + ) + ## GameSignAccount - 云原神 combo token (DPAPI 加密) + self.CloudGenshinToken = ConfigItem( + "GameSignAccount", "CloudGenshinToken", "", EncryptValidator() + ) ## GameSignAccount - 库街区登录凭证 (DPAPI 加密) self.KuroToken = ConfigItem( "GameSignAccount", "KuroToken", "", EncryptValidator() @@ -4029,6 +4041,10 @@ def __init__(self) -> None: self.GameSign_NotifyEnabled = ConfigItem( "GameSign", "NotifyEnabled", False, BoolValidator() ) + ## GameSign - 启用日常便笺 + self.GameSign_ActivityEnabled = ConfigItem( + "GameSign", "ActivityEnabled", True, BoolValidator() + ) ## GameSign - 旧版签到窗口起点(保留用于读取历史配置,不参与调度) self.GameSign_WindowStart = ConfigItem( "GameSign", "WindowStart", "08:00", DateTimeValidator("%H:%M") @@ -4114,14 +4130,14 @@ def arknights_pc_keys(self) -> list[str]: ] def game_sign_status(self) -> str: - """游戏签到状态标签""" + """游戏社区状态标签""" if not self.get("GameSign", "Enabled"): return TagItem(text="未启用", color="gray").model_dump_json() return TagItem(text="已启用", color="green").model_dump_json() def game_sign_result(self) -> str: - """游戏签到结果 JSON""" + """游戏社区结果 JSON""" return json.dumps(self._game_sign_result_data, ensure_ascii=False) diff --git a/app/models/schema.py b/app/models/schema.py index 3bc0ec814..bdd141fb5 100644 --- a/app/models/schema.py +++ b/app/models/schema.py @@ -583,8 +583,9 @@ class ToolsConfig_ArknightsPC(BaseModel): class ToolsConfig_GameSign(BaseModel): - Enabled: bool | None = Field(default=None, description="是否启用游戏签到") + Enabled: bool | None = Field(default=None, description="是否启用游戏社区") NotifyEnabled: bool | None = Field(default=None, description="签到后是否发送通知") + ActivityEnabled: bool | None = Field(default=None, description="是否启用日常便笺") WindowStart: str | None = Field(default=None, description="签到窗口起点 HH:mm") WindowEnd: str | None = Field(default=None, description="签到窗口终点 HH:mm") RunOnStartup: bool | None = Field(default=None, description="启动时运行") @@ -597,18 +598,33 @@ class ToolsConfig_GameSign(BaseModel): class GameSignAccountGroupConfig(BaseModel): - """游戏签到账号组配置""" + """游戏社区账号组配置""" Name: str | None = Field(default=None, description="账号组名称") Enabled: bool | None = Field(default=None, description="是否启用") MiyousheToken: str | None = Field(default=None, description="米游社登录凭证") + MiyousheDeviceId: str | None = Field( + default=None, + description="米游社安卓设备 ID,仅用于绝区零便笺", + repr=False, + ) + MiyousheDeviceFp: str | None = Field( + default=None, + description="米游社安卓设备指纹,仅用于绝区零便笺", + repr=False, + ) + CloudGenshinToken: str | None = Field( + default=None, + description="云原神 combo token", + ) KuroToken: str | None = Field(default=None, description="库街区登录凭证") SklandToken: str | None = Field(default=None, description="森空岛登录凭证") TaygedoToken: str | None = Field(default=None, description="塔吉多及云异环登录凭证") + LastSignDate: str | None = Field(default=None, description="账号组上次签到日期") class GameSignAccountCreateOut(OutBase): - """游戏签到账号组创建响应""" + """游戏社区账号组创建响应""" accountId: str = Field(default="", description="账号组 UUID") data: GameSignAccountGroupConfig = Field( @@ -617,36 +633,116 @@ class GameSignAccountCreateOut(OutBase): class GameSignAccountGetIn(BaseModel): - """游戏签到账号组查询请求""" + """游戏社区账号组查询请求""" accountId: str = Field(..., description="账号组 UUID") +class GameSignAccountInstanceOut(BaseModel): + """游戏社区账号组顺序项。""" + + uid: str = Field(..., description="账号组 UUID") + type: str = Field(..., description="账号组配置类型") + + +class GameSignAccountDataOut(BaseModel): + """动态 UUID 键对应的游戏社区账号组数据。""" + + GameSignAccount: GameSignAccountGroupConfig = Field( + ..., description="账号组配置" + ) + + class GameSignAccountsListOut(OutBase): - """游戏签到账号组列表响应""" + """游戏社区账号组列表响应""" - data: Dict[str, Any] = Field(default_factory=dict, description="账号组列表") + data: Dict[ + str, + list[GameSignAccountInstanceOut] | GameSignAccountDataOut, + ] = Field(default_factory=dict, description="账号组列表") class GameSignAccountUpdateIn(BaseModel): - """游戏签到账号组更新请求""" + """游戏社区账号组更新请求""" accountId: str = Field(..., description="账号组 UUID") data: GameSignAccountGroupConfig = Field(..., description="账号组配置") class GameSignAccountDeleteIn(BaseModel): - """游戏签到账号组删除请求""" + """游戏社区账号组删除请求""" accountId: str = Field(..., description="账号组 UUID") class GameSignAccountReorderIn(BaseModel): - """游戏签到账号组排序请求""" + """游戏社区账号组排序请求""" order: list[str] = Field(..., description="账号组 UUID 顺序列表") +class CommunityActivityQueryIn(BaseModel): + """游戏社区日常查询请求。""" + + accountIds: list[str] | None = Field( + default=None, + description="指定账号组 UUID 列表;为空时查询全部已配置账号组", + ) + + +class CommunityActivityTaskOut(BaseModel): + """日常活动中的单项任务。""" + + name: str = Field(..., description="任务名称") + completed: int = Field(..., description="已完成数量") + target: int = Field(..., description="目标数量") + status: str = Field(..., description="任务状态") + period: str = Field(default="daily", description="任务周期") + + +class CommunityActivityResourceOut(BaseModel): + """日常活动中的可用资源。""" + + name: str = Field(..., description="资源名称") + current: int = Field(..., description="当前数量") + target: int = Field(..., description="容量上限") + status: str = Field(..., description="资源状态") + + +class CommunityActivitySnapshotOut(BaseModel): + """单个游戏角色的日常活动快照。""" + + account: str = Field(..., description="账号组名称") + accountUid: str = Field(..., description="账号组 UUID") + game: str = Field(..., description="游戏名称") + platform: str = Field(..., description="社区平台名称") + status: Literal[ + "success", "empty", "limited", "unavailable", "failed" + ] = Field(..., description="活动查询状态") + completed: int | None = Field(default=None, description="已完成数量") + target: int | None = Field(default=None, description="目标数量") + tasks: list[CommunityActivityTaskOut] = Field( + default_factory=list, description="每日任务" + ) + resources: list[CommunityActivityResourceOut] = Field( + default_factory=list, description="可用资源" + ) + reason: str = Field(default="", description="失败或受限原因") + updatedAt: str = Field(default="", description="查询时间") + roleName: str = Field(default="", description="角色名称") + roleUid: str = Field(default="", description="角色 UID") + server: str = Field(default="", description="角色区服") + source: str = Field(default="", description="已确认的数据来源路径") + + +class CommunityActivityOut(OutBase): + """游戏社区日常活动查询响应。""" + + data: list[CommunityActivitySnapshotOut] = Field( + default_factory=list, description="按账号和游戏拆分的活动快照" + ) + + class TaygedoLoginIn(BaseModel): """塔吉多一次性账号密码登录请求。""" @@ -655,14 +751,6 @@ class TaygedoLoginIn(BaseModel): password: SecretStr = Field(..., min_length=1, description="塔吉多账号密码") -class SklandLoginIn(BaseModel): - """森空岛一次性手机号密码登录请求。""" - - accountId: str = Field(..., description="账号组 UUID") - phone: str = Field(..., min_length=1, description="鹰角网络通行证手机号") - password: SecretStr = Field(..., min_length=1, description="鹰角网络通行证密码") - - class ToolsConfig(BaseModel): ArknightsPC: ToolsConfig_ArknightsPC | None = Field( default=None, description="明日方舟PC工具配置" diff --git a/app/models/task.py b/app/models/task.py index 531e55b5e..e3b15fa88 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -142,13 +142,35 @@ class TaskItem(ABC): default_factory=list, repr=False ) # 循环运行的待运行条目预览 trigger_source: TaskTriggerSource = "manual_task" # MAS 任务触发来源 - game_sign_results: list[dict] = field(default_factory=list, repr=False) + game_sign_results: list[dict[str, object]] = field( + default_factory=list, repr=False + ) game_sign_summary_consumed: bool = field(default=False, repr=False) _change_task: asyncio.Task[None] | None = field( default=None, init=False, repr=False, compare=False ) _change_dirty: bool = field(default=False, init=False, repr=False, compare=False) + @property + def community_results(self) -> list[dict[str, object]]: + """读取社区签到结果,底层继续使用历史任务字段。""" + + return self.game_sign_results + + @community_results.setter + def community_results(self, value: list[dict[str, object]]) -> None: + self.game_sign_results = value + + @property + def community_summary_consumed(self) -> bool: + """读取社区汇总消费状态,保持旧任务协议字段不变。""" + + return self.game_sign_summary_consumed + + @community_summary_consumed.setter + def community_summary_consumed(self, value: bool) -> None: + self.game_sign_summary_consumed = value + def __setattr__(self, name, value): super().__setattr__(name, value) diff --git a/app/services/notification.py b/app/services/notification.py index c98333daa..96c149813 100644 --- a/app/services/notification.py +++ b/app/services/notification.py @@ -282,7 +282,14 @@ async def send_openclaw_qq(self, title: str, content: str) -> None: await openclaw_qq_manager.send(title=title, content=content) - async def WebhookPush(self, title: str, content: str, webhook: Webhook) -> None: + async def WebhookPush( + self, + title: str, + content: str, + webhook: Webhook, + *, + image_base64: str = "", + ) -> None: """ Webhook 推送通知 @@ -294,6 +301,8 @@ async def WebhookPush(self, title: str, content: str, webhook: Webhook) -> None: 通知内容 webhook: Webhook Webhook配置对象 + image_base64: str, optional + 可选图片的纯 Base64 数据,供 OneBot 等协议使用 """ if not webhook.get("Info", "Enabled"): return @@ -313,6 +322,7 @@ async def WebhookPush(self, title: str, content: str, webhook: Webhook) -> None: template_vars = { "title": title, "content": content, + "image_base64": image_base64, "datetime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "date": datetime.now().strftime("%Y-%m-%d"), "time": datetime.now().strftime("%H:%M:%S"), diff --git a/app/task/BetterGI/manager.py b/app/task/BetterGI/manager.py index bb791aa33..96a37e662 100644 --- a/app/task/BetterGI/manager.py +++ b/app/task/BetterGI/manager.py @@ -25,11 +25,6 @@ from app.models.config import BetterGIConfig, BetterGIUserConfig from app.models.ConfigBase import MultipleConfig from app.models.task import ScriptItem, TaskExecuteBase, UserItem -from app.services import Notify -from app.tools.game_sign_notify import ( - append_task_game_sign_summary, - mark_task_game_sign_summary_consumed, -) from app.utils import get_logger from app.utils.constants import TASK_MODE_ZH @@ -229,10 +224,6 @@ async def final_task(self): f"{datetime.now().strftime('%m-%d')} | " f"{self.script_info.name or '空白'}的{task_mode}任务报告" ) - task_result = append_task_game_sign_summary( - self.task_info, self.script_info.result - ) - has_game_sign_summary = task_result != self.script_info.result result = { "title": f"{task_mode}任务报告", "script_name": self.script_info.name or "空白", @@ -240,26 +231,13 @@ async def final_task(self): "end_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "completed_count": over_count, "uncompleted_count": error_count + wait_count, - "result": task_result, - "game_sign_summary": has_game_sign_summary, + "result": self.script_info.result, } - await Notify.push_plyer( - title.replace("报告", "已完成!"), - ( - f"已完成用户数: {over_count}, " - f"未完成用户数: {error_count + wait_count}" - ), - ( - f"已完成用户数: {over_count}, " - f"未完成用户数: {error_count + wait_count}" - ), - 10, - ) try: - await push_notification("代理结果", title, result) - if has_game_sign_summary: - mark_task_game_sign_summary_consumed(self.task_info) + await push_notification( + "代理结果", title, result, task_info=self.task_info + ) except Exception as e: logger.opt(exception=True).warning(f"推送代理结果时出现异常: {e}") await Config.send_websocket_message( diff --git a/app/task/BetterGI/tools/notify.py b/app/task/BetterGI/tools/notify.py index 0b3df5086..fa4970ffd 100644 --- a/app/task/BetterGI/tools/notify.py +++ b/app/task/BetterGI/tools/notify.py @@ -19,6 +19,13 @@ from datetime import datetime from app.core import Config +from app.core.notify import ( + DispatchResult, + NotifyPayload, + dispatch_task_report, + global_target, + should_send_result, +) from app.models.config import BetterGIUserConfig from app.services import Notify from app.utils import get_logger @@ -80,7 +87,8 @@ async def push_notification( title: str, message: dict, user_config: BetterGIUserConfig | None = None, -) -> None: + task_info: object | None = None, +) -> DispatchResult: """通过全局或用户配置的渠道推送 BetterGI 任务报告。""" logger.info(f"开始推送通知, 模式: {mode}, 标题: {title}") @@ -90,7 +98,7 @@ async def push_notification( user_config.get("Notify", "Enabled") and user_config.get("Notify", "IfSendStatistic") ): - return + return DispatchResult() # 简略版(所有渠道的兜底):仅 4 字段汇总,旧版格式 message_text = ( @@ -142,18 +150,16 @@ async def push_notification( for webhook in user_config.Notify_CustomWebhooks.values(): # Webhook 目标多为聊天机器人(企业微信 2048 字节 / Discord 2000 字符 / Telegram # 4096 字符),有真实字数瓶颈 → 用回简略版,避免分步表塞爆被静默丢弃。 - await Notify.WebhookPush(title, f"{message_text}\n\nAUTO-MAS 敬上", webhook) - return + await Notify.WebhookPush( + title, f"{message_text}\n\nAUTO-MAS 敬上", webhook + ) + return DispatchResult() if mode != "代理结果": - return + return DispatchResult() - result_time_setting = Config.get("Notify", "SendTaskResultTime") - if not message.get("game_sign_summary", False) and ( - result_time_setting != "任何时刻" - and (result_time_setting != "仅失败时" or message["uncompleted_count"] == 0) - ): - return + if not should_send_result(message, task_info=task_info): + return DispatchResult() message_text = ( f"任务开始时间: {message['start_time']}, 结束时间: {message['end_time']}\n" @@ -161,23 +167,23 @@ async def push_notification( f"未完成数: {message['uncompleted_count']}\n\n" f"{message['result']}" ) - message_html = Config.notify_env.get_template("general_result.html").render(message) - serverchan_message = message_text.replace("\n", "\n\n") - - if Config.get("Notify", "IfSendMail"): - await Notify.send_mail( - "网页", title, message_html, Config.get("Notify", "ToAddress") - ) - - if Config.get("Notify", "IfServerChan"): - await Notify.ServerChanPush( - title, - f"{serverchan_message}\n\nAUTO-MAS 敬上", - Config.get("Notify", "ServerChanKey"), - ) - - for webhook in Config.Notify_CustomWebhooks.values(): - await Notify.WebhookPush(title, f"{message_text}\n\nAUTO-MAS 敬上", webhook) - - if Config.get("Notify", "IfKoishiSupport"): - await Notify.send_koishi(f"{title}\n\n{message_text}\n\nAUTO-MAS 敬上") + message_html = Config.notify_env.get_template("general_result.html").render( + message + ) + counts = ( + f"已完成用户数: {message['completed_count']}, " + f"未完成用户数: {message['uncompleted_count']}" + ) + return await dispatch_task_report( + NotifyPayload( + title=title, + text=message_text, + html=message_html, + system_title=message.get("system_title") or title.replace("报告", "已完成!"), + system_message=counts, + system_ticker=counts, + system_timeout=10, + ), + [global_target(include_system=True)], + task_info, + ) diff --git a/app/task/HSR/manager.py b/app/task/HSR/manager.py index 7139f319d..ff31a5a3c 100644 --- a/app/task/HSR/manager.py +++ b/app/task/HSR/manager.py @@ -33,10 +33,6 @@ from app.models.ConfigBase import MultipleConfig from app.models.schema import WSTaskNoticeData from app.models.task import LogRecord, ScriptItem, TaskExecuteBase, UserItem -from app.tools.game_sign_notify import ( - append_task_game_sign_summary, - finalize_task_game_sign_notification, -) from app.utils import get_logger from app.utils.constants import TASK_MODE_ZH, UTC4 @@ -946,10 +942,6 @@ async def _push_result_notification(self) -> None: f"{datetime.now().strftime('%m-%d')} | " f"{self.script_info.name or '空白'}的{task_mode}任务报告" ) - task_result = append_task_game_sign_summary( - self.task_info, self.script_info.result - ) - has_game_sign_summary = task_result != self.script_info.result result = { "title": f"{task_mode}任务报告", "script_name": self.script_info.name or "空白", @@ -957,21 +949,17 @@ async def _push_result_notification(self) -> None: "end_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "completed_count": over_count, "uncompleted_count": uncompleted_count, - "result": task_result, - "game_sign_summary": has_game_sign_summary, + "result": self.script_info.result, } try: - push_result = await push_notification( + await push_notification( mode="代理结果", title=title, message=result, user_config=None, task_info=self.task_info, ) - finalize_task_game_sign_notification( - self.task_info, has_game_sign_summary, push_result - ) except Exception as e: # noqa: BLE001 logger.opt(exception=True).warning(f"推送 HSR 代理结果时出现异常: {e}") await self._send_notification_error(f"推送 HSR 代理结果时出现异常: {e}") diff --git a/app/task/M9A/manager.py b/app/task/M9A/manager.py index 1d9bf2d64..84d2a9ce0 100644 --- a/app/task/M9A/manager.py +++ b/app/task/M9A/manager.py @@ -33,10 +33,6 @@ from app.models.schema import WSTaskNoticeData from app.models.task import ScriptItem, TaskExecuteBase, UserItem from app.services import System -from app.tools.game_sign_notify import ( - append_task_game_sign_summary, - finalize_task_game_sign_notification, -) from app.utils import get_logger from app.utils.constants import TASK_MODE_ZH from app.utils.io import read_file, write_file @@ -334,10 +330,6 @@ async def final_task(self): ) title = f"{datetime.now().strftime('%m-%d')} | {self.script_info.name or '空白'}的{TASK_MODE_ZH[self.task_info.mode]}任务报告" - task_result = append_task_game_sign_summary( - self.task_info, self.script_info.result - ) - has_game_sign_summary = task_result != self.script_info.result result = { "title": f"{TASK_MODE_ZH[self.task_info.mode]}任务报告", "script_name": self.script_info.name or "空白", @@ -345,21 +337,17 @@ async def final_task(self): "end_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "completed_count": over_count, "uncompleted_count": error_count + wait_count, - "result": task_result, - "game_sign_summary": has_game_sign_summary, + "result": self.script_info.result, } try: - push_result = await push_notification( + await push_notification( mode="代理结果", title=title, message=result, user_config=None, task_info=self.task_info, ) - finalize_task_game_sign_notification( - self.task_info, has_game_sign_summary, push_result - ) except Exception as e: logger.opt(exception=True).warning(f"推送代理结果时出现异常: {e}") await Publisher.send( diff --git a/app/task/MAA/manager.py b/app/task/MAA/manager.py index f2354af1a..9b1536272 100644 --- a/app/task/MAA/manager.py +++ b/app/task/MAA/manager.py @@ -31,10 +31,6 @@ from app.models.ConfigBase import MultipleConfig from app.models.schema import WSTaskNoticeData from app.models.task import ScriptItem, TaskExecuteBase, UserItem -from app.tools.game_sign_notify import ( - append_task_game_sign_summary, - finalize_task_game_sign_notification, -) from app.utils import get_logger from app.utils.constants import TASK_MODE_ZH @@ -219,10 +215,6 @@ async def final_task(self): ) title = f"{datetime.now().strftime('%m-%d')} | {self.script_info.name or '空白'}的{TASK_MODE_ZH[self.task_info.mode]}任务报告" - task_result = append_task_game_sign_summary( - self.task_info, self.script_info.result - ) - has_game_sign_summary = task_result != self.script_info.result result = { "title": f"{TASK_MODE_ZH[self.task_info.mode]}任务报告", "script_name": self.script_info.name or "空白", @@ -230,21 +222,17 @@ async def final_task(self): "end_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "completed_count": over_count, "uncompleted_count": error_count + wait_count, - "result": task_result, - "game_sign_summary": has_game_sign_summary, + "result": self.script_info.result, } try: - push_result = await push_notification( + await push_notification( mode="代理结果", title=title, message=result, user_config=None, task_info=self.task_info, ) - finalize_task_game_sign_notification( - self.task_info, has_game_sign_summary, push_result - ) except Exception as e: logger.opt(exception=True).warning(f"推送代理结果时出现异常: {e}") await Publisher.send( diff --git a/app/task/MaaEnd/manager.py b/app/task/MaaEnd/manager.py index bf376e79f..7b9e0c0f1 100644 --- a/app/task/MaaEnd/manager.py +++ b/app/task/MaaEnd/manager.py @@ -31,10 +31,6 @@ from app.models.ConfigBase import MultipleConfig from app.models.schema import WSTaskNoticeData from app.models.task import ScriptItem, TaskExecuteBase, UserItem -from app.tools.game_sign_notify import ( - append_task_game_sign_summary, - finalize_task_game_sign_notification, -) from app.utils import get_logger from app.utils.constants import TASK_MODE_ZH @@ -282,10 +278,6 @@ async def final_task(self): ) title = f"{datetime.now().strftime('%m-%d')} | {self.script_info.name or '空白'}的{TASK_MODE_ZH[self.task_info.mode]}任务报告" - task_result = append_task_game_sign_summary( - self.task_info, self.script_info.result - ) - has_game_sign_summary = task_result != self.script_info.result result = { "title": f"{TASK_MODE_ZH[self.task_info.mode]}任务报告", "script_name": self.script_info.name or "空白", @@ -293,21 +285,17 @@ async def final_task(self): "end_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "completed_count": over_count, "uncompleted_count": error_count + wait_count, - "result": task_result, - "game_sign_summary": has_game_sign_summary, + "result": self.script_info.result, } try: - push_result = await push_notification( + await push_notification( mode="代理结果", title=title, message=result, user_config=None, task_info=self.task_info, ) - finalize_task_game_sign_notification( - self.task_info, has_game_sign_summary, push_result - ) except Exception as e: logger.opt(exception=True).warning(f"推送代理结果时出现异常: {e}") await Publisher.send( diff --git a/app/task/MaaFW/embedded_manager.py b/app/task/MaaFW/embedded_manager.py index de0091135..57b6b9f46 100644 --- a/app/task/MaaFW/embedded_manager.py +++ b/app/task/MaaFW/embedded_manager.py @@ -58,10 +58,6 @@ resolve_update_credentials, ) from app.task.MaaFW.tools.notify import push_notification -from app.tools.game_sign_notify import ( - append_task_game_sign_summary, - finalize_task_game_sign_notification, -) from app.utils import get_logger from app.utils.constants import TASK_MODE_ZH from app.utils.security import sanitize_log_message @@ -761,10 +757,6 @@ async def final_task(self) -> None: f"{datetime.now().strftime('%m-%d')} | " f"{self.script_info.name or '空白'}的{TASK_MODE_ZH[self.task_info.mode]}任务报告" ) - task_result = append_task_game_sign_summary( - self.task_info, self.script_info.result - ) - has_game_sign_summary = task_result != self.script_info.result result = { "title": f"{TASK_MODE_ZH[self.task_info.mode]}任务报告", "script_name": self.script_info.name or "空白", @@ -772,19 +764,15 @@ async def final_task(self) -> None: "end_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "completed_count": len(completed_users), "uncompleted_count": len(error_users), - "result": task_result, - "game_sign_summary": has_game_sign_summary, + "result": self.script_info.result, } try: - push_result = await push_notification( + await push_notification( mode="代理结果", title=title, message=result, task_info=self.task_info, ) - finalize_task_game_sign_notification( - self.task_info, has_game_sign_summary, push_result - ) except Exception as exc: # noqa: BLE001 logger.opt(exception=True).warning(f"推送 MFW 代理结果时出现异常: {exc}") await Publisher.send( diff --git a/app/task/OkNte/manager.py b/app/task/OkNte/manager.py index 3b5adc553..e78c208a2 100644 --- a/app/task/OkNte/manager.py +++ b/app/task/OkNte/manager.py @@ -28,10 +28,6 @@ from app.models.ConfigBase import MultipleConfig from app.models.schema import WSTaskNoticeData from app.models.task import ScriptItem, TaskExecuteBase, UserItem -from app.tools.game_sign_notify import ( - append_task_game_sign_summary, - finalize_task_game_sign_notification, -) from app.tools.push_log import build_user_result_text from app.utils import ProcessManager, get_logger from app.utils.constants import TASK_MODE_ZH @@ -307,10 +303,6 @@ async def final_task(self): user_result_text = build_user_result_text( self.script_info.user_list, has_uncompleted ) - task_result = append_task_game_sign_summary( - self.task_info, user_result_text - ) - has_game_sign_summary = task_result != user_result_text result = { "title": f"{TASK_MODE_ZH[self.task_info.mode]}任务报告", "script_name": self.script_info.name or "空白", @@ -318,21 +310,17 @@ async def final_task(self): "end_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "completed_count": over_count, "uncompleted_count": error_count + wait_count, - "result": task_result, - "game_sign_summary": has_game_sign_summary, + "result": user_result_text, } try: - push_result = await push_notification( + await push_notification( mode="代理结果", title=title, message=result, user_config=None, task_info=self.task_info, ) - finalize_task_game_sign_notification( - self.task_info, has_game_sign_summary, push_result - ) except Exception as e: logger.opt(exception=True).warning(f"推送代理结果时出现异常: {e}") await Publisher.send( diff --git a/app/task/Okww/manager.py b/app/task/Okww/manager.py index 36cf8d60c..b1779d0d2 100644 --- a/app/task/Okww/manager.py +++ b/app/task/Okww/manager.py @@ -28,10 +28,6 @@ from app.models.ConfigBase import MultipleConfig from app.models.schema import WSTaskNoticeData from app.models.task import ScriptItem, TaskExecuteBase, UserItem -from app.tools.game_sign_notify import ( - append_task_game_sign_summary, - finalize_task_game_sign_notification, -) from app.tools.push_log import build_user_result_text from app.utils import ProcessManager, get_logger from app.utils.constants import TASK_MODE_ZH @@ -367,10 +363,6 @@ async def final_task(self): user_result_text = build_user_result_text( self.script_info.user_list, has_uncompleted ) - task_result = append_task_game_sign_summary( - self.task_info, user_result_text - ) - has_game_sign_summary = task_result != user_result_text result = { "title": f"{task_mode}任务报告", "script_name": self.script_info.name or "空白", @@ -378,21 +370,17 @@ async def final_task(self): "end_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "completed_count": over_count, "uncompleted_count": error_count + wait_count, - "result": task_result, - "game_sign_summary": has_game_sign_summary, + "result": user_result_text, } try: - push_result = await push_notification( + await push_notification( mode="代理结果", title=title, message=result, user_config=None, task_info=self.task_info, ) - finalize_task_game_sign_notification( - self.task_info, has_game_sign_summary, push_result - ) except Exception as e: logger.opt(exception=True).warning(f"推送代理结果时出现异常: {e}") await Publisher.send( diff --git a/app/task/SRC/manager.py b/app/task/SRC/manager.py index c7d417a93..5ad912677 100644 --- a/app/task/SRC/manager.py +++ b/app/task/SRC/manager.py @@ -33,10 +33,6 @@ from app.models.ConfigBase import MultipleConfig from app.models.schema import WSTaskNoticeData from app.models.task import ScriptItem, TaskExecuteBase, UserItem -from app.tools.game_sign_notify import ( - append_task_game_sign_summary, - finalize_task_game_sign_notification, -) from app.utils import ProcessManager, get_logger from app.utils.constants import TASK_MODE_ZH @@ -860,10 +856,6 @@ async def _send_final_notification(self) -> None: wait_count = sum(1 for u in self.script_info.user_list if u.status == "等待") title = f"{datetime.now().strftime('%m-%d')} | {self.script_info.name or '空白'}的{TASK_MODE_ZH[self.task_info.mode]}任务报告" - task_result = append_task_game_sign_summary( - self.task_info, self.script_info.result - ) - has_game_sign_summary = task_result != self.script_info.result result = { "title": f"{TASK_MODE_ZH[self.task_info.mode]}任务报告", "script_name": self.script_info.name or "空白", @@ -871,8 +863,7 @@ async def _send_final_notification(self) -> None: "end_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "completed_count": over_count, "uncompleted_count": error_count + wait_count, - "result": task_result, - "game_sign_summary": has_game_sign_summary, + "result": self.script_info.result, } completion_title = ( @@ -882,7 +873,7 @@ async def _send_final_notification(self) -> None: ) result = {**result, "system_title": completion_title} try: - push_result = await asyncio.wait_for( + await asyncio.wait_for( push_notification( mode="代理结果", title=title, @@ -892,9 +883,6 @@ async def _send_final_notification(self) -> None: ), timeout=_NOTIFICATION_TIMEOUT_SECONDS, ) - finalize_task_game_sign_notification( - self.task_info, has_game_sign_summary, push_result - ) except Exception as e: await self._report_notification_error("推送代理结果", e) diff --git a/app/task/general/manager.py b/app/task/general/manager.py index 3c972ee5f..49589eba8 100644 --- a/app/task/general/manager.py +++ b/app/task/general/manager.py @@ -31,10 +31,6 @@ from app.models.ConfigBase import MultipleConfig from app.models.schema import WSTaskNoticeData from app.models.task import ScriptItem, TaskExecuteBase, UserItem -from app.tools.game_sign_notify import ( - append_task_game_sign_summary, - finalize_task_game_sign_notification, -) from app.tools.push_log import build_user_result_text from app.utils import ProcessManager, get_logger from app.utils.constants import TASK_MODE_ZH @@ -353,10 +349,6 @@ async def final_task(self): user_result_text = build_user_result_text( self.script_info.user_list, has_uncompleted ) - task_result = append_task_game_sign_summary( - self.task_info, user_result_text - ) - has_game_sign_summary = task_result != user_result_text result = { "title": f"{TASK_MODE_ZH[self.task_info.mode]}任务报告", "script_name": self.script_info.name or "空白", @@ -364,21 +356,17 @@ async def final_task(self): "end_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "completed_count": over_count, "uncompleted_count": error_count + wait_count, - "result": task_result, - "game_sign_summary": has_game_sign_summary, + "result": user_result_text, } try: - push_result = await push_notification( + await push_notification( mode="代理结果", title=title, message=result, user_config=None, task_info=self.task_info, ) - finalize_task_game_sign_notification( - self.task_info, has_game_sign_summary, push_result - ) except Exception as e: logger.opt(exception=True).warning(f"推送代理结果时出现异常: {e}") await Publisher.send( diff --git a/app/task/notify_core.py b/app/task/notify_core.py index e9e8d29b9..1eb8669d4 100644 --- a/app/task/notify_core.py +++ b/app/task/notify_core.py @@ -31,11 +31,11 @@ from app.core.notify import ( DispatchResult, NotifyPayload, + dispatch_task_report, global_target, should_send_result, ) from app.tools.game_sign_notify import ( - dispatch_task_report, get_task_game_sign_summary, ) @@ -63,7 +63,7 @@ async def push_proxy_result( skip_debug_message: SendTaskResultTime 不满足时的 debug 文案,仅 M9A 传入。 """ - if not should_send_result(message): + if not should_send_result(message, task_info=task_info): if logger is not None and skip_debug_message: logger.debug(skip_debug_message) return DispatchResult() diff --git a/app/tools/__init__.py b/app/tools/__init__.py index bb12876ca..2140d1a7f 100644 --- a/app/tools/__init__.py +++ b/app/tools/__init__.py @@ -19,11 +19,18 @@ # Contact: DLmaster_361@163.com +from .community import ( + format_community_sign_results, + has_community_credentials, + run_community_sign_in, +) from .game_sign import format_sign_results, run_all_sign_in -from .skland import login_skland_with_password, skland_sign_in +from .skland import skland_sign_in __all__ = [ - "login_skland_with_password", + "format_community_sign_results", + "has_community_credentials", + "run_community_sign_in", "skland_sign_in", "run_all_sign_in", "format_sign_results", diff --git a/app/tools/cloud_genshin.py b/app/tools/cloud_genshin.py new file mode 100644 index 000000000..7314635ba --- /dev/null +++ b/app/tools/cloud_genshin.py @@ -0,0 +1,475 @@ +# 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 + + +"""云原神签到协议适配,只返回本轮时长增量,不保存钱包数据。""" + +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import uuid +from collections.abc import Mapping + +import httpx + +from app.core import Config +from app.utils.logger import get_logger +from app.utils.security import format_exception_reason + +from .community_contract import CommunitySignResult + + +logger = get_logger("云原神签到") + +_BASE_URL = "https://api-cloudgame.mihoyo.com/hk4e_cg_cn" +_WEB_LOGIN_URL = ( + "https://hk4e-sdk.mihoyo.com/hk4e_cn/combo/granter/login/webLogin" +) +_WALLET_URL = f"{_BASE_URL}/wallet/wallet/get" +_NOTIFICATIONS_URL = ( + f"{_BASE_URL}/gamer/api/listNotifications" + "?status=NotificationStatusUnread&type=NotificationTypePopup&is_sort=true" +) +_ACK_NOTIFICATION_URL = f"{_BASE_URL}/gamer/api/ackNotification" +_APP_ID = 4 +_CHANNEL_ID = 1 +_GAME_BIZ = "hk4e_cn" +_APP_SIGN_KEY = b"d0d3a7342df2026a70f650b907800111" +_MIYOUSHE_COOKIE_FIELDS = frozenset( + { + "uni_web_token", + "cookie_token", + "cookie_token_v2", + "stoken", + "stoken_v2", + "stuid", + "stuid_v2", + "ltuid", + "ltuid_v2", + "account_id", + "account_id_v2", + } +) +_AUTH_EXPIRED_RETCODES = frozenset({"-100", "10001"}) + + +class CloudGenshinUnavailableError(ValueError): + """当前米游社账号未开通或无法使用云原神。""" + + +class CloudGenshinAuthenticationError(ValueError): + """米游社或云原神凭据已失效。""" + + +class CloudGenshinBusinessError(ValueError): + """云原神接口返回了非认证类业务失败。""" + + +def validate_cloud_genshin_token(token: str) -> str: + """校验并返回去除首尾空白的云原神 combo token。""" + + raw_value = str(token or "") + if any(ord(character) < 32 or ord(character) == 127 for character in raw_value): + raise ValueError("云原神 combo token 不应包含控制字符") + value = raw_value.strip(" ") + if not value: + raise ValueError("云原神 combo token 不能为空") + if len(value) < 20 or len(value) > 4096: + raise ValueError("云原神 combo token 长度无效") + return value + + +def parse_cloud_genshin_free_time(payload: Mapping[str, object]) -> int: + """从钱包响应严格读取剩余免费时长,单位为秒。""" + + retcode = payload.get("retcode") + if str(retcode).strip() in _AUTH_EXPIRED_RETCODES: + raise CloudGenshinAuthenticationError("云原神登录凭据已失效") + if retcode not in (0, "0", None) or payload.get("message") != "OK": + code = retcode if isinstance(retcode, (int, str)) else "未知" + raise CloudGenshinBusinessError(f"云原神钱包查询失败(错误码 {code})") + data = payload.get("data") + free_time = data.get("free_time") if isinstance(data, Mapping) else None + value = free_time.get("free_time") if isinstance(free_time, Mapping) else None + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ValueError("云原神钱包 free_time 字段格式无效") + return value + + +def format_cloud_genshin_duration(seconds: int) -> str: + """将非负秒数格式化为稳定的中文时长。""" + + if seconds < 0: + raise ValueError("云原神时长不能为负数") + hours, remainder = divmod(seconds, 3600) + minutes, trailing_seconds = divmod(remainder, 60) + parts = [] + if hours: + parts.append(f"{hours} 小时") + if minutes: + parts.append(f"{minutes} 分钟") + if trailing_seconds or not parts: + parts.append(f"{trailing_seconds} 秒") + return " ".join(parts) + + +def calculate_cloud_genshin_gain(before: int, after: int) -> int: + """计算本轮新增时长;消费或未变化均归零。""" + + if ( + isinstance(before, bool) + or not isinstance(before, int) + or before < 0 + or isinstance(after, bool) + or not isinstance(after, int) + or after < 0 + ): + raise ValueError("云原神钱包时长格式无效") + return max(0, after - before) + + +def build_cloud_genshin_combo_token(combo_token: str, open_id: str) -> str: + """将 Web 登录结果签名为云原神接口使用的完整 combo token。""" + + token = validate_cloud_genshin_token(combo_token) + uid = str(open_id or "").strip() + if not uid or any(ord(character) < 32 for character in uid): + raise ValueError("云原神 Web 登录返回的 open_id 无效") + message = ( + f"app_id={_APP_ID}&channel_id={_CHANNEL_ID}" + f"&combo_token={token}&open_id={uid}" + ) + signature = hmac.new( + _APP_SIGN_KEY, + message.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return ( + f"ai={_APP_ID};ci={_CHANNEL_ID};oi={uid};ct={token};" + f"si={signature};bi={_GAME_BIZ}" + ) + + +def _headers(token: str) -> dict[str, str]: + """构造普通云原神查询头;设备字段仅属于 Web 登录换凭据链路。""" + + return {"x-rpc-combo_token": token} + + +async def _request_json( + client: httpx.AsyncClient, + method: str, + url: str, + *, + headers: Mapping[str, str], + json_body: Mapping[str, object] | None = None, + cookies: Mapping[str, str] | None = None, +) -> Mapping[str, object]: + response = await client.request( + method, + url, + headers=dict(headers), + json=dict(json_body) if json_body is not None else None, + cookies=dict(cookies) if cookies is not None else None, + timeout=30.0, + ) + text = response.text.strip() + if not text: + raise ValueError("云原神接口返回空响应,疑似被风控") + try: + payload = response.json() + except ValueError as exc: + raise ValueError("云原神接口返回非 JSON 内容,疑似被风控") from exc + if not isinstance(payload, Mapping): + raise ValueError("云原神接口返回数据格式无效") + return payload + + +def _web_login_headers(device_id: str, device_fp: str) -> dict[str, str]: + """构造米游社 Cookie 换取云原神临时凭据所需的浏览器请求头。""" + + return { + "Accept": "application/json, text/plain, */*", + "Content-Type": "application/json", + "Origin": "https://ys.mihoyo.com", + "Referer": "https://ys.mihoyo.com/", + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36" + ), + "x-rpc-channel_id": str(_CHANNEL_ID), + "x-rpc-client_type": "22", + "x-rpc-device_fp": device_fp, + "x-rpc-device_id": device_id, + "x-rpc-device_model": "Chrome%20140.0.0.0", + "x-rpc-device_name": "Chrome", + "x-rpc-device_os": "Windows%2010%2064-bit", + "x-rpc-game_biz": _GAME_BIZ, + "x-rpc-language": "zh-cn", + "x-rpc-mdk_version": "2.24.0", + } + + +async def _prepare_cloud_genshin_credential( + client: httpx.AsyncClient, + credential: str, + *, + proxy: str | None = None, +) -> tuple[str, bool, str]: + """兼容历史 token,并按需从米游社 Cookie 换取本轮临时 token。""" + + value = validate_cloud_genshin_token(credential) + + from .miyoushe import ( + derive_miyoushe_cookie_token, + prepare_miyoushe_session, + validate_miyoushe_cookie, + ) + + session = prepare_miyoushe_session(value) + if not _MIYOUSHE_COOKIE_FIELDS.intersection(session.cookies): + return value, False, "" + + validate_miyoushe_cookie(value) + cookies = dict(session.cookies) + if not cookies.get("cookie_token"): + cookie_token, derived_uid = await derive_miyoushe_cookie_token( + stoken=cookies["stoken"], + mid=cookies["mid"], + stuid=session.uid, + proxy=proxy, + ) + cookies["cookie_token"] = cookie_token + if derived_uid: + session_uid = derived_uid + else: + session_uid = session.uid + else: + session_uid = session.uid + + device_id = str(cookies.get("_MHYUUID") or session.device_id).strip() + device_fp = str(cookies.get("DEVICEFP") or "38d7fa104e5d7").strip() + payload = await _request_json( + client, + "POST", + _WEB_LOGIN_URL, + headers=_web_login_headers(device_id, device_fp), + json_body={"app_id": _APP_ID, "channel_id": _CHANNEL_ID}, + cookies=cookies, + ) + if payload.get("retcode") not in (0, "0"): + if str(payload.get("retcode")).strip() in _AUTH_EXPIRED_RETCODES: + raise CloudGenshinAuthenticationError( + "米游社凭据已失效,无法登录云原神" + ) + # 云原神是米游社凭据的自动附加能力。除明确认证失效外,上游的 + # 业务拒绝通常表示账号未开通或不可用,不应拖累普通游戏签到。 + raise CloudGenshinUnavailableError("当前米游社账号无法使用云原神,已跳过") + data = payload.get("data") + combo_token = data.get("combo_token") if isinstance(data, Mapping) else None + open_id = ( + data.get("open_id") if isinstance(data, Mapping) else None + ) or session_uid + if not isinstance(combo_token, str) or not combo_token.strip() or not open_id: + raise ValueError("云原神 Web 登录返回数据不完整") + return ( + build_cloud_genshin_combo_token(combo_token, str(open_id)), + True, + device_id or str(uuid.uuid3(uuid.NAMESPACE_URL, session_uid)), + ) + + +def _response_data(payload: Mapping[str, object], stage: str) -> Mapping[str, object]: + retcode = payload.get("retcode") + if str(retcode).strip() in _AUTH_EXPIRED_RETCODES: + raise CloudGenshinAuthenticationError("云原神 combo token 已失效") + if retcode not in (0, "0", None) or payload.get("message") != "OK": + code = retcode if isinstance(retcode, (int, str)) else "未知" + raise CloudGenshinBusinessError(f"{stage}失败(错误码 {code})") + data = payload.get("data") + if not isinstance(data, Mapping): + raise ValueError(f"{stage}返回数据格式无效") + return data + + +async def _query_free_time( + client: httpx.AsyncClient, + headers: Mapping[str, str], +) -> int: + payload = await _request_json(client, "GET", _WALLET_URL, headers=headers) + return parse_cloud_genshin_free_time(payload) + + +async def _list_notification_ids( + client: httpx.AsyncClient, + headers: Mapping[str, str], +) -> tuple[str | int, ...]: + payload = await _request_json( + client, + "GET", + _NOTIFICATIONS_URL, + headers=headers, + ) + data = _response_data(payload, "查询云原神签到通知") + notifications = data.get("list") + if not isinstance(notifications, list): + raise ValueError("云原神通知列表格式无效") + result = [] + for item in notifications: + if not isinstance(item, Mapping): + continue + notification_id = item.get("id") + if isinstance(notification_id, bool) or not isinstance( + notification_id, (str, int) + ): + continue + if str(notification_id).strip(): + result.append(notification_id) + return tuple(result) + + +async def _ack_notification( + client: httpx.AsyncClient, + headers: Mapping[str, str], + notification_id: str | int, +) -> None: + payload = await _request_json( + client, + "POST", + _ACK_NOTIFICATION_URL, + headers=headers, + json_body={"id": notification_id}, + ) + _response_data(payload, "确认云原神签到通知") + + +def _result( + account_name: str, + account_uid: str, + *, + status: str, + reward: str = "", + reason: str = "", + completed: bool = False, + notification_only: bool = False, +) -> dict[str, object]: + return CommunitySignResult( + account=f"{account_name}/云原神", + account_uid=account_uid, + game="云原神", + platform="米游社", + status=status, + reward=reward, + reason=reason, + completed=completed, + notification_only=notification_only, + ).to_legacy() + + +async def cloud_genshin_sign_in( + token: str, + *, + account_name: str, + account_uid: str, + proxy: str | None = None, +) -> list[dict[str, object]]: + """确认云原神签到通知并返回本轮新增免费时长。""" + + try: + resolved_proxy = proxy if proxy is not None else Config.proxy + async with httpx.AsyncClient( + proxy=resolved_proxy, + trust_env=False, + ) as client: + credential, from_miyoushe_cookie, _device_id = ( + await _prepare_cloud_genshin_credential( + client, + token, + proxy=resolved_proxy, + ) + ) + headers = _headers(credential) + try: + before = await _query_free_time(client, headers) + notification_ids = await _list_notification_ids(client, headers) + for index, notification_id in enumerate(notification_ids): + await _ack_notification( + client, + headers, + notification_id, + ) + if index + 1 < len(notification_ids): + await asyncio.sleep(1.0) + if notification_ids: + await asyncio.sleep(1.0) + after = await _query_free_time(client, headers) + except CloudGenshinBusinessError as error: + if from_miyoushe_cookie: + raise CloudGenshinUnavailableError( + "当前米游社账号无法使用云原神,已跳过" + ) from error + raise + + gained = calculate_cloud_genshin_gain(before, after) + return [ + _result( + account_name, + account_uid, + status="成功" if gained else "已签到", + reward=f"新增 {format_cloud_genshin_duration(gained)}", + ) + ] + except CloudGenshinUnavailableError as error: + logger.info(f"[{account_name}] {error}") + return [ + _result( + account_name, + account_uid, + status="跳过", + reason=str(error), + completed=True, + notification_only=True, + ) + ] + except Exception as error: + expected = isinstance( + error, + (ValueError, httpx.HTTPError, TimeoutError, ConnectionError), + ) + reason = format_exception_reason( + error, + stage="云原神签到失败", + include_message=expected, + ) + if expected: + logger.warning(reason) + else: + logger.exception("云原神签到程序异常") + return [ + _result( + account_name, + account_uid, + status="失败", + reason=reason, + ) + ] diff --git a/app/tools/community.py b/app/tools/community.py new file mode 100644 index 000000000..625b95228 --- /dev/null +++ b/app/tools/community.py @@ -0,0 +1,101 @@ +# 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 + + +"""游戏社区工具统一入口,旧签到标识仅在兼容层保留。""" + +from contextlib import asynccontextmanager + +from .community_plugins import ( + COMMUNITY_PLUGIN_REFERENCES, + CommunityCapability, + CommunityPluginReference, + get_community_plugin_references, + get_confirmed_community_capabilities, + supports_community_capability, +) +from .community_sign_provider import ( + format_community_sign_results, + has_community_credentials, +) +from .community_contract import CommunitySignInProgressError +from .community_activity_parser import parse_activity_snapshot +from .community_activity_provider import ( + CommunityActivityProvider, + build_community_activity_requester, +) +from .community_activity_transport import ( + CommunityActivityRequest, + CommunityActivityTarget, + CommunityActivityTransportError, + build_community_activity_requests, + collect_community_activity, +) + +__all__ = [ + "COMMUNITY_PLUGIN_REFERENCES", + "CommunityCapability", + "CommunityPluginReference", + "CommunitySignInProgressError", + "CommunityToolInProgressError", + "CommunityActivityRequest", + "CommunityActivityTarget", + "CommunityActivityTransportError", + "CommunityActivityProvider", + "GameSignInProgressError", + "build_community_activity_requester", + "build_community_activity_requests", + "collect_community_activity", + "community_sign_flow", + "format_community_sign_results", + "get_community_plugin_references", + "get_confirmed_community_capabilities", + "has_community_credentials", + "parse_community_activity_snapshot", + "run_community_sign_in", + "supports_community_capability", +] + + +# 旧异常名称仅供历史调用方识别同一个并发冲突类型。 +CommunityToolInProgressError = CommunitySignInProgressError +GameSignInProgressError = CommunitySignInProgressError +parse_community_activity_snapshot = parse_activity_snapshot + + +@asynccontextmanager +async def community_sign_flow(): + """延迟进入核心社区签到流程,避免工具包初始化形成循环导入。""" + + from app.core.community_sign import community_sign_flow as core_flow + + async with core_flow(): + yield + + +async def run_community_sign_in( + force: bool = False, +) -> list[dict[str, object]]: + """延迟调用核心社区签到编排。""" + + from app.core.community_sign import run_community_sign_in as core_run + + return await core_run(force=force) diff --git a/app/tools/community_activity_parser.py b/app/tools/community_activity_parser.py new file mode 100644 index 000000000..606708d2e --- /dev/null +++ b/app/tools/community_activity_parser.py @@ -0,0 +1,1572 @@ +# 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 + + +"""游戏社区活跃度响应解析和状态归一,不负责网络请求或签到。""" + +from __future__ import annotations + +import json +import math +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from datetime import datetime + +from app.utils.constants import UTC8 + +from .community_contract import ActivityState, CommunityActivitySnapshot + +__all__ = ["ACTIVITY_GAME_DEFINITIONS", "parse_activity_snapshot"] + + +class ActivityResponseLimitedError(ValueError): + """上游响应被风控、拦截或返回了不可解析内容。""" + + +class ActivityResponseUnavailableError(ValueError): + """上游响应可达但没有当前版本可识别的数据。""" + + +ActivityRole = Mapping[str, object] | None +ActivityParser = Callable[ + [Mapping[str, object], str, str, ActivityRole], CommunityActivitySnapshot +] + +_RISK_CODES = frozenset({1034, 5003, 10035, 10041}) + + +def _as_int(value: object) -> int | None: + if isinstance(value, bool) or value is None: + return None + if isinstance(value, int): + return max(value, 0) + if isinstance(value, float): + return max(int(value), 0) if math.isfinite(value) else None + if isinstance(value, str): + text = value.strip() + if not text: + return None + try: + return max(int(float(text)), 0) + except ValueError: + return None + return None + + +def _as_code(value: object) -> int | None: + if isinstance(value, bool) or value is None: + return None + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) if math.isfinite(value) else None + if isinstance(value, str): + try: + return int(value.strip()) + except ValueError: + return None + return None + + +def _first_int(item: Mapping[str, object], names: tuple[str, ...]) -> int | None: + for name in names: + value = _as_int(item.get(name)) + if value is not None: + return value + return None + + +def _progress_pair(value: object) -> tuple[int, int] | None: + if isinstance(value, str): + current_text, separator, target_text = value.partition("/") + if separator: + current = _as_int(current_text) + target = _as_int(target_text) + if current is not None and target is not None: + return current, target + return None + + if not isinstance(value, Mapping): + return None + + current = _first_int( + value, + ( + "current", + "cur", + "completed", + "finished", + "finish", + "done", + "currentValue", + "current_value", + ), + ) + target = _first_int( + value, + ( + "total", + "target", + "max", + "maxProgress", + "totalCount", + "maxValue", + "max_value", + ), + ) + if current is not None and target is not None: + return current, target + + for name in ("progress", "value"): + nested = value.get(name) + if nested is not value: + pair = _progress_pair(nested) + if pair is not None: + return pair + return None + + +def _named_progress( + value: object, + *, + current_names: tuple[str, ...], + target_names: tuple[str, ...], +) -> tuple[int, int] | None: + """只从调用方确认的同一字段对象读取进度,不递归扫描通用数字。""" + + if not isinstance(value, Mapping): + return None + current = _first_int(value, current_names) + target = _first_int(value, target_names) + if current is None or target is None: + return None + return current, target + + +def _activity_item( + name: str, + value: object, + *, + pair: tuple[int, int] | None = None, +) -> dict[str, object] | None: + pair = pair or _progress_pair(value) + if pair is None: + return None + completed, target = pair + return { + "name": name, + "completed": completed, + "target": target, + "status": "已完成" if target > 0 and completed >= target else "进行中", + } + + +def _task_items(value: object, *, default_name: str) -> list[dict[str, object]]: + item = _activity_item(default_name, value) + if item is not None: + return [item] + if not isinstance(value, Mapping): + return [] + + for key in ("items", "tasks", "list"): + entries = value.get(key) + if not isinstance(entries, list): + continue + items: list[dict[str, object]] = [] + for index, entry in enumerate(entries): + if not isinstance(entry, Mapping): + continue + name = str( + entry.get("name") + or entry.get("title") + or entry.get("desc") + or f"{default_name}{index + 1}" + ).strip() + item = _activity_item(name, entry) + if item is not None: + items.append(item) + if items: + return items + return [] + + +def _resource_item( + name: str, + value: object, + *, + pair: tuple[int, int] | None = None, + status: str | None = None, +) -> dict[str, object] | None: + pair = pair or _progress_pair(value) + if pair is None: + return None + current, target = pair + return { + "name": name, + "current": current, + "target": target, + "status": status or ( + "已满" if target > 0 and current >= target else "可用" + ), + } + + +def _recovery_status( + seconds: object, + *, + current: int, + target: int, +) -> str: + """将上游剩余秒数转换为便笺可直接展示的恢复状态。""" + + if target > 0 and current >= target: + return "已满" + duration = _duration_text(seconds) + if duration is None: + return "恢复中" + if _as_int(seconds) == 0: + return "即将回满" + + return f"预计{duration}后回满" + + +def _duration_text(seconds: object) -> str | None: + total_seconds = _as_int(seconds) + if total_seconds is None: + return None + + total_minutes = max(1, math.ceil(total_seconds / 60)) + days, remaining_minutes = divmod(total_minutes, 24 * 60) + hours, minutes = divmod(remaining_minutes, 60) + parts: list[str] = [] + if days: + parts.append(f"{days}天") + if hours: + parts.append(f"{hours}小时") + if minutes or not parts: + parts.append(f"{minutes}分钟") + return "".join(parts) + + +def _remaining_seconds(timestamp: object, current_timestamp: object) -> int | None: + finish = _as_int(timestamp) + current = _as_int(current_timestamp) + if finish is None or current is None: + return None + return max(0, finish - current) + + +def _future_status( + seconds: object, + *, + action: str, + fallback: str, +) -> str: + duration = _duration_text(seconds) + if duration is None: + return fallback + if _as_int(seconds) == 0: + return f"即将{action}" + return f"预计{duration}后{action}" + + +def _expedition_status( + entries: list[Mapping[str, object]], + *, + accepted: int | None, + remaining_field: str, +) -> str: + if accepted == 0 and not entries: + return "尚未派遣" + finished = sum(entry.get("status") == "Finished" for entry in entries) + if finished and finished == accepted: + return "奖励待领取" + ongoing = [entry for entry in entries if entry.get("status") == "Ongoing"] + remaining = [_as_int(entry.get(remaining_field)) for entry in ongoing] + # 列表或计时不完整时,不用部分派遣的时长声称全部派遣即将完成。 + duration = ( + max(value for value in remaining if value is not None) + if remaining + and all(value is not None for value in remaining) + and len(ongoing) + finished == accepted + else None + ) + status = _future_status( + duration, + action="全部完成", + fallback="进行中" if ongoing else "状态未返回", + ) + if finished: + return f"{finished}项奖励待领取;{status}" + return status + + +def _status_item(name: str, status: str, *, period: str = "daily") -> dict[str, object]: + return { + "name": name, + "completed": 0, + "target": 0, + "status": status, + "period": period, + } + + +def _status_resource(name: str, status: str) -> dict[str, object]: + return { + "name": name, + "current": 0, + "target": 0, + "status": status, + } + + +def _number_text(value: object) -> str | None: + if isinstance(value, bool) or value is None: + return None + if isinstance(value, int): + return str(max(value, 0)) + if isinstance(value, float): + return f"{max(value, 0):g}" if math.isfinite(value) else None + if isinstance(value, str): + try: + number = float(value.strip()) + except ValueError: + return None + return f"{max(number, 0):g}" if math.isfinite(number) else None + return None + + +def _mark_period( + items: list[dict[str, object]], period: str +) -> list[dict[str, object]]: + return [dict(item, period=period) for item in items] + + +def _unwrap_data(payload: Mapping[str, object]) -> Mapping[str, object]: + data = payload.get("data") + return data if isinstance(data, Mapping) else payload + + +def _role_value(role: ActivityRole, names: tuple[str, ...]) -> str: + if not isinstance(role, Mapping): + return "" + for name in names: + value = role.get(name) + if value is not None and str(value).strip(): + return str(value).strip() + return "" + + +def _build_snapshot( + *, + account_uid: str, + account_name: str, + platform: str, + game: str, + items: list[dict[str, object]], + resources: list[dict[str, object]], + role: ActivityRole, + source: str, + progress: tuple[int, int] | None = None, +) -> CommunityActivitySnapshot: + daily_items = [item for item in items if item.get("period", "daily") == "daily"] + if not daily_items and not items and not resources: + raise ActivityResponseUnavailableError( + f"{platform}{game}未返回可识别的每日任务进度" + ) + + if progress is None and daily_items: + completed = sum( + _as_int(item.get("completed")) or 0 for item in daily_items + ) + target = sum(_as_int(item.get("target")) or 0 for item in daily_items) + elif progress is None: + completed = None + target = None + else: + completed, target = progress + return CommunityActivitySnapshot( + account=account_name, + account_uid=account_uid, + game=game, + platform=platform, + status="success", + completed=completed, + target=target, + tasks=tuple(items), + resources=tuple(resources), + updated_at=datetime.now(tz=UTC8).isoformat(), + role_name=_role_value(role, ("name", "nickname", "nickName")), + role_uid=_role_value( + role, + ("roleId", "role_id", "uid", "gameUid", "game_uid"), + ), + server=_role_value( + role, + ("serverName", "server", "serverId", "server_id", "channelName"), + ), + source=source, + ) + + +def _failed_snapshot( + *, + account_uid: str, + account_name: str, + platform: str, + game: str, + status: ActivityState, + reason: str, + role: ActivityRole, + source: str = "", +) -> CommunityActivitySnapshot: + return CommunityActivitySnapshot( + account=account_name, + account_uid=account_uid, + game=game, + platform=platform, + status=status, + reason=reason, + updated_at=datetime.now(tz=UTC8).isoformat(), + role_name=_role_value(role, ("name", "nickname", "nickName")), + role_uid=_role_value( + role, + ("roleId", "role_id", "uid", "gameUid", "game_uid"), + ), + server=_role_value( + role, + ("serverName", "server", "serverId", "server_id", "channelName"), + ), + source=source, + ) + + +def _coerce_payload( + payload: object, *, platform: str, game: str +) -> Mapping[str, object]: + if isinstance(payload, Mapping): + root = payload + else: + if isinstance(payload, (bytes, bytearray)): + text = bytes(payload).decode("utf-8", errors="replace").strip() + elif isinstance(payload, str): + text = payload.strip() + else: + text = "" + if not text: + raise ActivityResponseLimitedError( + f"{platform}{game}接口返回空响应,可能触发风控或维护" + ) + try: + decoded = json.loads(text) + except json.JSONDecodeError as exc: + raise ActivityResponseLimitedError( + f"{platform}{game}接口返回非 JSON,可能触发风控" + ) from exc + if not isinstance(decoded, Mapping): + raise ActivityResponseUnavailableError( + f"{platform}{game}接口返回的数据结构无法识别" + ) + root = decoded + + for key in ("retcode", "code"): + code = _as_code(root.get(key)) + if code is None or code == 0: + continue + if abs(code) in _RISK_CODES: + raise ActivityResponseLimitedError( + f"{platform}{game}接口受到上游风控(业务码 {code})" + ) + raise ActivityResponseUnavailableError( + f"{platform}{game}接口返回业务失败(业务码 {code})" + ) + return root + + +def _parse_skland_arknights( + payload: Mapping[str, object], + account_uid: str, + account_name: str, + role: ActivityRole, +) -> CommunityActivitySnapshot: + root = _unwrap_data(payload) + routine = root.get("routine") + routine = routine if isinstance(routine, Mapping) else {} + daily_value = routine.get("daily") or root.get("daily") + weekly_value = routine.get("weekly") or root.get("weekly") + items = _task_items(daily_value, default_name="日常活跃度") + items.extend( + _mark_period( + _task_items(weekly_value, default_name="每周活跃度"), "weekly" + ) + ) + + campaign = root.get("campaign") + campaign_reward = ( + campaign.get("reward") if isinstance(campaign, Mapping) else None + ) + campaign_item = _activity_item("每周报酬合成玉", campaign_reward) + if campaign_item is not None: + items.append(dict(campaign_item, period="weekly")) + + tower = root.get("tower") + tower_reward = tower.get("reward") if isinstance(tower, Mapping) else None + if isinstance(tower_reward, Mapping): + for name, field in ( + ("数据增补条", "lowerItem"), + ("数据增补仪", "higherItem"), + ): + tower_item = _activity_item(name, tower_reward.get(field)) + if tower_item is not None: + refresh = _remaining_seconds( + tower_reward.get("termTs"), root.get("currentTs") + ) + if refresh is not None: + tower_item["status"] = _future_status( + refresh, action="刷新", fallback="" + ) + # 保全派驻按上游结算周期刷新,不属于每周任务。 + items.append(dict(tower_item, period="periodic")) + + status = root.get("status") + status = status if isinstance(status, Mapping) else {} + ap_value = status.get("ap") or root.get("ap") + ap_status: str | None = None + if isinstance(ap_value, Mapping): + current = _first_int(ap_value, ("current", "cur")) + maximum = _first_int(ap_value, ("max", "total")) + current_ts = _as_int(root.get("currentTs")) + last_add_ts = _as_int(ap_value.get("lastApAddTime")) + if ( + current is not None + and maximum is not None + and current_ts is not None + and last_add_ts is not None + ): + # Widget 返回的是上次恢复时的基础值,按每 6 分钟恢复 1 点补齐当前值。 + elapsed = max(0, current_ts - last_add_ts) + if current < maximum: + current = min(maximum, current + elapsed // 360) + complete_recovery_ts = _as_int( + ap_value.get("completeRecoveryTime") + ) + recovery_seconds = ( + max(0, complete_recovery_ts - current_ts) + if complete_recovery_ts is not None + else max(0, (maximum - current) * 360 - (elapsed % 360)) + ) + ap_status = _recovery_status( + recovery_seconds, + current=current, + target=maximum, + ) + ap_value = {"current": current, "total": maximum} + + resources: list[dict[str, object]] = [] + ap_item = _resource_item("理智", ap_value, status=ap_status) + if ap_item is not None: + resources.append(ap_item) + + current_ts = _as_int(root.get("currentTs")) + recruit_value = root.get("recruit") + recruit_entries = ( + [entry for entry in recruit_value if isinstance(entry, Mapping)] + if isinstance(recruit_value, list) + else [] + ) + if recruit_entries: + completed_recruits = sum( + (_as_code(entry.get("state")) or 0) != 2 + for entry in recruit_entries + ) + finish_timestamps = [ + timestamp + for entry in recruit_entries + if (timestamp := _as_code(entry.get("finishTs"))) is not None + and timestamp >= 0 + ] + recruit_status = ( + "招募已全部完成" + if completed_recruits >= len(recruit_entries) + else _future_status( + _remaining_seconds( + max(finish_timestamps) if finish_timestamps else None, + current_ts, + ), + action="全部完成", + fallback="招募进行中", + ) + ) + recruit_item = _resource_item( + "公开招募", + {}, + pair=(completed_recruits, len(recruit_entries)), + status=recruit_status, + ) + if recruit_item is not None: + resources.append(recruit_item) + + building = root.get("building") + building = building if isinstance(building, Mapping) else {} + + hire = building.get("hire") + if isinstance(hire, Mapping): + refresh_count = _as_int(hire.get("refreshCount")) + if refresh_count is not None: + hire_status = ( + f"可刷新{refresh_count}次" + if refresh_count > 0 + else _future_status( + _remaining_seconds( + hire.get("completeWorkTime"), + current_ts, + ), + action="获得刷新次数", + fallback="联络中", + ) + ) + resources.append(_status_resource("公招刷新", hire_status)) + + training = building.get("training") + if isinstance(training, Mapping): + trainee = training.get("trainee") + if isinstance(trainee, Mapping): + char_info_map = root.get("charInfoMap") + char_info = ( + char_info_map.get(str(trainee.get("charId"))) + if isinstance(char_info_map, Mapping) + else None + ) + trainee_name = ( + str(char_info.get("name") or "").strip() + if isinstance(char_info, Mapping) + else "" + ) + training_state = ( + "训练已完成" + if _as_int(training.get("remainSecs")) == 0 + else _future_status( + training.get("remainSecs"), + action="完成训练", + fallback="训练中", + ) + ) + resources.append( + _status_resource( + "训练室", + f"{trainee_name},{training_state}" + if trainee_name + else training_state, + ) + ) + else: + resources.append(_status_resource("训练室", "空闲中")) + + labor = building.get("labor") + labor_pair = _named_progress( + labor, + current_names=("value",), + target_names=("maxValue",), + ) + if labor_pair is not None: + current, target = labor_pair + labor_item = _resource_item( + "无人机", + labor, + pair=labor_pair, + status=_recovery_status( + labor.get("remainSecs") if isinstance(labor, Mapping) else None, + current=current, + target=target, + ), + ) + if labor_item is not None: + resources.append(labor_item) + + manufactures = building.get("manufactures") + formula_map = root.get("manufactureFormulaInfoMap") + manufacture_current = 0 + manufacture_target = 0 + if isinstance(manufactures, list) and isinstance(formula_map, Mapping): + for manufacture in manufactures: + if not isinstance(manufacture, Mapping): + continue + manufacture_current += _as_int(manufacture.get("complete")) or 0 + formula_id = str(manufacture.get("formulaId") or "") + formula = formula_map.get(formula_id) + capacity = _as_int(manufacture.get("capacity")) + weight = ( + _as_int(formula.get("weight")) + if isinstance(formula, Mapping) + else None + ) + if capacity is not None and weight: + manufacture_target += capacity // weight + if manufacture_target > 0: + manufacture_item = _resource_item( + "制造进度", + {}, + pair=(manufacture_current, manufacture_target), + ) + if manufacture_item is not None: + resources.append(manufacture_item) + + tradings = building.get("tradings") + trading_current = 0 + trading_target = 0 + if isinstance(tradings, list): + for trading in tradings: + if not isinstance(trading, Mapping): + continue + stock = trading.get("stock") + stock_count = len(stock) if isinstance(stock, list) else _as_int(stock) + stock_limit = _as_int(trading.get("stockLimit")) + if stock_count is not None and stock_limit is not None: + trading_current += stock_count + trading_target += stock_limit + if trading_target > 0: + trading_item = _resource_item( + "订单进度", + {}, + pair=(trading_current, trading_target), + ) + if trading_item is not None: + resources.append(trading_item) + + tired_chars = building.get("tiredChars") + if isinstance(tired_chars, list): + resources.append( + _status_resource("干员疲劳", f"{len(tired_chars)}名干员疲劳") + ) + + return _build_snapshot( + account_uid=account_uid, + account_name=account_name, + platform="森空岛", + game="明日方舟", + items=items, + resources=resources, + role=role, + source="/api/v1/game/player/info", + ) + + +def _parse_skland_endfield( + payload: Mapping[str, object], + account_uid: str, + account_name: str, + role: ActivityRole, +) -> CommunityActivitySnapshot: + root = _unwrap_data(payload) + detail = root.get("detail") + if not isinstance(detail, Mapping): + raise ActivityResponseUnavailableError( + "森空岛终末地未返回可识别的角色详情" + ) + + daily_value = detail.get("dailyMission") + daily_pair = _named_progress( + daily_value, + current_names=("dailyActivation",), + target_names=("maxDailyActivation",), + ) + items = [] + if daily_pair is not None: + daily = _activity_item("日常活跃度", daily_value, pair=daily_pair) + if daily is not None: + items.append(daily) + + weekly_value = detail.get("weeklyMission") + weekly_pair = _named_progress( + weekly_value, + current_names=("score",), + target_names=("total",), + ) + if weekly_pair is not None: + weekly_item = _activity_item( + "每周事务", + weekly_value, + pair=weekly_pair, + ) + if weekly_item is not None: + items.append(dict(weekly_item, period="weekly")) + + bp_system = detail.get("bpSystem") + bp_pair = _named_progress( + bp_system, + current_names=("curLevel",), + target_names=("maxLevel",), + ) + if bp_pair is not None: + item = _activity_item("通行证等级", bp_system, pair=bp_pair) + if item is not None: + items.append(dict(item, period="periodic")) + + seek_suspicion = detail.get("seekSuspicion") + seek_pair = _named_progress( + seek_suspicion, + current_names=("count",), + target_names=("total",), + ) + if seek_pair is not None: + seek_item = _activity_item( + "蚀像寻遗", + seek_suspicion, + pair=seek_pair, + ) + if seek_item is not None: + items.append(dict(seek_item, period="periodic")) + + resources = [] + dungeon = detail.get("dungeon") + dungeon_pair = _named_progress( + dungeon, + current_names=("curStamina",), + target_names=("maxStamina",), + ) + if dungeon_pair is not None: + current, target = dungeon_pair + current_ts = detail.get("currentTs") or root.get("currentTs") + recovery_seconds = ( + _remaining_seconds(dungeon.get("maxTs"), current_ts) + if isinstance(dungeon, Mapping) + else None + ) + item = _resource_item( + "理智", + dungeon, + pair=dungeon_pair, + status=_recovery_status( + recovery_seconds + if recovery_seconds is not None + else max(0, target - current) * 432, + current=current, + target=target, + ), + ) + if item is not None: + resources.append(item) + return _build_snapshot( + account_uid=account_uid, + account_name=account_name, + platform="森空岛", + game="终末地", + items=items, + resources=resources, + role=role, + source="/web/v1/game/endfield/card/detail", + ) + + +def _parse_miyoushe_genshin( + payload: Mapping[str, object], + account_uid: str, + account_name: str, + role: ActivityRole, +) -> CommunityActivitySnapshot: + root = _unwrap_data(payload) + daily_value = root.get("daily") or root.get("daily_task") + daily = _activity_item("每日委托", daily_value) + if daily is None: + daily = _activity_item( + "每日委托", + daily_value, + pair=_named_progress( + daily_value, + current_names=("finished_num", "finished_task_num"), + target_names=("total_num", "total_task_num"), + ), + ) + if daily is None: + daily = _activity_item( + "每日委托", + root, + pair=_named_progress( + root, + current_names=("finished_task_num",), + target_names=("total_task_num",), + ), + ) + items = [daily] if daily is not None else [] + nested_reward_received = ( + daily_value.get("is_extra_task_reward_received") + if isinstance(daily_value, Mapping) + else None + ) + reward_received = ( + nested_reward_received + if isinstance(nested_reward_received, bool) + else root.get("is_extra_task_reward_received") + ) + if daily is not None and isinstance(reward_received, bool): + # 历练点也可领取委托奖励,领取状态不能只依赖委托完成数。 + if reward_received or daily["completed"] == daily["target"]: + items[0] = dict( + daily, + status="奖励已领取" if reward_received else "奖励待领取", + ) + + remaining_discounts = _as_int(root.get("remain_resin_discount_num")) + discount_limit = _as_int(root.get("resin_discount_num_limit")) + if remaining_discounts is not None and discount_limit is not None: + items.append( + { + "name": "周本减半次数", + "completed": max(0, discount_limit - remaining_discounts), + "target": discount_limit, + "status": f"剩余{remaining_discounts}次", + "period": "weekly", + } + ) + + transformer = root.get("transformer") + if isinstance(transformer, Mapping): + if transformer.get("obtained") is False: + transformer_status = "未获得" + else: + recovery_time = transformer.get("recovery_time") + recovery_time = ( + recovery_time if isinstance(recovery_time, Mapping) else {} + ) + if recovery_time.get("reached") is True: + transformer_status = "可使用" + else: + time_parts = [ + _as_int(recovery_time.get(field)) + for field in ("Day", "Hour", "Minute", "Second") + ] + transformer_seconds = ( + sum( + part * multiplier + for part, multiplier in zip(time_parts, (86400, 3600, 60, 1)) + if part is not None + ) + if all(part is not None for part in time_parts) + else None + ) + transformer_status = _future_status( + transformer_seconds, + action="可使用", + fallback="冷却中", + ) + items.append( + _status_item( + "参量质变仪", + transformer_status, + period="weekly", + ) + ) + + week_active = root.get("week_active_progress") + if isinstance(week_active, Mapping): + if week_active.get("unlock") is False: + items.append(_status_item("砺行修远", "未解锁", period="periodic")) + elif week_active.get("is_active_period") is False: + items.append(_status_item("砺行修远", "本期未开放", period="periodic")) + else: + weekly_pair = _named_progress( + week_active, + current_names=("progress_current",), + target_names=("progress_total",), + ) + period_pair = _named_progress( + week_active, + current_names=("period_progress_current",), + target_names=("period_progress_total",), + ) + active_item = _activity_item( + "砺行修远", {}, pair=period_pair or weekly_pair + ) + if active_item is not None: + if weekly_pair is not None: + active_item["status"] = f"本周 {weekly_pair[0]} / {weekly_pair[1]}" + items.append(dict(active_item, period="periodic")) + + resources = [] + recoverable_resources = ( + ( + "原粹树脂", + root.get("current_resin"), + root.get("max_resin"), + root.get("resin_recovery_time"), + ), + ( + "洞天宝钱", + root.get("current_home_coin"), + root.get("max_home_coin"), + root.get("home_coin_recovery_time"), + ), + ) + for name, current_value, target_value, recovery_time in recoverable_resources: + pair = _progress_pair( + {"current": current_value, "total": target_value} + ) + if pair is None: + continue + current, target = pair + item = _resource_item( + name, + {}, + pair=pair, + status=_recovery_status( + recovery_time, + current=current, + target=target, + ), + ) + if item is not None: + resources.append(item) + + daily_detail = root.get("daily_task") + stored_attendance = ( + _number_text(daily_detail.get("stored_attendance")) + if isinstance(daily_detail, Mapping) + and daily_detail.get("attendance_visible") is not False + else None + ) + if stored_attendance is not None: + resources.append( + _status_resource("长效历练点", f"现有{stored_attendance}点") + ) + + expeditions = root.get("expeditions") + expedition_entries = ( + [entry for entry in expeditions if isinstance(entry, Mapping)] + if isinstance(expeditions, list) + else [] + ) + expedition_current = _as_int(root.get("current_expedition_num")) + if expedition_current is None and isinstance(expeditions, list): + expedition_current = len(expedition_entries) + expedition_target = _first_int( + root, + ("max_expedition_num", "total_expedition_num"), + ) + expedition = _resource_item( + "探索派遣", + { + "current": expedition_current, + "total": expedition_target, + }, + status=_expedition_status( + expedition_entries, + accepted=expedition_current, + remaining_field="remained_time", + ), + ) + if expedition is not None: + resources.append(expedition) + return _build_snapshot( + account_uid=account_uid, + account_name=account_name, + platform="米游社", + game="原神", + items=items, + resources=resources, + role=role, + source="/game_record/app/genshin/api/dailyNote", + ) + + +def _parse_miyoushe_hsr( + payload: Mapping[str, object], + account_uid: str, + account_name: str, + role: ActivityRole, +) -> CommunityActivitySnapshot: + root = _unwrap_data(payload) + daily_value = root.get("daily") or root.get("daily_task") + daily = _activity_item("每日实训", daily_value) + if daily is None: + daily = _activity_item( + "每日实训", + { + "current": ( + root.get("current_train_score") + if root.get("current_train_score") is not None + else root.get("current_training_score") + ), + "total": ( + root.get("max_train_score") + if root.get("max_train_score") is not None + else root.get("max_training_score") + ), + }, + ) + items = [daily] if daily is not None else [] + period_pair = _named_progress( + root, + current_names=("period_score",), + target_names=("period_max_score",), + ) + rogue_pair = _named_progress( + root, + current_names=("current_rogue_score",), + target_names=("max_rogue_score",), + ) + if period_pair is not None or rogue_pair is not None: + # 新版周期积分覆盖原模拟宇宙周积分;旧 Widget 仍保留原字段。 + rogue = _activity_item( + "周期积分" if period_pair is not None else "模拟宇宙积分", + {}, + pair=period_pair or rogue_pair, + ) + if rogue is not None: + items.append(dict(rogue, period="weekly")) + + if root.get("rogue_tourn_weekly_unlocked") is True: + synchronicity_pair = _named_progress( + root, + current_names=("rogue_tourn_weekly_cur",), + target_names=("rogue_tourn_weekly_max",), + ) + # Widget 偶尔返回当前值大于目标值的反向语义,无法确认时不展示错误进度。 + if ( + synchronicity_pair is not None + and synchronicity_pair[0] <= synchronicity_pair[1] + ): + synchronicity = _activity_item( + "差分宇宙同步积分", + root, + pair=synchronicity_pair, + ) + if synchronicity is not None: + items.append(dict(synchronicity, period="weekly")) + + remaining_discounts = _as_int(root.get("weekly_cocoon_cnt")) + discount_limit = _as_int(root.get("weekly_cocoon_limit")) + if remaining_discounts is not None and discount_limit is not None: + items.append( + { + "name": "历战余响次数", + "completed": max(0, discount_limit - remaining_discounts), + "target": discount_limit, + "status": f"剩余{remaining_discounts}次", + "period": "weekly", + } + ) + + resources = [] + stamina_pair = _progress_pair( + { + "current": root.get("current_stamina"), + "total": root.get("max_stamina"), + } + ) + if stamina_pair is not None: + current, target = stamina_pair + stamina = _resource_item( + "开拓力", + {}, + pair=stamina_pair, + status=_recovery_status( + root.get("stamina_recover_time"), + current=current, + target=target, + ), + ) + if stamina is not None: + resources.append(stamina) + + reserve_stamina = _as_int(root.get("current_reserve_stamina")) + reserve_target = _as_int(root.get("max_reserve_stamina")) + if reserve_stamina is not None: + reserve_item = _resource_item( + "储备开拓力", + {}, + pair=(reserve_stamina, reserve_target or 2400), + ) + if reserve_item is not None: + resources.append(reserve_item) + + expeditions = root.get("expeditions") + expedition_entries = ( + [entry for entry in expeditions if isinstance(entry, Mapping)] + if isinstance(expeditions, list) + else [] + ) + expedition_current = _first_int( + root, + ( + "accepted_epedition_num", + "accepted_expedition_num", + "current_expedition_num", + ), + ) + if expedition_current is None and isinstance(expeditions, list): + expedition_current = len(expedition_entries) + expedition_target = _as_int(root.get("total_expedition_num")) + if expedition_current is not None and expedition_target is not None: + expedition = _resource_item( + "探索派遣", + {}, + pair=(expedition_current, expedition_target), + status=_expedition_status( + expedition_entries, + accepted=expedition_current, + remaining_field="remaining_time", + ), + ) + if expedition is not None: + resources.append(expedition) + return _build_snapshot( + account_uid=account_uid, + account_name=account_name, + platform="米游社", + game="星穹铁道", + items=items, + resources=resources, + role=role, + source="/game_record/app/hkrpg/api/note", + ) + + +def _state_task( + name: str, + value: object, + *, + complete: str, + incomplete: tuple[str, ...] = (), + complete_status: str = "已完成", + incomplete_status: str = "未完成", +) -> dict[str, object] | None: + if value is None: + return None + state = str(value).strip() + if not state: + return None + # CardSignNotDone 也含 Done,必须精确识别上游状态。 + status = ( + complete_status + if state == complete + else incomplete_status if state in incomplete else "状态未知" + ) + return _status_item( + name, + status, + ) + + +def _parse_miyoushe_zzz( + payload: Mapping[str, object], + account_uid: str, + account_name: str, + role: ActivityRole, +) -> CommunityActivitySnapshot: + root = _unwrap_data(payload) + vitality = _progress_pair(root.get("vitality")) + video_sale = root.get("vhs_sale") + video_state = ( + str(video_sale.get("sale_state") or "") + if isinstance(video_sale, Mapping) + else "" + ) + video_status = { + "SaleStateDone": "待结算", + "SaleStateDoing": "营业中", + "SaleStateNo": "尚未营业", + }.get(video_state) + items: list[dict[str, object]] = [] + daily_item = _activity_item("今日活跃度", root.get("vitality"), pair=vitality) + if daily_item is not None: + items.append(daily_item) + if video_status is not None: + items.append(_status_item("录像店经营", video_status)) + card_task = _state_task( + "刮刮卡/占卜", + root.get("card_sign"), + complete="CardSignDone", + incomplete=("CardSignNo", "CardSignNotDone"), + ) + if card_task is not None: + items.append(card_task) + + hollow_zero = root.get("hollow_zero") + hollow_zero = hollow_zero if isinstance(hollow_zero, Mapping) else {} + bounty = root.get("bounty_commission") or hollow_zero.get( + "bounty_commission" + ) + bounty_pair = _named_progress( + bounty, + current_names=("num",), + target_names=("total",), + ) + if isinstance(bounty, Mapping) and bounty.get("unlock") is False: + items.append(_status_item("悬赏委托", "未解锁", period="weekly")) + elif bounty_pair is not None: + bounty_item = _activity_item("悬赏委托", bounty, pair=bounty_pair) + if bounty_item is not None: + items.append(dict(bounty_item, period="weekly")) + + survey_points = root.get("survey_points") or hollow_zero.get( + "survey_points" + ) + survey_pair = _named_progress( + survey_points, + current_names=("num",), + target_names=("total",), + ) + if survey_pair is not None: + survey_item = _activity_item( + "零号空洞调查积分", + survey_points, + pair=survey_pair, + ) + if survey_item is not None: + items.append(dict(survey_item, period="weekly")) + + weekly_task = root.get("weekly_task") + weekly_pair = _named_progress( + weekly_task, + current_names=("cur_point",), + target_names=("max_point",), + ) + if weekly_pair is not None: + weekly_item = _activity_item( + "丽都周纪积分", + weekly_task, + pair=weekly_pair, + ) + if weekly_item is not None: + items.append(dict(weekly_item, period="weekly")) + + resources = [] + energy = root.get("energy") + if isinstance(energy, Mapping): + energy_pair = _progress_pair(energy.get("progress")) + energy_item = None + if energy_pair is not None: + current, target = energy_pair + energy_item = _resource_item( + "电量", + energy.get("progress"), + pair=energy_pair, + status=_recovery_status( + energy.get("restore"), + current=current, + target=target, + ), + ) + if energy_item is not None: + resources.append(energy_item) + + temple = root.get("temple_running") + temple_pair = _named_progress( + temple, + current_names=("current_currency",), + target_names=("weekly_currency_max",), + ) + if temple_pair is not None: + temple_item = _resource_item( + "随便观周收益", + temple, + pair=temple_pair, + ) + if temple_item is not None: + resources.append(temple_item) + if isinstance(temple, Mapping): + for name, field, states in ( + ( + "随便观制作", + "bench_state", + {"BenchStateCanProduce": "可制作", "BenchStateProducing": "制作中"}, + ), + ( + "随便观售卖", + "shelve_state", + { + "ShelveStateCanSell": "可上架", + "ShelveStateSelling": "售卖中", + "ShelveStateSoldOut": "已售罄", + }, + ), + ( + "邦布派遣", + "expedition_state", + { + "ExpeditionStateInCanSend": "可派遣", + "ExpeditionStateInProgress": "派遣中", + "ExpeditionStateEnd": "奖励待领取", + }, + ), + ): + state = temple.get(field) + if isinstance(state, str) and state: + resources.append(_status_resource(name, states.get(state, "状态未知"))) + return _build_snapshot( + account_uid=account_uid, + account_name=account_name, + platform="米游社", + game="绝区零", + items=[item for item in items if item is not None], + resources=resources, + role=role, + source="/event/game_record_zzz/api/zzz/note", + progress=vitality, + ) + + +@dataclass(frozen=True) +class ActivityGameDefinition: + platform: str + game: str + parser: ActivityParser | None + source: str = "" + limited_reason: str = "" + + +ACTIVITY_GAME_DEFINITIONS = ( + ActivityGameDefinition( + platform="森空岛", + game="明日方舟", + parser=_parse_skland_arknights, + source="/api/v1/game/player/info", + ), + ActivityGameDefinition( + platform="森空岛", + game="终末地", + parser=_parse_skland_endfield, + source="/web/v1/game/endfield/card/detail", + ), + ActivityGameDefinition( + platform="米游社", + game="原神", + parser=_parse_miyoushe_genshin, + source="/game_record/app/genshin/api/dailyNote", + ), + ActivityGameDefinition( + platform="米游社", + game="星穹铁道", + parser=_parse_miyoushe_hsr, + source="/game_record/app/hkrpg/api/note", + ), + ActivityGameDefinition( + platform="米游社", + game="绝区零", + parser=_parse_miyoushe_zzz, + source="/event/game_record_zzz/api/zzz/note", + ), +) + + +def _definition_for(platform: str, game: str) -> ActivityGameDefinition | None: + return next( + ( + definition + for definition in ACTIVITY_GAME_DEFINITIONS + if definition.platform == platform and definition.game == game + ), + None, + ) + + +def parse_activity_snapshot( + payload: object, + *, + account_uid: str, + account_name: str, + platform: str, + game: str, + role: ActivityRole = None, +) -> CommunityActivitySnapshot: + """将单个游戏的脱敏响应转换为稳定的活跃度快照。 + + Args: + payload: 已由调用方读取的 JSON 对象、JSON 字符串或响应正文。 + account_uid: 游戏社区账号组 UUID。 + account_name: 游戏社区账号组名称。 + platform: 社区平台名称。 + game: 游戏名称。 + role: 当前查询角色的脱敏字段。 + + Returns: + 单个游戏的活跃度快照;不支持或受限状态也以快照返回。 + """ + definition = _definition_for(platform, game) + if definition is None: + return _failed_snapshot( + account_uid=account_uid, + account_name=account_name, + platform=platform, + game=game, + status="unavailable", + reason=f"{platform}{game}尚未登记活跃度解析器", + role=role, + ) + if definition.parser is None: + return _failed_snapshot( + account_uid=account_uid, + account_name=account_name, + platform=platform, + game=game, + status="limited", + reason=definition.limited_reason, + role=role, + source=definition.source, + ) + + try: + root = _coerce_payload(payload, platform=platform, game=game) + return definition.parser(root, account_uid, account_name, role) + except ActivityResponseLimitedError as exc: + return _failed_snapshot( + account_uid=account_uid, + account_name=account_name, + platform=platform, + game=game, + status="limited", + reason=str(exc), + role=role, + source=definition.source, + ) + except ActivityResponseUnavailableError as exc: + return _failed_snapshot( + account_uid=account_uid, + account_name=account_name, + platform=platform, + game=game, + status="unavailable", + reason=str(exc), + role=role, + source=definition.source, + ) + except ValueError: + return _failed_snapshot( + account_uid=account_uid, + account_name=account_name, + platform=platform, + game=game, + status="failed", + reason=f"{platform}{game}活跃度响应解析失败", + role=role, + source=definition.source, + ) + except Exception: + return _failed_snapshot( + account_uid=account_uid, + account_name=account_name, + platform=platform, + game=game, + status="failed", + reason=f"{platform}{game}活跃度处理失败", + role=role, + source=definition.source, + ) diff --git a/app/tools/community_activity_provider.py b/app/tools/community_activity_provider.py new file mode 100644 index 000000000..ba2a4fe26 --- /dev/null +++ b/app/tools/community_activity_provider.py @@ -0,0 +1,1129 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025-2026 AUTO-MAS Team + +# This file incorporates request knowledge from the following acknowledged +# community projects: +# +# nonebot-plugin-mystool Copyright © 2023-2025 Ljzd-PRO +# https://github.com/Ljzd-PRO/nonebot-plugin-mystool +# +# gxy12345/arknights-plugin +# https://github.com/gxy12345/arknights-plugin + +# 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 + + +"""森空岛和米游社的只读活动请求适配器。 + +该模块只负责把凭据暂存于本次查询的内存、完成 provider 请求和返回 JSON。 +活动解析由 ``community_activity_parser`` 负责,签到编排和配置保存不在本模块内。 +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import random +import string +import time +import uuid +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass, field, replace +from typing import TYPE_CHECKING +from urllib.parse import urlparse + +import httpx + +from .community_activity_roles import ( + CommunityActivityCapability, + CommunityActivityRoleDiscovery, + normalize_miyoushe_roles, + normalize_skland_roles, +) +from .community_activity_transport import ( + CommunityActivityRequest, + CommunityActivityTarget, + CommunityActivityTransportError, +) + +if TYPE_CHECKING: + from .miyoushe import MiyousheSessionCapabilities + +__all__ = [ + "CommunityActivityProvider", + "build_community_activity_requester", +] + + +CredentialUpdate = Callable[[str], Awaitable[None]] + +_ACTIVITY_REQUEST_TIMEOUT = 12.0 +_MIYOUSHE_RECORD_SALT = "xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs" +_MIYOUSHE_WIDGET_SALT = "9ttJY72HxbjwWRNHJvn0n2AYue47nYsK" +_MIYOUSHE_DEVICE_FP_URL = ( + "https://public-data-api.mihoyo.com/device-fp/api/getFp" +) +_MIYOUSHE_DEVICE_LOGIN_URL = ( + "https://bbs-api.mihoyo.com/apihub/api/deviceLogin" +) +_MIYOUSHE_DEVICE_SAVE_URL = "https://bbs-api.mihoyo.com/apihub/api/saveDevice" +_MIYOUSHE_DEVICE_MODEL = "MI 8 SE" +_MIYOUSHE_DEVICE_NAME = "Xiaomi MI 8 SE" +_MIYOUSHE_ZZZ_DEVICE_NAME = "XiaomiMI 8 SE" +_MIYOUSHE_ZZZ_DEVICE_FP_SEED = "38d805c20d53d" +_MIYOUSHE_DEVICE_USER_AGENT = ( + "Mozilla/5.0 (Linux; Android 12; Unspecified Device) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Version/4.0 Chrome/103.0.5060.129 Mobile Safari/537.36 " + "miHoYoBBS/2.99.1" +) +_MIYOUSHE_ZZZ_USER_AGENT = ( + "Mozilla/5.0 (Linux; Android 12; MI 8 SE Build/RQ3A.211001.001; wv) " + "AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 " + "Chrome/111.0.5563.116 Mobile Safari/537.36 miHoYoBBS/2.73.1" +) +_MIYOUSHE_RISK_CODES = frozenset({1034, 5003, 10035, 10041}) + + +def _response_code(payload: Mapping[str, object]) -> int | None: + for key in ("retcode", "code", "status"): + value = payload.get(key) + if isinstance(value, bool) or value is None: + continue + try: + return int(value) + except (TypeError, ValueError): + continue + return None + + +def _response_json( + response: httpx.Response, + *, + platform: str, + game: str, +) -> Mapping[str, object]: + """读取安全的 JSON 响应,不把 HTML 或请求正文带到错误文案。""" + + if response.status_code == 404: + raise CommunityActivityTransportError( + f"{platform}{game}角色数据不存在(HTTP 404)", + status="unavailable", + ) + if response.status_code in (401, 403, 429): + raise CommunityActivityTransportError( + f"{platform}{game}接口受到上游限制(HTTP {response.status_code})", + status="limited", + ) + if response.status_code >= 500: + raise CommunityActivityTransportError( + f"{platform}{game}接口暂时不可用(HTTP {response.status_code})", + status="unavailable", + ) + try: + payload = response.json() + except ValueError as exc: + raise CommunityActivityTransportError( + f"{platform}{game}接口返回非 JSON,可能触发风控", + status="limited", + ) from exc + if not isinstance(payload, Mapping): + raise CommunityActivityTransportError( + f"{platform}{game}接口返回的数据结构无法识别", + status="unavailable", + ) + return payload + + +def _raise_business_error( + payload: Mapping[str, object], + *, + platform: str, + game: str, +) -> None: + code = _response_code(payload) + if code is None or code == 0: + return + if platform == "米游社" and abs(code) in _MIYOUSHE_RISK_CODES: + raise CommunityActivityTransportError( + f"米游社{game}查询受到验证限制(业务码 {code})," + "上游未返回便笺数据", + status="limited", + ) + limited = abs(code) in _MIYOUSHE_RISK_CODES or code in { + -100, + 10000, + 10001, + } + status = "limited" if limited else "unavailable" + raise CommunityActivityTransportError( + f"{platform}{game}接口返回业务失败(业务码 {code})", + status=status, + ) + + +def _miyoushe_ds( + *, + query: str = "", + widget: bool = False, + data: bool = False, + game: str = "", +) -> str: + timestamp = str(int(time.time())) + if data: + from .miyoushe import SALT_DATA + + # 星铁 Widget 的参考实现调用 generate_ds(data={}), + # 因而使用数据签名盐和空 body/query,而不是 iOS 无参盐。 + nonce = str(random.randint(100000, 200000)) + raw = f"salt={SALT_DATA}&t={timestamp}&r={nonce}&b=&q=" + else: + if widget: + nonce = "".join( + random.choices(string.ascii_lowercase + string.digits, k=6) + ) + raw = f"salt={_MIYOUSHE_WIDGET_SALT}&t={timestamp}&r={nonce}" + elif game == "绝区零": + # 绝区零参考实现的 CN 记录接口使用 xV8 盐和六位数字 nonce。 + nonce = str(random.randint(100000, 999999)) + raw = ( + f"salt={_MIYOUSHE_RECORD_SALT}&t={timestamp}&r={nonce}" + f"&b=&q={query}" + ) + elif query: + # 记录接口的参数签名沿用参考实现的 4X 合同。 + nonce = str(random.randint(100000, 200000)) + raw = ( + f"salt={_MIYOUSHE_RECORD_SALT}&t={timestamp}&r={nonce}" + f"&b=&q={query}" + ) + else: + nonce = "".join( + random.choices(string.ascii_lowercase + string.digits, k=6) + ) + raw = ( + f"salt={_MIYOUSHE_RECORD_SALT}&t={timestamp}&r={nonce}" + f"&b=&q={query}" + ) + return f"{timestamp},{nonce},{hashlib.md5(raw.encode()).hexdigest()}" + + +def _device_ds(body: str, *, game: str = "") -> str: + from .miyoushe import SALT_DATA + + timestamp = str(int(time.time())) + if game == "绝区零": + nonce = str(random.randint(100000, 999999)) + salt = _MIYOUSHE_RECORD_SALT + else: + nonce = "".join( + random.choices(string.ascii_lowercase + string.digits, k=6) + ) + salt = SALT_DATA + raw = f"salt={salt}&t={timestamp}&r={nonce}&b={body}&q=" + return f"{timestamp},{nonce},{hashlib.md5(raw.encode()).hexdigest()}" + + +def _device_fp_body( + device_id: str, + *, + game: str = "", + seed_id: str | None = None, +) -> dict[str, str]: + is_zzz = game == "绝区零" + normalized_device_id = device_id.lower() + zzz_seed_id = (seed_id or str(uuid.uuid4())) if is_zzz else "" + ext_fields = { + "userAgent": _MIYOUSHE_ZZZ_USER_AGENT + if is_zzz + else _MIYOUSHE_DEVICE_USER_AGENT, + "browserScreenSize": 243750, + "maxTouchPoints": 5, + "isTouchSupported": True, + "browserLanguage": "zh-CN", + "browserPlat": "Linux armv8l", + "browserTimeZone": "Asia/Shanghai", + "webGlRender": "Adreno (TM) 640", + "webGlVendor": "Qualcomm", + "numOfPlugins": 0, + "listOfPlugins": "unknown", + "screenRatio": 3, + "deviceMemory": "4", + "hardwareConcurrency": "8", + "cpuClass": "unknown", + "ifNotTrack": "unknown", + "ifAdBlock": 0, + "hasLiedResolution": 1, + "hasLiedOs": 0, + "hasLiedBrowser": 0, + } + if is_zzz: + # ZZZ-Plugin 的 CN getFp 合同同时需要 Android 设备字段和一枚 + # 与实际 device_id 分离的 bbs/seed UUID;这里使用固定兼容设备 + # 描述,不读取或持久化本机设备信息。 + ext_fields.update( + { + "proxyStatus": 1, + "isRoot": 0, + "romCapacity": "768", + "deviceName": _MIYOUSHE_DEVICE_MODEL, + "productName": "MI 8 SE", + "romRemain": "727", + "hostname": "BuildHost", + "screenSize": "1096x2434", + "isTablet": 0, + "aaid": zzz_seed_id, + "model": _MIYOUSHE_DEVICE_MODEL, + "brand": "Xiaomi", + "hardware": "qcom", + "deviceType": "MI 8 SE", + "devId": "REL", + "serialNumber": "unknown", + "sdCapacity": 224845, + "buildTime": "1692775759000", + "buildUser": "BuildUser", + "simState": 1, + "ramRemain": "218344", + "appUpdateTimeDiff": 1740498108042, + "deviceInfo": "Xiaomi/MI 8 SE/MI 8 SE/MI 8 SE", + "vaid": zzz_seed_id, + "buildType": "user", + "sdkVersion": "33", + "ui_mode": "UI_MODE_TYPE_NORMAL", + "isMockLocation": 0, + "cpuType": "arm64-v8a", + "isAirMode": 0, + "ringMode": 2, + "chargeStatus": 1, + "manufacturer": "Xiaomi", + "emulatorStatus": 0, + "appMemory": "768", + "osVersion": "12", + "vendor": "unknown", + "accelerometer": "-1.588236x6.8404818x6.999604", + "sdRemain": 218214, + "buildTags": "release-keys", + "packageName": "com.mihoyo.hyperion", + "networkType": "WiFi", + "oaid": zzz_seed_id, + "debugStatus": 1, + "ramCapacity": "224845", + "magnetometer": "-47.04375x51.3375x137.96251", + "display": _MIYOUSHE_DEVICE_MODEL, + "appInstallTimeDiff": 1740498108042, + "packageVersion": "2.35.0", + "gyroscope": "-0.22601996x-0.09453133x0.09040799", + "batteryStatus": 88, + "hasKeyboard": 0, + "board": "qcom", + } + ) + body: dict[str, str] = { + "seed_id": ( + zzz_seed_id + if is_zzz + else "".join( + random.choices(string.ascii_lowercase + string.digits, k=16) + ) + ), + # ZZZ 参考实现的固定值只是 getFp 模板占位符;最终 noDs 请求会 + # 替换为当前账号设备 ID,设备登记和后续查询也使用同一设备值。 + "device_id": normalized_device_id, + "platform": "2" if is_zzz else "5", + "seed_time": str(int(time.time() * 1000)), + "ext_fields": json.dumps(ext_fields, ensure_ascii=False), + "app_name": "bbs_cn" if is_zzz else "account_cn", + # getFp 需要一个本次申请用的种子;官方返回值才会用于后续请求。 + "device_fp": ( + _MIYOUSHE_ZZZ_DEVICE_FP_SEED + if is_zzz + else "".join(random.choices("0123456789abcdef", k=13)) + ), + } + if is_zzz: + body["bbs_device_id"] = zzz_seed_id + return body + + +def _miyoushe_v2_stoken(cookies: Mapping[str, str]) -> str: + """读取小组件要求的 v2 stoken,兼容旧 Cookie 命名。""" + + stoken_v2 = str(cookies.get("stoken_v2") or "").strip() + if stoken_v2: + return stoken_v2 + stoken = str(cookies.get("stoken") or "").strip() + return stoken if stoken.startswith("v2_") else "" + + +def _miyoushe_request_cookies( + cookies: Mapping[str, str], + *, + require_v2_stoken: bool = False, +) -> dict[str, str]: + """构建只用于本次请求的 Cookie 副本,不改变已保存凭据。""" + + result = dict(cookies) + if require_v2_stoken: + stoken_v2 = _miyoushe_v2_stoken(result) + if stoken_v2: + # 参考 BBSCookies.dict(v2_stoken=True, cookie_type=True): + # 请求字段使用 stoken,内部别名不随请求发送。 + result["stoken"] = stoken_v2 + result.pop("stoken_v1", None) + result.pop("stoken_v2", None) + return result + + +@dataclass +class CommunityActivityProvider: + """为一个账号组执行只读活动请求。 + + 一个 provider 实例应覆盖一个账号组的全部游戏,以便在同一轮查询内共享 + 森空岛刷新结果、米游社官方设备指纹和请求间隔。凭据默认只保留在内存中; + ``on_credential_update`` 只有由调用方显式提供时才会收到刷新后的森空岛值。 + """ + + platform: str + raw_credential: str = field(repr=False) + proxy: str | httpx.Proxy | None = None + on_credential_update: CredentialUpdate | None = field( + default=None, repr=False + ) + miyoushe_request_interval: float = 1.2 + _credential: dict[str, str] | None = field( + default=None, init=False, repr=False + ) + _device_id: str = field(default="", init=False, repr=False) + _device_fp: str = field(default="", init=False, repr=False) + _miyoushe_device_fps: dict[str, str] = field( + default_factory=dict, init=False, repr=False + ) + _credential_ready: bool = field(default=False, init=False, repr=False) + _credential_update_sent: bool = field(default=False, init=False, repr=False) + _miyoushe_cookies: dict[str, str] | None = field( + default=None, init=False, repr=False + ) + _miyoushe_capabilities: MiyousheSessionCapabilities | None = field( + default=None, init=False, repr=False + ) + _miyoushe_zzz_roles: frozenset[tuple[str, str]] | None = field( + default=None, init=False, repr=False + ) + _state_lock: asyncio.Lock = field( + default_factory=asyncio.Lock, init=False, repr=False + ) + _miyoushe_rate_lock: asyncio.Lock = field( + default_factory=asyncio.Lock, init=False, repr=False + ) + _last_miyoushe_request: float | None = field( + default=None, init=False, repr=False + ) + + def __post_init__(self) -> None: + self.raw_credential = str(self.raw_credential or "").strip() + + async def request(self, request: CommunityActivityRequest) -> object: + """执行一个已登记的请求规格并返回未解析的 JSON 对象。""" + + if request.target.platform != self.platform: + raise ValueError( + f"活动 provider 与目标平台不匹配:{request.target.platform}" + ) + if self.platform == "森空岛": + return await self._request_skland(request) + if self.platform == "米游社": + return await self._request_miyoushe(request) + raise ValueError(f"{self.platform}活动 provider 尚未登记") + + async def discover_roles( + self, + *, + account_uid: str, + account_name: str, + ) -> CommunityActivityRoleDiscovery: + """在同一 provider 会话内读取账号组的游戏角色。""" + + if self.platform == "森空岛": + return await self._discover_skland_roles( + account_uid=account_uid, + account_name=account_name, + ) + if self.platform == "米游社": + return await self._discover_miyoushe_roles( + account_uid=account_uid, + account_name=account_name, + ) + raise ValueError(f"{self.platform}活动 provider 尚未登记") + + async def _discover_skland_roles( + self, + *, + account_uid: str, + account_name: str, + ) -> CommunityActivityRoleDiscovery: + from .skland import ( + SKLAND_BINDING_URL, + ) + + target = CommunityActivityTarget( + account_uid=account_uid, + account_name=account_name, + platform="森空岛", + game="角色绑定", + role_uid="", + ) + request = CommunityActivityRequest( + target=target, + source=SKLAND_BINDING_URL, + method="GET", + auth_scope="skland", + signature_profile="skland_widget", + ) + try: + async with httpx.AsyncClient( + proxy=self.proxy, trust_env=False + ) as client: + credential, device_id = await self._prepare_skland( + None, + client, + ) + response = await client.get( + SKLAND_BINDING_URL, + headers=self._skland_request_headers( + request, + credential=credential, + device_id=device_id, + ), + timeout=_ACTIVITY_REQUEST_TIMEOUT, + ) + except httpx.TimeoutException as exc: + raise CommunityActivityTransportError( + "森空岛角色列表请求超时", status="failed" + ) from exc + except httpx.HTTPError as exc: + raise CommunityActivityTransportError( + "森空岛角色列表网络请求失败", status="failed" + ) from exc + + payload = _response_json( + response, + platform="森空岛", + game="角色列表", + ) + _raise_business_error(payload, platform="森空岛", game="角色列表") + return normalize_skland_roles(payload) + + async def _discover_miyoushe_roles( + self, + *, + account_uid: str, + account_name: str, + ) -> CommunityActivityRoleDiscovery: + from .miyoushe import ( + BASE_HEADERS, + ROLES_URL, + ) + + target = CommunityActivityTarget( + account_uid=account_uid, + account_name=account_name, + platform="米游社", + game="角色绑定", + role_uid="", + ) + request = CommunityActivityRequest( + target=target, + source=ROLES_URL, + method="GET", + auth_scope="miyoushe", + signature_profile="miyoushe_params", + ) + try: + async with httpx.AsyncClient( + proxy=self.proxy, trust_env=False + ) as client: + cookies, device_id, _ = await self._prepare_miyoushe( + request, + client, + require_activity=False, + ) + await self._wait_miyoushe_request() + headers = BASE_HEADERS.copy() + headers.update(self._miyoushe_request_headers( + request, + device_id=device_id, + device_fp="", + )) + response = await client.get( + ROLES_URL, + headers=headers, + cookies=_miyoushe_request_cookies( + cookies, + require_v2_stoken=True, + ), + timeout=_ACTIVITY_REQUEST_TIMEOUT, + ) + except httpx.TimeoutException as exc: + raise CommunityActivityTransportError( + "米游社角色列表请求超时", status="failed" + ) from exc + except httpx.HTTPError as exc: + raise CommunityActivityTransportError( + "米游社角色列表网络请求失败", status="failed" + ) from exc + + payload = _response_json( + response, + platform="米游社", + game="角色列表", + ) + _raise_business_error(payload, platform="米游社", game="角色列表") + discovery = normalize_miyoushe_roles(payload) + self._miyoushe_zzz_roles = frozenset( + (role.role_uid, role.server) + for role in discovery.roles_for_game("绝区零") + ) + capabilities = self._miyoushe_capabilities + if capabilities is None or capabilities.activity_ready: + return discovery + return replace( + discovery, + activity_capability=CommunityActivityCapability( + status="limited", + reason=capabilities.activity_reason, + ), + ) + + async def _request_skland( + self, request: CommunityActivityRequest + ) -> Mapping[str, object]: + try: + async with httpx.AsyncClient( + proxy=self.proxy, trust_env=False + ) as client: + credential, device_id = await self._prepare_skland( + request.target, + client, + ) + headers = self._skland_request_headers( + request, + credential=credential, + device_id=device_id, + ) + response = await client.get( + request.source, + params=request.query, + headers=headers, + timeout=request.timeout, + ) + except httpx.TimeoutException as exc: + raise CommunityActivityTransportError( + f"森空岛{request.target.game}接口请求超时", status="failed" + ) from exc + except httpx.HTTPError as exc: + raise CommunityActivityTransportError( + f"森空岛{request.target.game}接口网络请求失败", status="failed" + ) from exc + + payload = _response_json( + response, + platform="森空岛", + game=request.target.game, + ) + _raise_business_error( + payload, + platform="森空岛", + game=request.target.game, + ) + return payload + + async def _prepare_skland( + self, + target: CommunityActivityTarget | None, + client: httpx.AsyncClient, + ) -> tuple[dict[str, str], str]: + from .skland import ( + get_cached_device_id, + prepare_skland_session_credential, + refresh_skland_session_credential, + serialize_skland_credential, + validate_skland_credential, + ) + + async with self._state_lock: + if self._credential is None: + self._credential = validate_skland_credential( + self.raw_credential + ) + if not self._device_id: + cached_device_id = target.device_id if target else "" + self._device_id = cached_device_id or await get_cached_device_id( + self.proxy, client=client + ) + if not self._credential_ready: + credential = self._credential + if not credential["cred"] or not credential["token"]: + self._credential = await prepare_skland_session_credential( + client, + credential, + self._device_id, + ) + else: + try: + self._credential = await refresh_skland_session_credential( + client, + self._credential, + self._device_id, + timeout=_ACTIVITY_REQUEST_TIMEOUT, + ) + except ( + CommunityActivityTransportError, + httpx.HTTPError, + OSError, + ValueError, + ): + # 刷新失败不覆盖原凭据;本次查询继续用旧值,让活动接口 + # 自己返回可见的失效或风控状态。 + pass + self._credential_ready = True + if self.on_credential_update is not None: + serialized = serialize_skland_credential(self._credential) + if serialized != self.raw_credential: + await self._publish_credential_update(serialized) + return dict(self._credential), self._device_id + + async def _publish_credential_update(self, serialized: str) -> None: + if self._credential_update_sent or self.on_credential_update is None: + return + self._credential_update_sent = True + try: + await self.on_credential_update(serialized) + except Exception: + # 回写是调用方的可选持久化动作,不能改变只读查询结果。 + return + + @staticmethod + def _skland_request_headers( + request: CommunityActivityRequest, + *, + credential: Mapping[str, str], + device_id: str, + ) -> dict[str, str]: + from .skland import build_skland_signed_headers + + profile = request.signature_profile + platform = "3" if profile == "skland_endfield_web" else "1" + version = "1.0.0" if profile == "skland_endfield_web" else "1.21.0" + parsed = urlparse(request.source) + headers = build_skland_signed_headers( + credential["token"], + path=parsed.path, + body_or_query=request.query_string, + device_id=device_id, + headers=request.header_map, + platform=platform, + version=version, + ) + headers["cred"] = credential["cred"] + return headers + + async def _request_miyoushe( + self, request: CommunityActivityRequest + ) -> Mapping[str, object]: + if request.source.endswith("/zzz/widget"): + if self._miyoushe_zzz_roles is None: + await self._discover_miyoushe_roles( + account_uid=request.target.account_uid, + account_name=request.target.account_name, + ) + # 小组件不接受角色参数,也不返回角色标识,不能把默认角色的 + # 数据分配给多个角色;只有绑定列表能唯一确认归属时才回退。 + if self._miyoushe_zzz_roles != { + (request.target.role_uid, request.target.server) + }: + raise CommunityActivityTransportError( + "绝区零小组件无法唯一确认当前角色,已保留原便笺查询结果", + status="limited", + ) + try: + async with httpx.AsyncClient( + proxy=self.proxy, trust_env=False + ) as client: + cookies, device_id, device_fp = await self._prepare_miyoushe( + request, + client, + ) + request_cookies = _miyoushe_request_cookies( + cookies, + require_v2_stoken=True, + ) + await self._wait_miyoushe_request() + headers = self._miyoushe_request_headers( + request, + device_id=device_id, + device_fp=device_fp, + ) + response = await client.get( + request.source, + params=request.query, + headers=headers, + cookies=request_cookies, + timeout=request.timeout, + ) + except httpx.TimeoutException as exc: + raise CommunityActivityTransportError( + f"米游社{request.target.game}接口请求超时", status="failed" + ) from exc + except httpx.HTTPError as exc: + raise CommunityActivityTransportError( + f"米游社{request.target.game}接口网络请求失败", status="failed" + ) from exc + + payload = _response_json( + response, + platform="米游社", + game=request.target.game, + ) + _raise_business_error( + payload, + platform="米游社", + game=request.target.game, + ) + return payload + + async def _prepare_miyoushe( + self, + request: CommunityActivityRequest, + client: httpx.AsyncClient, + *, + require_activity: bool = True, + ) -> tuple[dict[str, str], str, str]: + from .miyoushe import ( + prepare_miyoushe_session, + validate_miyoushe_cookie, + ) + + async with self._state_lock: + if self._miyoushe_cookies is None: + validate_miyoushe_cookie(self.raw_credential) + session = prepare_miyoushe_session(self.raw_credential) + self._miyoushe_cookies = dict(session.cookies) + self._miyoushe_capabilities = session.capabilities + self._device_id = session.device_id + cookies = dict(self._miyoushe_cookies) + capabilities = self._miyoushe_capabilities + if ( + require_activity + and capabilities is not None + and not capabilities.activity_ready + ): + raise CommunityActivityTransportError( + capabilities.activity_reason, + status="limited", + ) + game = request.target.game + if game == "绝区零": + device_id = request.target.device_id.strip() + device_fp = request.target.device_fp.strip() + # 历史配置中的真实设备对继续优先使用;没有配置时复用下方 + # 官方 getFp 和设备登记链路自动准备运行期设备信息。 + if device_id and device_fp: + return cookies, device_id, device_fp + + device_id = self._device_id + device_fp = self._miyoushe_device_fps.get(game, "") + if request.requires_device_fingerprint and not device_fp: + device_fp = await self._acquire_device_fp( + client, + device_id, + game=game, + cookies=_miyoushe_request_cookies( + cookies, + require_v2_stoken=True, + ), + ) + await self._register_device( + client, + device_id=device_id, + device_fp=device_fp, + cookies=_miyoushe_request_cookies( + cookies, + require_v2_stoken=True, + ), + game=game, + ) + self._miyoushe_device_fps[game] = device_fp + self._device_fp = device_fp + return cookies, device_id, self._device_fp + + @staticmethod + def _miyoushe_device_fp_headers( + device_id: str, + *, + game: str, + user_agent: str, + ) -> dict[str, str]: + headers = { + "Content-Type": "application/json", + "User-Agent": user_agent, + } + if game == "绝区零": + headers.update( + { + "Accept": "application/json, text/plain, */*", + "Origin": "https://act.mihoyo.com", + "Referer": "https://act.mihoyo.com/", + "X-Requested-With": "com.mihoyo.hyperion", + "x-rpc-app_version": "2.73.1", + "x-rpc-channel": "mihoyo", + "x-rpc-client_type": "2", + "x-rpc-csm_source": "myself", + "x-rpc-device_id": device_id, + "x-rpc-device_model": _MIYOUSHE_DEVICE_MODEL, + "x-rpc-device_name": _MIYOUSHE_DEVICE_NAME, + "x-rpc-sys_version": "12", + } + ) + return headers + + async def _acquire_device_fp( + self, + client: httpx.AsyncClient, + device_id: str, + *, + game: str = "", + seed_id: str = "", + cookies: Mapping[str, str] | None = None, + ) -> str: + user_agent = ( + _MIYOUSHE_ZZZ_USER_AGENT + if game == "绝区零" + else _MIYOUSHE_DEVICE_USER_AGENT + ) + response = await client.post( + _MIYOUSHE_DEVICE_FP_URL, + json=_device_fp_body( + device_id, + game=game, + seed_id=seed_id or None, + ), + headers=self._miyoushe_device_fp_headers( + device_id, + game=game, + user_agent=user_agent, + ), + cookies=cookies, + timeout=_ACTIVITY_REQUEST_TIMEOUT, + ) + payload = _response_json(response, platform="米游社", game="设备指纹") + _raise_business_error(payload, platform="米游社", game="设备指纹") + data = payload.get("data") + device_fp = data.get("device_fp") if isinstance(data, Mapping) else None + if not isinstance(device_fp, str) or not device_fp.strip(): + raise CommunityActivityTransportError( + "米游社官方设备指纹响应无效,无法确认设备状态", + status="limited", + ) + return device_fp.strip() + + async def _register_device( + self, + client: httpx.AsyncClient, + *, + device_id: str, + device_fp: str, + cookies: Mapping[str, str], + game: str = "", + ) -> None: + is_zzz = game == "绝区零" + app_version = "2.73.1" if is_zzz else "2.99.1" + base_data = { + "app_version": app_version, + "device_id": device_id, + "device_name": ( + _MIYOUSHE_ZZZ_DEVICE_NAME + if is_zzz + else _MIYOUSHE_DEVICE_NAME + ), + "os_version": "33" if is_zzz else "30", + "platform": "Android", + } + base_headers = { + "x-rpc-client_type": "2", + "x-rpc-app_version": app_version, + "x-rpc-sys_version": "12", + "x-rpc-channel": "mihoyo" if is_zzz else "miyousheluodi", + "x-rpc-device_id": device_id, + "x-rpc-device_name": _MIYOUSHE_DEVICE_NAME, + "x-rpc-device_model": _MIYOUSHE_DEVICE_MODEL, + "x-rpc-device_fp": device_fp, + "Referer": ( + "https://act.mihoyo.com/" + if is_zzz + else "https://app.mihoyo.com" + ), + "Content-Type": "application/json; charset=UTF-8", + "User-Agent": ( + _MIYOUSHE_ZZZ_USER_AGENT + if is_zzz + else "okhttp/4.9.3" + ), + } + if is_zzz: + base_headers.update( + { + "Accept": "application/json, text/plain, */*", + "Origin": "https://act.mihoyo.com", + "X-Requested-With": "com.mihoyo.hyperion", + "x-rpc-csm_source": "myself", + } + ) + # 登记是降低风控概率的辅助步骤;失败时保留官方 getFp 值继续查询, + # 由真正的实时便笺响应决定最终是成功还是受限。两个登记接口各自 + # 重新计算 DS,避免复用已过时的请求签名。 + for url in (_MIYOUSHE_DEVICE_LOGIN_URL, _MIYOUSHE_DEVICE_SAVE_URL): + try: + data = { + **base_data, + "registration_id": "".join( + random.choices( + string.ascii_lowercase + string.digits, k=19 + ) + ), + } + body = json.dumps(data, separators=(",", ":")) + headers = { + **base_headers, + "DS": _device_ds(body, game=game), + } + response = await client.post( + url, + headers=headers, + content=body, + cookies=cookies, + timeout=_ACTIVITY_REQUEST_TIMEOUT, + ) + payload = _response_json(response, platform="米游社", game="设备登记") + _raise_business_error( + payload, + platform="米游社", + game="设备登记", + ) + except ( + CommunityActivityTransportError, + httpx.HTTPError, + OSError, + ValueError, + ): + continue + + async def _wait_miyoushe_request(self) -> None: + if self.miyoushe_request_interval <= 0: + return + async with self._miyoushe_rate_lock: + now = time.monotonic() + if self._last_miyoushe_request is not None: + delay = self.miyoushe_request_interval - ( + now - self._last_miyoushe_request + ) + if delay > 0: + await asyncio.sleep(delay) + self._last_miyoushe_request = time.monotonic() + + @staticmethod + def _miyoushe_request_headers( + request: CommunityActivityRequest, + *, + device_id: str, + device_fp: str, + ) -> dict[str, str]: + profile = request.signature_profile + headers = request.header_map + if profile == "miyoushe_params": + headers["DS"] = _miyoushe_ds( + query=request.query_string, + game=request.target.game, + ) + elif profile == "miyoushe_ios": + headers["DS"] = _miyoushe_ds(widget=True) + elif profile == "miyoushe_data": + headers["DS"] = _miyoushe_ds(data=True) + else: + raise CommunityActivityTransportError( + "米游社当前请求规格未确认,不发起请求", + status="limited", + ) + headers.setdefault("x-rpc-app_version", "2.99.1") + if not request.source.endswith("/zzz/widget"): + headers.setdefault("x-rpc-device_model", _MIYOUSHE_DEVICE_MODEL) + headers.setdefault("x-rpc-device_name", _MIYOUSHE_DEVICE_NAME) + headers.setdefault("User-Agent", _MIYOUSHE_DEVICE_USER_AGENT) + if request.requires_device_id: + headers["x-rpc-device_id"] = device_id + else: + headers.pop("x-rpc-device_id", None) + if request.requires_device_fingerprint and device_fp: + headers["x-rpc-device_fp"] = device_fp + else: + headers.pop("x-rpc-device_fp", None) + if ( + request.target.game == "星穹铁道" + and profile == "miyoushe_data" + ): + # 参考项目的星铁便笺走 iOS Widget 合同,不混入记录接口的 + # Android 设备头,避免上游返回通用 -10001。 + headers.update( + { + "x-rpc-app_version": "2.63.1", + "x-rpc-channel": "appstore", + "x-rpc-page": "", + "x-rpc-device_fp": "", + "x-rpc-device_id": "", + "x-rpc-device_model": "iPhone10,2", + "x-rpc-device_name": "iPhone", + "x-rpc-sys_version": "16.2", + "Connection": "keep-alive", + "Host": "api-takumi-record.mihoyo.com", + "User-Agent": "WidgetExtension/231 CFNetwork/1390 Darwin/22.0.0", + } + ) + return headers + + +def build_community_activity_requester( + platform: str, + raw_credential: str, + *, + proxy: str | httpx.Proxy | None = None, + on_credential_update: CredentialUpdate | None = None, + miyoushe_request_interval: float = 1.2, +) -> Callable[[CommunityActivityRequest], Awaitable[object]]: + """构造一个账号级 provider 请求函数,供活动 collector 注入。""" + + if platform not in {"森空岛", "米游社"}: + raise ValueError(f"{platform}活动 provider 尚未登记") + provider = CommunityActivityProvider( + platform=platform, + raw_credential=raw_credential, + proxy=proxy, + on_credential_update=on_credential_update, + miyoushe_request_interval=miyoushe_request_interval, + ) + return provider.request diff --git a/app/tools/community_activity_roles.py b/app/tools/community_activity_roles.py new file mode 100644 index 000000000..22564488d --- /dev/null +++ b/app/tools/community_activity_roles.py @@ -0,0 +1,311 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025-2026 AUTO-MAS Team + +# This file incorporates request knowledge from the following acknowledged +# community projects: +# +# nonebot-plugin-mystool Copyright © 2023-2025 Ljzd-PRO +# https://github.com/Ljzd-PRO/nonebot-plugin-mystool +# +# gxy12345/arknights-plugin +# https://github.com/gxy12345/arknights-plugin + +# 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 + +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Literal + +from .community_activity_transport import CommunityActivityTarget + +__all__ = [ + "ACTIVITY_COMMUNITY_DEFINITIONS", + "CommunityActivityProviderDefinition", + "CommunityActivityCapability", + "CommunityActivityRole", + "CommunityActivityRoleDiscovery", + "normalize_miyoushe_roles", + "normalize_skland_roles", +] + + +ActivityCommunity = Literal["森空岛", "米游社"] + + +@dataclass(frozen=True) +class CommunityActivityProviderDefinition: + """活动查询 provider 的目标游戏登记。""" + + platform: ActivityCommunity + games: tuple[str, ...] + + +ACTIVITY_COMMUNITY_DEFINITIONS = ( + CommunityActivityProviderDefinition( + platform="森空岛", + games=("明日方舟", "终末地"), + ), + CommunityActivityProviderDefinition( + platform="米游社", + games=("原神", "星穹铁道", "绝区零"), + ), +) + + +@dataclass(frozen=True) +class CommunityActivityCapability: + """角色发现后可公开的脱敏活动能力。""" + + status: Literal["ready", "limited"] = "ready" + reason: str = "" + + +@dataclass(frozen=True) +class CommunityActivityRole: + """不含凭据和设备值的单个游戏角色。""" + + platform: ActivityCommunity + game: str + role_uid: str + server: str = "" + role_name: str = "" + user_id: str = "" + + def to_target( + self, + *, + account_uid: str, + account_name: str, + device_id: str = "", + device_fp: str = "", + ) -> CommunityActivityTarget: + """转换为活动 transport 使用的脱敏目标。""" + + return CommunityActivityTarget( + account_uid=account_uid, + account_name=account_name, + platform=self.platform, + game=self.game, + role_uid=self.role_uid, + server=self.server, + role_name=self.role_name, + user_id=self.user_id, + device_id=device_id, + device_fp=device_fp, + ) + + +@dataclass(frozen=True) +class CommunityActivityRoleDiscovery: + """一次账号组角色发现的脱敏结果。""" + + platform: ActivityCommunity + roles: tuple[CommunityActivityRole, ...] = () + activity_capability: CommunityActivityCapability = field( + default_factory=CommunityActivityCapability + ) + + def roles_for_game(self, game: str) -> tuple[CommunityActivityRole, ...]: + """按游戏筛选角色并保持上游顺序。""" + + return tuple(role for role in self.roles if role.game == game) + + +def _text(item: Mapping[str, object], *names: str) -> str: + for name in names: + value = item.get(name) + if value is not None and str(value).strip(): + return str(value).strip() + return "" + + +def _binding_entries( + payload: Mapping[str, object], *, app_code: str +) -> tuple[Mapping[str, object], ...]: + data = payload.get("data") + if isinstance(data, list): + groups: object = data + elif isinstance(data, Mapping): + groups = data.get("list") + if groups is None and data.get("appCode"): + groups = [data] + elif groups is None and ( + isinstance(data.get("bindingList"), list) + or isinstance(data.get("binding_list"), list) + ): + binding_list = data.get("bindingList") + if binding_list is None: + binding_list = data.get("binding_list") + assert isinstance(binding_list, list) + # 部分版本直接返回一个无 appCode 的绑定列表。仅按两款游戏互斥的 + # 角色字段筛选,不把同一条记录同时解释为两个游戏。 + if app_code == "arknights": + return tuple( + entry + for entry in binding_list + if isinstance(entry, Mapping) + and bool(_text(entry, "uid")) + and not bool(_text(entry, "roleId", "role_id")) + and not isinstance(entry.get("roles"), list) + ) + return tuple( + entry + for entry in binding_list + if isinstance(entry, Mapping) + and ( + bool(_text(entry, "roleId", "role_id")) + or isinstance(entry.get("roles"), list) + ) + ) + else: + groups = None + + if not isinstance(groups, list): + raise ValueError("森空岛角色列表响应缺少绑定列表") + + entries: list[Mapping[str, object]] = [] + for group in groups: + if not isinstance(group, Mapping): + continue + group_app_code = _text(group, "appCode", "app_code") + if group_app_code != app_code: + continue + binding_list = group.get("bindingList") + if binding_list is None: + binding_list = group.get("binding_list") + if binding_list is None: + continue + if not isinstance(binding_list, list): + raise ValueError("森空岛角色绑定列表响应格式无效") + entries.extend( + entry for entry in binding_list if isinstance(entry, Mapping) + ) + return tuple(entries) + + +def _deduplicate_roles( + roles: list[CommunityActivityRole], +) -> tuple[CommunityActivityRole, ...]: + seen: set[tuple[str, str, str]] = set() + result: list[CommunityActivityRole] = [] + for role in roles: + key = (role.game, role.role_uid, role.server) + if key in seen: + continue + seen.add(key) + result.append(role) + return tuple(result) + + +def normalize_skland_roles( + payload: Mapping[str, object], +) -> CommunityActivityRoleDiscovery: + """归一化森空岛明日方舟和终末地绑定响应。""" + + roles: list[CommunityActivityRole] = [] + for entry in _binding_entries(payload, app_code="arknights"): + role_uid = _text(entry, "uid") + if role_uid: + roles.append( + CommunityActivityRole( + platform="森空岛", + game="明日方舟", + role_uid=role_uid, + server=_text(entry, "channelName", "channel_name"), + role_name=_text(entry, "nickName", "nickname", "name"), + ) + ) + + for entry in _binding_entries(payload, app_code="endfield"): + nested_roles = entry.get("roles") + if not isinstance(nested_roles, list): + nested_roles = [entry] if _text(entry, "roleId", "role_id") else [] + entry_user_id = _text(entry, "userId", "user_id") + entry_server = _text(entry, "serverId", "server_id") + for role in nested_roles: + if not isinstance(role, Mapping): + continue + role_uid = _text(role, "roleId", "role_id") + if not role_uid: + continue + roles.append( + CommunityActivityRole( + platform="森空岛", + game="终末地", + role_uid=role_uid, + server=_text(role, "serverId", "server_id") or entry_server, + role_name=_text(role, "nickname", "nickName", "name"), + user_id=_text(role, "userId", "user_id") or entry_user_id, + ) + ) + + return CommunityActivityRoleDiscovery("森空岛", _deduplicate_roles(roles)) + + +_MIYOUSHE_GAME_NAMES = { + "hk4e_cn": "原神", + "hkrpg_cn": "星穹铁道", + "nap_cn": "绝区零", +} + + +def normalize_miyoushe_roles( + payload: Mapping[str, object], +) -> CommunityActivityRoleDiscovery: + """归一化米游社角色接口的扁平和按游戏嵌套响应。""" + + data = payload.get("data") + data_list = data.get("list") if isinstance(data, Mapping) else None + if not isinstance(data_list, list): + return CommunityActivityRoleDiscovery("米游社") + + role_entries: list[Mapping[str, object]] = [] + for item in data_list: + if not isinstance(item, Mapping): + continue + nested = item.get("list") + if isinstance(nested, list): + role_entries.extend( + role for role in nested if isinstance(role, Mapping) + ) + else: + role_entries.append(item) + + roles: list[CommunityActivityRole] = [] + for role in role_entries: + game = _MIYOUSHE_GAME_NAMES.get(_text(role, "game_biz")) + role_uid = _text(role, "game_uid", "gameUid") + if not game or not role_uid: + continue + roles.append( + CommunityActivityRole( + platform="米游社", + game=game, + role_uid=role_uid, + server=_text(role, "region", "server"), + role_name=_text(role, "nickname", "nickName", "name"), + ) + ) + + return CommunityActivityRoleDiscovery("米游社", _deduplicate_roles(roles)) diff --git a/app/tools/community_activity_transport.py b/app/tools/community_activity_transport.py new file mode 100644 index 000000000..2c1bb8dd7 --- /dev/null +++ b/app/tools/community_activity_transport.py @@ -0,0 +1,612 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025-2026 AUTO-MAS Team + +# ZZZ widget protocol reference: PizzaHelperUnited +# Copyright © 2024 and onwards Pizza Studio (AGPL-3.0-or-later) +# https://github.com/pizza-studio/PizzaHelperUnited + +# 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 + + +"""社区日常查询的 provider 请求规格和独立执行边界。""" + +from __future__ import annotations + +import asyncio +import re +import time +from collections.abc import Awaitable, Callable, Iterable +from dataclasses import dataclass, field, replace +from datetime import datetime +from typing import Literal +from urllib.parse import urlencode + +from app.utils.constants import UTC8 + +from .community_activity_parser import parse_activity_snapshot +from .community_contract import ActivityState, CommunityActivitySnapshot + +__all__ = [ + "CommunityActivityRequest", + "CommunityActivityTarget", + "CommunityActivityTransportError", + "ActivitySignatureProfile", + "build_community_activity_requests", + "collect_community_activity", +] + + +ActivityRequester = Callable[["CommunityActivityRequest"], Awaitable[object]] +ActivityAuthScope = Literal["skland", "miyoushe"] +ActivitySignatureProfile = Literal[ + "skland_widget", + "skland_endfield_web", + "miyoushe_params", + "miyoushe_ios", + "miyoushe_data", +] + +SKLAND_BASE_URL = "https://zonai.skland.com" +MIYOUSHE_RECORD_BASE_URL = "https://api-takumi-record.mihoyo.com" + + +class CommunityActivityTransportError(RuntimeError): + """provider 请求失败时供适配器传递的安全原因。""" + + def __init__( + self, + reason: str, + *, + status: Literal["limited", "unavailable", "failed"] = "failed", + ) -> None: + safe_reason = _normalize_transport_reason(reason, status=status) + super().__init__(safe_reason) + self.reason = safe_reason + self.status = status + + +def _normalize_transport_reason( + reason: object, + *, + status: Literal["limited", "unavailable", "failed"], +) -> str: + """压缩适配器文案,拒绝明显的键值型敏感字段。""" + + text = " ".join(str(reason or "").split()) + if re.search( + r"(?i)(?:token|cookie|authorization|cred|device[_-]?(?:id|fp))\s*[:=]", + text, + ): + text = "社区活动接口受到上游限制" if status == "limited" else "社区活动接口请求失败" + if not text: + text = "社区活动接口受到上游限制" if status == "limited" else "社区活动接口请求失败" + return text[:240] + + +@dataclass(frozen=True) +class CommunityActivityTarget: + """一次社区活动查询所需的账号/角色脱敏上下文。""" + + account_uid: str + account_name: str + platform: str + game: str + role_uid: str + server: str = "" + role_name: str = "" + user_id: str = "" + device_id: str = field(default="", repr=False) + device_fp: str = field(default="", repr=False) + + def role_metadata(self) -> dict[str, str]: + """返回传给解析器的角色元数据,不包含凭据或设备字段。""" + + return { + "name": self.role_name, + "roleId": self.role_uid, + "serverName": self.server, + } + + +@dataclass(frozen=True) +class CommunityActivityRequest: + """不携带认证值的单次 provider 请求规格。""" + + target: CommunityActivityTarget + source: str + method: Literal["GET"] + params: tuple[tuple[str, str], ...] = () + headers: tuple[tuple[str, str], ...] = () + auth_scope: ActivityAuthScope = "miyoushe" + signature_profile: ActivitySignatureProfile = "miyoushe_data" + requires_ds: bool = False + requires_device_id: bool = False + requires_device_fingerprint: bool = False + timeout: float = 30.0 + + @property + def query(self) -> dict[str, str]: + """返回供 HTTP 客户端使用的查询参数副本。""" + + return dict(self.params) + + @property + def header_map(self) -> dict[str, str]: + """返回供 HTTP 客户端使用的公开请求头副本。""" + + return dict(self.headers) + + @property + def query_string(self) -> str: + """返回用于签名计算的 URL 编码查询串。""" + + return urlencode(self.params) + + +def _required(value: str, name: str) -> str: + normalized = str(value or "").strip() + if not normalized: + raise ValueError(f"社区活动请求缺少 {name}") + return normalized + + +def _pairs(*values: tuple[str, object]) -> tuple[tuple[str, str], ...]: + return tuple((name, str(value)) for name, value in values if str(value).strip()) + + +def _headers( + target: CommunityActivityTarget, + *values: tuple[str, str], + include_device: bool = True, +) -> tuple[tuple[str, str], ...]: + headers = {name: value for name, value in values if value} + if include_device and target.device_id: + headers["x-rpc-device_id"] = target.device_id + if include_device and target.device_fp: + headers["x-rpc-device_fp"] = target.device_fp + return tuple(headers.items()) + + +def _skland_headers(target: CommunityActivityTarget) -> tuple[tuple[str, str], ...]: + return _headers( + target, + ("os", "iOS"), + ("platform", "2"), + ("manufacturer", "Apple"), + ("nid", "1"), + ("vName", "0.1.1"), + include_device=False, + ) + + +def _miyoushe_bbs_headers( + target: CommunityActivityTarget, + *, + tool_version: str = "v4.2.2-ys", + page: str = "v4.2.2-ys_#/ys/daily", +) -> tuple[tuple[str, str], ...]: + return _headers( + target, + ("Accept", "application/json, text/plain, */*"), + ("Origin", "https://webstatic.mihoyo.com"), + ("Referer", "https://webstatic.mihoyo.com/"), + ("x-rpc-client_type", "5"), + ("x-rpc-tool_version", tool_version), + ("x-rpc-page", page), + ) + + +def _miyoushe_widget_headers( + target: CommunityActivityTarget, + *, + client_type: str, +) -> tuple[tuple[str, str], ...]: + return _headers( + target, + ("Accept", "*/*"), + ("Accept-Encoding", "gzip, deflate, br"), + ("Accept-Language", "zh-CN,zh-Hans;q=0.9"), + ("Referer", "https://app.mihoyo.com"), + ("x-rpc-client_type", client_type), + ("x-rpc-channel", "appstore"), + ) + + +def _miyoushe_zzz_headers( + target: CommunityActivityTarget, +) -> tuple[tuple[str, str], ...]: + """复用绝区零参考插件的 CN 记录接口请求头。""" + + return _headers( + target, + ("Accept", "application/json, text/plain, */*"), + ("Origin", "https://act.mihoyo.com"), + ("Referer", "https://act.mihoyo.com/"), + ( + "User-Agent", + "Mozilla/5.0 (Linux; Android 12; " + "MI 8 SE Build/RQ3A.211001.001; wv) " + "AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 " + "Chrome/111.0.5563.116 Mobile Safari/537.36 " + "miHoYoBBS/2.73.1", + ), + ("x-rpc-app_version", "2.73.1"), + ("x-rpc-channel", "mihoyo"), + ("x-rpc-client_type", "2"), + ("x-rpc-csm_source", "myself"), + ("x-rpc-sys_version", "12"), + ("X-Requested-With", "com.mihoyo.hyperion"), + ("Connection", "keep-alive"), + include_device=False, + ) + + +def _miyoushe_zzz_widget_headers( + target: CommunityActivityTarget, +) -> tuple[tuple[str, str], ...]: + """使用已实跑的 PizzaHelperUnited 国服小组件请求头。""" + + return _headers( + target, + ( + "User-Agent", + "WidgetExtension/434 CFNetwork/1492.0.1 Darwin/23.3.0", + ), + ("Referer", "https://webstatic.mihoyo.com"), + ("Origin", "https://webstatic.mihoyo.com"), + ("Accept", "application/json, text/plain, */*"), + ("Accept-Language", "zh-CN,zh-Hans;q=0.9"), + ("Connection", "keep-alive"), + ("X-Requested-With", "com.mihoyo.hyperion"), + ("x-rpc-app_version", "2.40.1"), + ("x-rpc-client_type", "5"), + ("x-rpc-page", "3.1.3_#/rpg"), + ("x-rpc-language", "zh-cn"), + ("Sec-Fetch-Dest", "empty"), + ("Sec-Fetch-Site", "same-site"), + ("Sec-Fetch-Mode", "cors"), + include_device=False, + ) + + +def build_community_activity_requests( + target: CommunityActivityTarget, + *, + timestamp: int | None = None, +) -> tuple[CommunityActivityRequest, ...]: + """按已确认的 provider 接口生成一款游戏的请求规格。 + + 规格不包含 Cookie、Token、Authorization 或请求体;认证签名由调用方的 + provider 适配器在执行请求时注入。原神和星穹铁道保留记录接口失败后的 + Widget/记录接口回退,绝区零记录接口失败后回退到已确认的小组件接口。 + + Args: + target: 当前账号组和游戏角色的脱敏上下文。 + timestamp: 兼容调用方的可选查询时间戳;当前已确认的森空岛请求不使用它。 + + Returns: + 按优先级排列的单次请求规格。 + + Raises: + ValueError: 目标缺少该 provider 必需的角色字段。 + """ + + role_uid = _required(target.role_uid, "角色 UID") + if target.platform == "森空岛" and target.game == "明日方舟": + query_timestamp = timestamp if timestamp is not None else int(time.time()) + return ( + CommunityActivityRequest( + target=target, + source=f"{SKLAND_BASE_URL}/api/v1/game/player/info", + method="GET", + params=_pairs(("uid", role_uid), ("ts", query_timestamp)), + headers=_skland_headers(target), + auth_scope="skland", + signature_profile="skland_widget", + requires_device_id=False, + ), + ) + + if target.platform == "森空岛" and target.game == "终末地": + server = _required(target.server or "1", "终末地 serverId") + return ( + CommunityActivityRequest( + target=target, + source=f"{SKLAND_BASE_URL}/web/v1/game/endfield/card/detail", + method="GET", + params=_pairs( + ("roleId", role_uid), + ("serverId", server), + # 已确认成功记录只携带 roleId/serverId;userId 不属于 + # 当前角色卡合同,不能混入签名查询串。 + ), + headers=_headers( + target, + ("Accept", "*/*"), + ("Origin", "https://game.skland.com"), + ("Referer", "https://game.skland.com/"), + ("X-Requested-With", "com.hypergryph.skland"), + ("sk-game-role", f"3_{role_uid}_{server}"), + ), + auth_scope="skland", + signature_profile="skland_endfield_web", + requires_device_id=True, + ), + ) + + if target.platform != "米游社": + raise ValueError(f"{target.platform}{target.game}尚未登记活动请求") + + server = _required(target.server, "米游社角色区服") + role_params = _pairs(("role_id", role_uid), ("server", server)) + if target.game == "原神": + return ( + CommunityActivityRequest( + target=target, + source=( + f"{MIYOUSHE_RECORD_BASE_URL}" + "/game_record/app/genshin/api/dailyNote" + ), + method="GET", + params=role_params, + headers=_miyoushe_bbs_headers(target), + auth_scope="miyoushe", + signature_profile="miyoushe_params", + requires_ds=True, + requires_device_id=True, + requires_device_fingerprint=True, + ), + CommunityActivityRequest( + target=target, + source=( + f"{MIYOUSHE_RECORD_BASE_URL}" + "/game_record/genshin/aapi/widget/v2" + ), + method="GET", + headers=_miyoushe_widget_headers(target, client_type="1"), + auth_scope="miyoushe", + signature_profile="miyoushe_ios", + requires_ds=True, + requires_device_id=True, + requires_device_fingerprint=True, + ), + ) + + if target.game == "星穹铁道": + return ( + CommunityActivityRequest( + target=target, + source=( + f"{MIYOUSHE_RECORD_BASE_URL}" + "/game_record/app/hkrpg/aapi/widget" + ), + method="GET", + headers=_miyoushe_widget_headers(target, client_type="2"), + auth_scope="miyoushe", + signature_profile="miyoushe_data", + requires_ds=True, + requires_device_id=False, + requires_device_fingerprint=False, + ), + CommunityActivityRequest( + target=target, + source=( + f"{MIYOUSHE_RECORD_BASE_URL}" + "/game_record/app/hkrpg/api/note" + ), + method="GET", + params=role_params, + headers=_miyoushe_bbs_headers( + target, + tool_version="v4.51.1", + page="v4.51.1_#/rpg", + ), + auth_scope="miyoushe", + signature_profile="miyoushe_params", + requires_ds=True, + requires_device_id=True, + requires_device_fingerprint=True, + ), + ) + + if target.game == "绝区零": + return ( + CommunityActivityRequest( + target=target, + source=( + f"{MIYOUSHE_RECORD_BASE_URL}" + "/event/game_record_zzz/api/zzz/note" + ), + method="GET", + params=role_params, + headers=_miyoushe_zzz_headers(target), + auth_scope="miyoushe", + signature_profile="miyoushe_params", + requires_ds=True, + requires_device_id=True, + requires_device_fingerprint=True, + ), + CommunityActivityRequest( + target=target, + source=( + f"{MIYOUSHE_RECORD_BASE_URL}" + "/event/game_record_zzz/api/zzz/widget" + ), + method="GET", + headers=_miyoushe_zzz_widget_headers(target), + auth_scope="miyoushe", + signature_profile="miyoushe_params", + requires_ds=True, + requires_device_id=True, + requires_device_fingerprint=False, + ), + ) + + raise ValueError(f"米游社{target.game}尚未登记活动请求") + + +def _failed_snapshot( + target: CommunityActivityTarget, + *, + status: ActivityState, + reason: str, + source: str = "", +) -> CommunityActivitySnapshot: + return CommunityActivitySnapshot( + account=target.account_name, + account_uid=target.account_uid, + game=target.game, + platform=target.platform, + status=status, + reason=reason, + updated_at=datetime.now(tz=UTC8).isoformat(), + role_name=target.role_name, + role_uid=target.role_uid, + server=target.server, + source=source, + ) + + +def _transport_failure( + error: Exception, +) -> tuple[ActivityState, str]: + if isinstance(error, CommunityActivityTransportError): + return error.status, error.reason + if isinstance(error, asyncio.TimeoutError): + return "failed", "社区活动接口请求超时" + return "failed", "社区活动接口请求失败" + + +async def _collect_one( + target: CommunityActivityTarget, + requester: ActivityRequester, + *, + semaphore: asyncio.Semaphore, + timestamp: int | None, +) -> CommunityActivitySnapshot: + async with semaphore: + try: + requests = build_community_activity_requests(target, timestamp=timestamp) + except (ValueError, CommunityActivityTransportError) as exc: + if isinstance(exc, CommunityActivityTransportError): + status, reason = exc.status, exc.reason + else: + status, reason = "unavailable", str(exc) + return _failed_snapshot( + target, + status=status, + reason=reason, + ) + + last_snapshot: CommunityActivitySnapshot | None = None + for request in requests: + try: + payload = await asyncio.wait_for( + requester(request), + timeout=request.timeout, + ) + snapshot = parse_activity_snapshot( + payload, + account_uid=target.account_uid, + account_name=target.account_name, + platform=target.platform, + game=target.game, + role=target.role_metadata(), + ) + except Exception as exc: + status, reason = _transport_failure(exc) + snapshot = _failed_snapshot( + target, + status=status, + reason=reason, + source=request.source, + ) + + if snapshot.status == "success" and snapshot.source != request.source: + snapshot = replace(snapshot, source=request.source) + if last_snapshot is None or _activity_failure_priority( + snapshot.status + ) >= _activity_failure_priority(last_snapshot.status): + last_snapshot = snapshot + if snapshot.status == "success": + return snapshot + + return last_snapshot or _failed_snapshot( + target, + status="failed", + reason="社区活动接口未返回结果", + ) + + +def _activity_failure_priority(status: ActivityState) -> int: + """回退时保留更具体的受限/不可用原因。""" + + return { + "limited": 3, + "unavailable": 2, + "failed": 1, + "empty": 0, + "success": 4, + }.get(status, 1) + + +async def collect_community_activity( + targets: Iterable[CommunityActivityTarget], + requester: ActivityRequester, + *, + max_concurrency: int = 4, + timestamp: int | None = None, +) -> tuple[CommunityActivitySnapshot, ...]: + """独立并发查询多款游戏,保证单款失败不取消其他游戏。 + + 该函数只使用本地活动查询 semaphore,不获取签到锁、不调用签到入口,也不 + 负责保存凭据。调用方若需要防止重复点击,应在 `community_activity_flow()` + 边界内调用本函数。 + + Args: + targets: 待查询的账号/角色集合。 + requester: provider 适配器提供的异步请求函数;认证值只能在其内部注入。 + max_concurrency: 活动查询自身的并发上限。 + timestamp: 传给森空岛 Widget 的可选固定时间戳。 + + Returns: + 与输入顺序一致的独立游戏快照。 + + Raises: + ValueError: 并发上限小于 1。 + """ + + if max_concurrency < 1: + raise ValueError("社区活动查询并发上限必须大于 0") + + target_list = tuple(targets) + semaphore = asyncio.Semaphore(max_concurrency) + return tuple( + await asyncio.gather( + *( + _collect_one( + target, + requester, + semaphore=semaphore, + timestamp=timestamp, + ) + for target in target_list + ) + ) + ) diff --git a/app/tools/community_contract.py b/app/tools/community_contract.py new file mode 100644 index 000000000..59e0cf956 --- /dev/null +++ b/app/tools/community_contract.py @@ -0,0 +1,165 @@ +# 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 + + +"""游戏社区内部结果契约,不依赖 API、前端或通知渠道。""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Literal + + +CredentialState = Literal["empty", "valid", "incomplete", "invalid"] +ActivityState = Literal[ + "success", + "empty", + "limited", + "unavailable", + "failed", +] + + +class CommunitySignInProgressError(RuntimeError): + """游戏社区签到已在执行。""" + + +@dataclass(frozen=True) +class CredentialStatus: + """凭据本地校验摘要,不包含凭据值。""" + + token_field: str + state: CredentialState + fields: tuple[str, ...] = () + reason: str = "" + + @property + def configured(self) -> bool: + """返回是否存在非空凭据。""" + + return self.state != "empty" + + @property + def locally_valid(self) -> bool: + """返回是否通过本地格式校验,不表示上游认证成功。""" + + return self.state == "valid" + + +@dataclass(frozen=True) +class CommunitySignResult: + """签到结果的领域表示,同时兼容旧版字典字段。""" + + account: str + account_uid: str + game: str + platform: str + status: str + reward: str = "" + reason: str = "" + completed: bool = False + notification_only: bool = False + + @classmethod + def from_legacy( + cls, + item: Mapping[str, object], + *, + fallback_account: str = "未知用户", + fallback_uid: str = "", + ) -> "CommunitySignResult": + """从既有结果字典读取领域结果,不改变外部字段语义。""" + + account = str(item.get("account") or fallback_account) + if account == "未知用户": + account = fallback_account + return cls( + account=account, + account_uid=str(item.get("account_uid") or fallback_uid), + game=str(item.get("game") or ""), + platform=str(item.get("platform") or "未知"), + status=str(item.get("status") or "失败"), + reward=str(item.get("reward") or ""), + reason=str(item.get("reason") or ""), + completed=bool(item.get("_completed")), + notification_only=bool(item.get("_notification_only")), + ) + + def to_legacy(self) -> dict[str, object]: + """转换回旧版签到结果字典,供现有 API 与通知继续消费。""" + + result: dict[str, object] = { + "account": self.account, + "account_uid": self.account_uid, + "game": self.game, + "platform": self.platform, + "status": self.status, + "reward": self.reward, + "reason": self.reason, + } + if self.completed: + result["_completed"] = True + if self.notification_only: + result["_notification_only"] = True + return result + + +@dataclass(frozen=True) +class CommunityActivitySnapshot: + """日常活跃度领域结果,供后续只读查询模块复用。""" + + account: str + account_uid: str + game: str + platform: str + status: ActivityState + completed: int | None = None + target: int | None = None + tasks: tuple[Mapping[str, object], ...] = () + resources: tuple[Mapping[str, object], ...] = () + reason: str = "" + updated_at: str = "" + role_name: str = "" + role_uid: str = "" + server: str = "" + source: str = "" + + def to_dict(self) -> dict[str, object]: + """转换为与 API 无关的普通字典。""" + + return { + "account": self.account, + "account_uid": self.account_uid, + "game": self.game, + "platform": self.platform, + "status": self.status, + "completed": self.completed, + "target": self.target, + "tasks": [dict(task) for task in self.tasks], + "resources": [dict(resource) for resource in self.resources], + "reason": self.reason, + "updated_at": self.updated_at, + "role_name": self.role_name, + "role_uid": self.role_uid, + "server": self.server, + "source": self.source, + } diff --git a/app/tools/community_credentials.py b/app/tools/community_credentials.py new file mode 100644 index 000000000..00ad579fb --- /dev/null +++ b/app/tools/community_credentials.py @@ -0,0 +1,193 @@ +# 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 + + +"""游戏社区凭据解析和本地校验边界。 + +该模块只保存格式、字段名和本地校验状态,不保存、截断、脱敏或改写原始 Token。 +平台适配器仍负责真实上游认证、刷新和网络请求。 +""" + +import json +from collections.abc import Callable +from dataclasses import dataclass +from typing import Literal + +from .community_contract import CredentialStatus + + +CredentialFormat = Literal["empty", "opaque", "cookie", "json"] + + +@dataclass(frozen=True) +class CommunityCredentialInfo: + """不包含凭据值的解析摘要,供编排层判断凭据形态。""" + + token_field: str + format: CredentialFormat + fields: tuple[str, ...] = () + configured: bool = False + + def has_any(self, *field_names: str) -> bool: + """判断 JSON/Cookie 是否包含任一指定字段名。""" + + return any(field_name in self.fields for field_name in field_names) + + +def _cookie_field_names(raw_token: str) -> tuple[str, ...]: + fields = set() + for item in raw_token.split(";"): + key, separator, _ = item.strip().partition("=") + if separator and key: + fields.add(key.strip()) + return tuple(sorted(fields)) + + +def _json_field_names(raw_token: str) -> tuple[str, ...] | None: + if not raw_token.startswith("{"): + return None + try: + payload = json.loads(raw_token) + except json.JSONDecodeError: + return None + if not isinstance(payload, dict): + return None + return tuple(sorted(str(key) for key in payload if isinstance(key, str))) + + +def parse_community_credential( + token_field: str, raw_token: object +) -> CommunityCredentialInfo: + """解析凭据外形,原始值始终由调用方持有且不会被本模块写回。""" + + text = raw_token if isinstance(raw_token, str) else str(raw_token or "") + text = text.strip() + if not text: + return CommunityCredentialInfo(token_field, "empty") + + if token_field == "MiyousheToken": + fields = _cookie_field_names(text) + if fields: + return CommunityCredentialInfo( + token_field, "cookie", fields, configured=True + ) + + fields = _json_field_names(text) + if fields is not None: + return CommunityCredentialInfo(token_field, "json", fields, configured=True) + + # 保留旧版纯 Token、损坏 JSON 和未知平台 Token 的“已配置”语义。 + return CommunityCredentialInfo(token_field, "opaque", configured=True) + + +def is_community_credential_configured(token_field: str, raw_token: object) -> bool: + """判断凭据是否非空,不对平台有效性作结论。""" + + return parse_community_credential(token_field, raw_token).configured + + +CredentialValidator = Callable[[str], object] + + +def _validate_skland(raw_token: str) -> object: + from .skland import validate_skland_credential + + return validate_skland_credential(raw_token) + + +def _validate_miyoushe(raw_token: str) -> object: + from .miyoushe import validate_miyoushe_cookie + + return validate_miyoushe_cookie(raw_token) + + +def _validate_cloud_genshin(raw_token: str) -> object: + from .cloud_genshin import validate_cloud_genshin_token + + return validate_cloud_genshin_token(raw_token) + + +def _validate_kuro(raw_token: str) -> object: + from .kuro import validate_kuro_credential + + return validate_kuro_credential(raw_token) + + +def _validate_taygedo(raw_token: str) -> object: + from .taygedo import validate_taygedo_credential + + return validate_taygedo_credential(raw_token) + + +_CREDENTIAL_VALIDATORS: dict[str, CredentialValidator] = { + "SklandToken": _validate_skland, + "MiyousheToken": _validate_miyoushe, + "CloudGenshinToken": _validate_cloud_genshin, + "KuroToken": _validate_kuro, + "TaygedoToken": _validate_taygedo, +} + + +def _credential_error_state(reason: str) -> Literal["incomplete", "invalid"]: + if any(marker in reason for marker in ("缺少", "不能为空", "必须", "不应")): + return "incomplete" + return "invalid" + + +def validate_community_credential( + token_field: str, raw_token: object +) -> CredentialStatus: + """执行提供方本地凭据校验,不代表上游 Token 仍然有效。""" + + info = parse_community_credential(token_field, raw_token) + if not info.configured: + return CredentialStatus(token_field, "empty", info.fields) + + validator = _CREDENTIAL_VALIDATORS.get(token_field) + if validator is None: + return CredentialStatus(token_field, "valid", info.fields) + + text = raw_token if isinstance(raw_token, str) else str(raw_token or "") + try: + validator(text) + except ValueError as exc: + reason = str(exc).strip() or "凭据格式无效" + return CredentialStatus( + token_field, + _credential_error_state(reason), + info.fields, + reason, + ) + except Exception: + return CredentialStatus( + token_field, + "invalid", + info.fields, + "凭据本地校验失败", + ) + return CredentialStatus(token_field, "valid", info.fields) + + +# 旧名称继续作为历史调用兼容入口。 +GameSignCredentialInfo = CommunityCredentialInfo +parse_game_sign_credential = parse_community_credential +is_game_sign_credential_configured = is_community_credential_configured +validate_game_sign_credential = validate_community_credential diff --git a/app/tools/community_notify.py b/app/tools/community_notify.py new file mode 100644 index 000000000..29a6d9851 --- /dev/null +++ b/app/tools/community_notify.py @@ -0,0 +1,588 @@ +# 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 + + +import base64 +import re +from html import escape +from html.parser import HTMLParser +from io import BytesIO +from pathlib import Path +from typing import Literal + +from PIL import Image, ImageDraw, ImageFont + +from app.core import Config +from app.core.notify import NotifyPayload, dispatch, global_target +from app.utils.logger import get_logger + +logger = get_logger("游戏社区通知") + +NOTIFICATION_SEND_ATTEMPTS = 2 +NOTIFICATION_RETRY_DELAY_SECONDS = 1 +_SUCCESS_STATUSES = {"成功", "已签到"} +_PLATFORM_ORDER = ("森空岛", "米游社", "库街区", "塔吉多", "云异环") +NotificationBodyFormat = Literal["text", "markdown"] + + +def detect_community_notification_format(content: str) -> NotificationBodyFormat: + """识别通知正文是否包含 Markdown 块级标记。""" + + for line in content.splitlines(): + stripped = line.lstrip() + if stripped.startswith(("# ", "## ", "### ", "- ", "* ", "> ", "```")): + return "markdown" + return "markdown" if "**" in content else "text" + + +def _result_status_text(item: dict[str, object]) -> str: + """将单条签到结果转换为模板中的短状态。""" + + status = str(item.get("status", "失败")) + if status == "已签到": + return "已签" + if status == "成功": + return "签到成功" + if status == "风控": + return "签到失败-风控" + + reason = str(item.get("reason", "") or "").strip() + if not reason or reason in {"失败", "签到失败"}: + return "签到失败" + if reason.startswith("签到失败-"): + return reason + return f"签到失败-{reason}" + + +def _result_account(item: dict[str, object]) -> str: + """返回优先使用角色名/UID 的账号标识。""" + + account = str(item.get("account", "") or "").strip() + if account: + return account + account_uid = str(item.get("account_uid", "") or "").strip() + return account_uid or "未知用户" + + +def _result_nickname(item: dict[str, object]) -> str: + """从已确认的账号展示格式中提取角色名,保留其它原始昵称。""" + + account = " ".join(_result_account(item).splitlines()) + # 上游常用 昵称/昵称(UID),昵称本身也可能带斜线。 + repeated = re.fullmatch(r"(?P.+)/(?P=name)\(\d+\)", account) + if repeated: + return repeated.group("name") + role = re.fullmatch(r"[^/]+/(.+)\(\d+\)", account) + if role: + return role.group(1).strip() + alias, separator, suffix = account.rpartition("/") + if ( + separator + and alias + and suffix + in ( + item.get("platform"), + item.get("game"), + "官服", + "B服", + ) + ): + return alias + return account + + +def _notification_results( + results: list[dict[str, object]], +) -> list[dict[str, object]]: + """过滤没有实际签到角色的平台占位结果。""" + + return [item for item in results if not item.get("_notification_only")] + + +def _ordered_platforms( + grouped: dict[str, list[dict[str, object]]], +) -> list[str]: + """按通知模板固定社区顺序,并保留未知平台结果。""" + + return [ + *[platform for platform in _PLATFORM_ORDER if platform in grouped], + *[platform for platform in grouped if platform not in _PLATFORM_ORDER], + ] + + +def _result_detail(item: dict[str, object]) -> str: + """保留已签和失败原因,有奖励的成功结果省去重复状态文案。""" + + status = _result_status_text(item) + game = str(item.get("game", "") or "").strip() + reward = str(item.get("reward", "") or "").strip().replace("×", "x") + if not reward: + return status + # 云异环的剩余时长属于状态,不能标成新领取的奖励。 + duration_only = game == "云异环" and not reward.startswith("每日首登") + reward_text = reward if duration_only else f"📥{reward}" + if item.get("status") == "成功" and not duration_only: + return reward_text + return f"{status} {reward_text}" + + +def _format_notification_item(item: dict[str, object]) -> str: + """生成各通知渠道共用的紧凑单行结果。""" + + marker = "✅" if item.get("status") in _SUCCESS_STATUSES else "❌" + game = str(item.get("game") or item.get("platform") or "未知") + identity = _result_nickname(item) + detail = _result_detail(item) + return " ".join(f"{marker} [{game}] {identity} {detail}".splitlines()) + + +_FONT_CANDIDATES = ( + Path("C:/Windows/Fonts/msyh.ttc"), + Path("C:/Windows/Fonts/simhei.ttf"), + Path("/System/Library/Fonts/PingFang.ttc"), + Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"), + Path("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc"), +) +_SYMBOL_FONT_CANDIDATES = ( + Path("C:/Windows/Fonts/seguiemj.ttf"), + Path("/System/Library/Fonts/Apple Color Emoji.ttc"), + Path("/usr/share/fonts/truetype/noto/NotoEmoji-Regular.ttf"), + Path("/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf"), +) +_SYMBOL_FALLBACK = {"✅": "[OK]", "❌": "[X]", "📥": "[+]"} + + +def _load_notification_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + """加载可显示中文的通知字体,缺失时回退到 Pillow 内置字体。""" + + for path in _FONT_CANDIDATES: + if path.is_file(): + return ImageFont.truetype(str(path), size) + return ImageFont.load_default(size=size) + + +def _load_notification_symbol_font(size: int) -> ImageFont.FreeTypeFont | None: + """加载通知符号字体,固定尺寸字体不可用时保留文字回退。""" + + for path in _SYMBOL_FONT_CANDIDATES: + if path.is_file(): + try: + return ImageFont.truetype(str(path), size) + except OSError: + continue + return None + + +def _notification_text_runs( + text: str, + font: ImageFont.FreeTypeFont | ImageFont.ImageFont, + symbol_font: ImageFont.FreeTypeFont | None, +) -> list[tuple[str, ImageFont.FreeTypeFont | ImageFont.ImageFont]]: + """测量与绘制使用相同字体,避免中文字体缺少图标时产生方框。""" + + runs = [] + for part in re.split(r"([✅❌📥])", text): + if not part: + continue + if part in _SYMBOL_FALLBACK: + runs.append( + (part, symbol_font) if symbol_font else (_SYMBOL_FALLBACK[part], font) + ) + else: + runs.append((part, font)) + return runs + + +def _wrap_notification_text( + draw: ImageDraw.ImageDraw, + text: str, + font: ImageFont.FreeTypeFont | ImageFont.ImageFont, + max_width: int, + *, + symbol_font: ImageFont.FreeTypeFont | None = None, +) -> list[str]: + """按像素宽度换行,兼容中文这类没有空格分隔的长文本。""" + + lines: list[str] = [] + current = "" + for char in text: + if char == "\n": + lines.append(current) + current = "" + continue + candidate = current + char + candidate_width = sum( + draw.textlength(part, font=run_font) + for part, run_font in _notification_text_runs( + candidate, font=font, symbol_font=symbol_font + ) + ) + if current and candidate_width > max_width: + lines.append(current) + current = char + else: + current = candidate + lines.append(current) + return lines + + +def _community_notification_rows( + results: list[dict[str, object]], + *, + include_signature: bool = True, +) -> list[tuple[str, str]]: + """统一文本、Markdown、HTML 与图片中的平台分组和结果行。""" + + results = _notification_results(results) + if not results: + return [] + + grouped: dict[str, list[dict[str, object]]] = {} + for item in results: + platform = str(item.get("platform", "未知") or "未知") + grouped.setdefault(platform, []).append(item) + + rows: list[tuple[str, str]] = [("title", "【社区签到通知】")] + for platform in _ordered_platforms(grouped): + items = grouped[platform] + total = len(items) + success_count = sum( + 1 for item in items if item.get("status") in _SUCCESS_STATUSES + ) + platform = " ".join(platform.splitlines()) + rows.append(("heading", f"• {platform}({success_count}/{total}):")) + for item in items: + rows.append(("item", _format_notification_item(item))) + if include_signature: + rows.append(("footer", "AUTO-MAS 敬上")) + return rows + + +def _community_notification_image(results: list[dict[str, object]]) -> bytes: + """把社区签到结果渲染为适合聊天渠道展示的 PNG 图片。""" + + width = 760 + card_margin = 16 + padding = 32 + inner_width = width - (card_margin + padding) * 2 + row_specs: list[tuple[str, list[str], int]] = [] + total_height = card_margin * 2 + padding * 2 + symbol_font = _load_notification_symbol_font(22) + + with Image.new("RGB", (width, 100), "#f4f4f4") as measure_image: + measure = ImageDraw.Draw(measure_image) + for kind, text in _community_notification_rows(results): + if kind == "title": + size = 32 + spacing = 16 + elif kind == "heading": + size = 24 + spacing = 14 + elif kind == "footer": + size = 18 + spacing = 16 + else: + size = 22 + spacing = 8 + font = _load_notification_font(size) + lines = _wrap_notification_text( + measure, + text, + font, + inner_width, + symbol_font=symbol_font, + ) + line_height = int(size * 1.55) + row_height = len(lines) * line_height + row_specs.append((kind, lines, row_height)) + total_height += row_height + spacing + + image = Image.new("RGB", (width, total_height), "#f4f4f4") + draw = ImageDraw.Draw(image) + draw.rounded_rectangle( + (card_margin, card_margin, width - card_margin, total_height - card_margin), + radius=18, + fill="#ffffff", + ) + + y = card_margin + padding + for kind, lines, row_height in row_specs: + if kind == "title": + font = _load_notification_font(32) + color = "#009faa" + spacing = 16 + elif kind == "heading": + font = _load_notification_font(24) + color = "#009faa" + spacing = 14 + elif kind == "footer": + font = _load_notification_font(18) + color = "#888888" + spacing = 16 + else: + font = _load_notification_font(22) + color = "#303133" + spacing = 8 + + for line_index, line in enumerate(lines): + x = 48.0 + line_y = y + line_index * (row_height // len(lines)) + baseline = ( + font.getmetrics()[0] if isinstance(font, ImageFont.FreeTypeFont) else 0 + ) + for part, run_font in _notification_text_runs( + line, font=font, symbol_font=symbol_font + ): + draw.text( + (x, line_y + baseline), + part, + font=run_font, + fill=color, + anchor="ls" if baseline else None, + embedded_color=True, + ) + x += draw.textlength(part, font=run_font) + y += row_height + spacing + + buffer = BytesIO() + image.save(buffer, format="PNG", optimize=True) + return buffer.getvalue() + + +def _community_notification_image_base64( + results: list[dict[str, object]], +) -> str: + """生成 OneBot 等渠道可用的纯 Base64 PNG 数据。""" + + return base64.b64encode(_community_notification_image(results)).decode("ascii") + + +def _escape_markdown_text(value: str) -> str: + """账号和奖励作为文字展示,不解释为 Markdown 或 HTML。""" + + return re.sub(r"([\\`*_{}\[\]<>!#|])", r"\\\1", " ".join(value.splitlines())) + + +def format_community_notification( + results: list[dict[str, object]], + *, + output_format: NotificationBodyFormat = "markdown", + include_signature: bool = True, +) -> str: + """按社区分组生成通知正文,并显式支持文本与 Markdown。""" + + lines: list[str] = [] + for kind, text in _community_notification_rows( + results, include_signature=include_signature + ): + if kind in {"heading", "footer"} and lines: + lines.append("") + if output_format == "markdown": + text = _escape_markdown_text(text) + if kind in {"title", "heading"}: + text = f"**{text}**" + elif kind == "heading": + text = f" {text}" + lines.append(text) + + separator = " \n" if output_format == "markdown" else "\n" + return separator.join(lines) + + +def format_community_task_summary( + results: list[dict[str, object]], + *, + output_format: NotificationBodyFormat = "text", +) -> str: + """复用紧凑通知格式追加社区结果,签名由原任务报告保留。""" + + return format_community_notification( + results, output_format=output_format, include_signature=False + ) + + +def get_task_community_summary( + task_info: object, *, output_format: NotificationBodyFormat = "text" +) -> str: + """读取尚未发送的社区签到汇总,兼容旧任务字段。""" + + consumed = ( + getattr(task_info, "community_summary_consumed", False) + if hasattr(task_info, "community_summary_consumed") + else getattr(task_info, "game_sign_summary_consumed", False) + ) + if consumed: + return "" + + results = ( + getattr(task_info, "community_results", []) + if hasattr(task_info, "community_results") + else getattr(task_info, "game_sign_results", []) + ) + if not results: + return "" + + return format_community_task_summary(list(results), output_format=output_format) + + +def mark_task_community_summary_consumed(task_info: object) -> None: + """标记社区签到汇总已由任务报告消费。""" + + if hasattr(task_info, "community_summary_consumed"): + setattr(task_info, "community_summary_consumed", True) + else: + setattr(task_info, "game_sign_summary_consumed", True) + + +def append_task_community_summary( + task_info: object, + result: str, + *, + output_format: NotificationBodyFormat = "text", +) -> str: + """将尚未发送的社区签到汇总附加到任务报告。""" + + if not Config.ToolsConfig.get("GameSign", "NotifyEnabled"): + return result + + summary = get_task_community_summary(task_info, output_format=output_format) + return f"{result}\n\n{summary}" if summary else result + + +class _TaskReportSummaryPosition(HTMLParser): + """定位模板签名或正文末尾,保持专项 HTML 和样式原样。""" + + def __init__(self) -> None: + super().__init__(convert_charrefs=False) + self.position: tuple[int, int] | None = None + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if ( + self.position is None + and "signature" in (dict(attrs).get("class") or "").split() + ): + self.position = self.getpos() + + def handle_endtag(self, tag: str) -> None: + if self.position is None and tag == "body": + self.position = self.getpos() + + +def append_community_summary_html(content: str, summary: str) -> str: + """在任务报告签名前追加经过转义的社区摘要。""" + + parser = _TaskReportSummaryPosition() + parser.feed(content) + parser.close() + offset = len(content) + if parser.position is not None: + line, column = parser.position + offset = ( + sum(len(part) for part in content.splitlines(keepends=True)[: line - 1]) + + column + ) + fragment = ( + '
' + f"{escape(summary)}
" + ) + return f"{content[:offset]}{fragment}{content[offset:]}" + + +def _community_html_fragment( + results: list[dict[str, object]], *, include_signature: bool = True +) -> str: + """生成社区通知的 HTML 主体,动态文本先统一转义。""" + + tags = {"title": "h2", "heading": "h3", "item": "p", "footer": "p"} + html_lines = [] + for kind, text in _community_notification_rows( + results, include_signature=include_signature + ): + tag = tags[kind] + html_lines.append(f"<{tag}>{escape(text)}") + return "".join(html_lines) + + +def _render_community_html( + results: list[dict[str, object]], + title: str, +) -> str: + """按 MAS 通知模板渲染社区签到邮件正文。""" + + return Config.notify_env.get_template("community_result.html").render( + title=title, + content=_community_html_fragment(results, include_signature=False), + ) + + +def build_community_notification_payload( + results: list[dict[str, object]], +) -> NotifyPayload: + """把结构化社区结果渲染为统一通知载荷。 + + Markdown 是独立社区通知的主内容形态;ServerChan/Webhook 使用 Markdown + 源码,Koishi 使用转换后的 HTML,系统/OpenClaw 等纯文本渠道使用不带 + Markdown 标记的可读文本,邮件使用 MAS HTML 卡片模板。 + """ + + results = _notification_results(results) + title = "社区签到通知" + plain_text = format_community_notification(results, output_format="text") + markdown_text = format_community_notification(results) + return NotifyPayload( + title=title, + text=plain_text, + append_signature=False, + html=_render_community_html(results, title), + markdown_text=markdown_text, + webhook_image_base64=_community_notification_image_base64(results), + koishi_text=_community_html_fragment(results), + koishi_msgtype="html", + ) + + +async def push_community_notification( + results: list[dict[str, object]], +) -> list[str]: + """推送手动或启动时触发的游戏社区结果通知。""" + results = _notification_results(results) + if not results: + return [] + + payload = build_community_notification_payload(results) + dispatch_result = await dispatch( + payload, + [global_target(include_system=True)], + attempts=NOTIFICATION_SEND_ATTEMPTS, + retry_delay=NOTIFICATION_RETRY_DELAY_SECONDS, + ) + return list(dispatch_result.failed) + + +__all__ = [ + "append_task_community_summary", + "detect_community_notification_format", + "format_community_notification", + "format_community_task_summary", + "get_task_community_summary", + "mark_task_community_summary_consumed", + "push_community_notification", +] diff --git a/app/tools/community_plugins.py b/app/tools/community_plugins.py new file mode 100644 index 000000000..2d75b823a --- /dev/null +++ b/app/tools/community_plugins.py @@ -0,0 +1,200 @@ +# 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 + + +"""云崽社区插件能力目录,不负责网络请求、凭据保存或任务执行。""" + +from dataclasses import dataclass +from typing import Literal + +__all__ = [ + "COMMUNITY_PLUGIN_REFERENCES", + "CommunityCapability", + "CommunityPluginReference", + "get_community_plugin_references", + "get_confirmed_community_capabilities", + "supports_community_capability", +] + + +CommunityCapability = Literal[ + "credential", + "sign", + "community_task", + "daily_activity", + "role_profile", + "announcement", + "activity_calendar", + "gacha_record", + "game_data", + "notification", +] + + +@dataclass(frozen=True) +class CommunityPluginReference: + """记录外部社区插件已展示的能力,不能直接视为 AUTO-MAS 已接入。""" + + name: str + provider: str + games: tuple[str, ...] + capabilities: frozenset[CommunityCapability] + evidence: tuple[str, ...] + interface_confirmed: bool = True + + +# 只记录参考项目中已看到的能力;接口接入仍需单独完成协议和凭据审查。 +COMMUNITY_PLUGIN_REFERENCES = ( + CommunityPluginReference( + name="Yunzai-Kuro-Plugin", + provider="库街区", + games=("战双帕弥什", "鸣潮"), + capabilities=frozenset( + { + "credential", + "sign", + "community_task", + "daily_activity", + "role_profile", + "gacha_record", + "notification", + } + ), + evidence=( + "apps/gameEnergy.js", + "apps/bbsActivityTask.js", + "model/gameCard.js", + ), + ), + CommunityPluginReference( + name="ZZZ-Plugin", + provider="米游社", + games=("绝区零",), + capabilities=frozenset( + { + "credential", + "daily_activity", + "role_profile", + "gacha_record", + "game_data", + } + ), + evidence=("dist/apps/note.js", "dist/lib/mysapi/api.js"), + ), + CommunityPluginReference( + name="zmd-plugin", + provider="森空岛", + games=("明日方舟", "终末地"), + capabilities=frozenset( + { + "credential", + "sign", + "daily_activity", + "role_profile", + "announcement", + "activity_calendar", + "gacha_record", + } + ), + evidence=( + "model/skland/api.js", + "apps/status.js", + "resources/enduid/daily.html", + ), + ), + CommunityPluginReference( + name="bh3-plugin", + provider="米游社", + games=("崩坏3",), + capabilities=frozenset( + {"credential", "daily_activity", "role_profile", "game_data"} + ), + evidence=("lib/api.js", "apps/main.js"), + ), + CommunityPluginReference( + name="1999-plugin", + provider="未确认", + games=("重返未来:1999",), + capabilities=frozenset(), + evidence=("README.md",), + interface_confirmed=False, + ), + CommunityPluginReference( + name="skland-plugin", + provider="森空岛", + games=("明日方舟",), + capabilities=frozenset( + { + "credential", + "sign", + "daily_activity", + "role_profile", + "gacha_record", + "notification", + } + ), + evidence=("components/Code.js", "apps/Sanity.js", "apps/SignIn.js"), + ), +) + + +def get_community_plugin_references( + *, + provider: str | None = None, + game: str | None = None, + confirmed_only: bool = False, +) -> tuple[CommunityPluginReference, ...]: + """按社区或游戏筛选参考插件能力。""" + + provider_value = provider.strip() if provider else None + game_value = game.strip() if game else None + return tuple( + item + for item in COMMUNITY_PLUGIN_REFERENCES + if (not confirmed_only or item.interface_confirmed) + and (provider_value is None or item.provider == provider_value) + and (game_value is None or game_value in item.games) + ) + + +def get_confirmed_community_capabilities( + provider: str, game: str +) -> frozenset[CommunityCapability]: + """合并已确认参考插件对指定社区/游戏展示的能力。""" + + capabilities: set[CommunityCapability] = set() + for item in get_community_plugin_references( + provider=provider, + game=game, + confirmed_only=True, + ): + capabilities.update(item.capabilities) + return frozenset(capabilities) + + +def supports_community_capability( + provider: str, + game: str, + capability: CommunityCapability, +) -> bool: + """判断参考项目是否展示过指定社区能力。""" + + return capability in get_confirmed_community_capabilities(provider, game) diff --git a/app/tools/community_sign_provider.py b/app/tools/community_sign_provider.py new file mode 100644 index 000000000..70f1d39b2 --- /dev/null +++ b/app/tools/community_sign_provider.py @@ -0,0 +1,671 @@ +# 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 + + +"""游戏社区签到 provider 注册、执行与结果格式化实现。""" + +import time +from dataclasses import dataclass, field +from typing import Awaitable, Callable, Protocol + +import httpx + +from app.core import Config +from app.utils.logger import get_logger +from app.utils.security import format_exception_reason + +from .community_contract import CommunitySignResult +from .community_credentials import ( + is_community_credential_configured, + parse_community_credential, + validate_community_credential, +) +from .game_sign_result import build_skland_sign_results + +logger = get_logger("游戏社区签到") + +_system_time_checked_at = 0.0 +_SYSTEM_TIME_CHECK_INTERVAL = 300.0 +_TAYGEDO_COMMUNITY_DETAIL_GAMES = frozenset( + ("幻塔社区", "异环社区", "塔吉多社区") +) + + +class _TimeSource(Protocol): + """社区时间校验所需的最小时间接口。""" + + def monotonic(self) -> float: ... + + def time(self) -> float: ... + + +@dataclass +class _CommunityProviderRun: + """单个社区适配器的结果和需要回写的凭据。""" + + results: list[dict[str, object]] + platforms: tuple[str, ...] + credential_updates: dict[str, str] = field(default_factory=dict) + + +CredentialUpdateCallback = Callable[[str, str], Awaitable[None]] +ProviderRunner = Callable[ + [str, str, str, CredentialUpdateCallback | None], + Awaitable[_CommunityProviderRun], +] +PlatformResolver = Callable[[str], tuple[str, ...]] +ErrorGameResolver = Callable[[str], str] + + +@dataclass(frozen=True) +class _CommunitySignProvider: + """以配置字段驱动的社区签到适配器描述。""" + + token_field: str + log_name: str + runner: ProviderRunner + resolve_platforms: PlatformResolver + error_game: ErrorGameResolver + + +async def check_community_system_time(*, time_source: _TimeSource = time) -> None: + """检查系统时间偏差并提示用户,不阻断签到流程。 + + 时间源不可信或不可用时(服务退役、被劫持的网络等)仅记录日志; + 真正对时间敏感的只有米游社 DS 签名,其容差远大于此处阈值, + 因此偏差过大时也只告警,由具体平台的签到结果反映实际影响。 + """ + global _system_time_checked_at + now = time_source.monotonic() + if now - _system_time_checked_at < _SYSTEM_TIME_CHECK_INTERVAL: + return + # 无论时间服务成功与否,都缓存本次尝试,避免网络异常时每次签到重复等待。 + _system_time_checked_at = now + + try: + async with httpx.AsyncClient(proxy=Config.proxy) as client: + resp = await client.get( + "https://worldtimeapi.org/api/timezone/Asia/Shanghai", timeout=5 + ) + api_time = resp.json().get("unixtime", 0) + if not api_time: + return + local_time = time_source.time() + offset = abs(api_time - local_time) + if offset > 300: + logger.warning( + f"系统时间与网络时间偏差约 {offset:.0f} 秒,部分平台签到可能失败,建议校准系统时间" + ) + elif offset > 30: + logger.info(f"系统时间偏差 {offset:.0f} 秒,在可接受范围内") + except Exception as e: + logger.debug(f"时间校准跳过: {e}") + + +def _empty_platform_result( + *, account_name: str, account_uid: str, platform: str +) -> dict[str, object]: + """为没有返回可签到角色的平台保留通知占位,不写入前端结果列表。""" + + return CommunitySignResult( + account=account_name, + account_uid=account_uid, + game="", + platform=platform, + status="失败", + reason="未获取到可签到角色", + notification_only=True, + ).to_legacy() + + +def _fixed_platforms(platform: str) -> PlatformResolver: + return lambda _token: (platform,) + + +def _default_error_game(platform: str) -> str: + return platform + + +def _cloud_genshin_error_game(_platform: str) -> str: + return "云原神" + + +def _taygedo_error_game(platform: str) -> str: + # 未完成凭据/上游校验时没有可靠的游戏 ID,不把结果猜成某一款游戏。 + return "塔吉多社区" if platform == "塔吉多" else "云异环" + + +def _resolve_taygedo_platforms(raw_token: str) -> tuple[str, ...]: + """根据塔吉多凭据字段确定实际启用的社区。""" + + try: + credential = parse_community_credential("TaygedoToken", raw_token) + except Exception: + return ("塔吉多",) + + platforms = [] + if credential.has_any( + "refreshToken", + "refresh_token", + "accessToken", + "access_token", + ): + platforms.append("塔吉多") + if credential.has_any("cloudToken", "cloud_token") and credential.has_any( + "cloudUserId", "cloud_user_id" + ): + platforms.append("云异环") + return tuple(platforms) or ("塔吉多",) + + +async def _run_skland_provider( + token: str, + account_name: str, + account_uid: str, + on_credential_update: CredentialUpdateCallback | None = None, +) -> _CommunityProviderRun: + from .skland import skland_sign_in + + updated_token = "" + + async def capture_credential(value: str) -> None: + nonlocal updated_token + updated_token = str(value or "").strip() + if updated_token and on_credential_update is not None: + try: + await on_credential_update("SklandToken", updated_token) + except Exception as error: + # 收尾 credential_updates 仍会重试,不能让回写故障掩盖签到结果。 + logger.warning( + f"[{account_name}] 森空岛凭据即时回写失败: {type(error).__name__}" + ) + + raw_result = await skland_sign_in( + token, + app_code="all", + proxy=getattr(Config, "proxy", None), + on_credential_update=capture_credential, + ) + updates = {"SklandToken": updated_token} if updated_token else {} + return _CommunityProviderRun( + results=build_skland_sign_results( + raw_result, + account_name=account_name, + account_uid=account_uid, + ), + platforms=("森空岛",), + credential_updates=updates, + ) + + +async def _run_miyoushe_provider( + token: str, + account_name: str, + account_uid: str, + on_credential_update: CredentialUpdateCallback | None = None, +) -> _CommunityProviderRun: + from .miyoushe import merge_miyoushe_cookie_update, miyoushe_sign_in + + updated_token = "" + + async def capture_credential(value: str) -> None: + nonlocal updated_token + candidate = str(value or "") + if candidate: + updated_token = merge_miyoushe_cookie_update(token, candidate) + if updated_token and on_credential_update is not None: + try: + await on_credential_update("MiyousheToken", updated_token) + except Exception as error: + # 收尾 credential_updates 仍会重试,不能让回写故障掩盖签到结果。 + logger.warning( + f"米游社凭据即时回写失败: {type(error).__name__}" + ) + + results = await miyoushe_sign_in( + token, + account_name=account_name, + account_uid=account_uid, + on_credential_update=capture_credential, + ) + updates = {"MiyousheToken": updated_token} if updated_token else {} + return _CommunityProviderRun( + results=results, + platforms=("米游社",), + credential_updates=updates, + ) + + +async def _run_kuro_provider( + token: str, + _account_name: str, + _account_uid: str, + _on_credential_update: CredentialUpdateCallback | None = None, +) -> _CommunityProviderRun: + from .kuro import kuro_sign_in + + return _CommunityProviderRun( + results=await kuro_sign_in(token), + platforms=("库街区",), + ) + + +async def _run_cloud_genshin_provider( + token: str, + account_name: str, + account_uid: str, + _on_credential_update: CredentialUpdateCallback | None = None, +) -> _CommunityProviderRun: + from .cloud_genshin import cloud_genshin_sign_in + + return _CommunityProviderRun( + results=await cloud_genshin_sign_in( + token, + account_name=account_name, + account_uid=account_uid, + ), + platforms=("米游社",), + ) + + +async def _run_taygedo_provider( + token: str, + _account_name: str, + _account_uid: str, + on_credential_update: CredentialUpdateCallback | None = None, +) -> _CommunityProviderRun: + from .taygedo import ( + parse_taygedo_credential, + serialize_taygedo_credential, + sign_taygedo, + validate_taygedo_credential, + ) + + validate_taygedo_credential(token) + + # 刷新结果由 provider 先聚合,再交给社区签到编排统一回写;这样既能 + # 接住上游轮换后的 refreshToken,也能让下一次调用优先复用当前 accessToken。 + updated_token = "" + + async def capture_credential(value: str) -> None: + nonlocal updated_token + updated_token = str(value or "").strip() + if updated_token and on_credential_update is not None: + # 塔吉多内部会在刷新返回后立即调用此回调;不要在这里吞掉异常, + # 让 sign_taygedo 保留收尾兜底机会。 + await on_credential_update("TaygedoToken", updated_token) + + community_results, runtime_credential = await sign_taygedo( + token, + proxy=Config.proxy, + on_credential_update=capture_credential, + ) + # 云时长在登录凭据刷新之后才返回,收尾必须保存完整的最终快照。 + final_token = serialize_taygedo_credential(runtime_credential) + if final_token != serialize_taygedo_credential(parse_taygedo_credential(token)): + updated_token = final_token + return _CommunityProviderRun( + results=community_results, + platforms=(), + credential_updates={"TaygedoToken": updated_token} if updated_token else {}, + ) + + +def get_community_sign_providers() -> tuple[_CommunitySignProvider, ...]: + """返回按通知顺序排列的签到适配器注册表。""" + + return _COMMUNITY_SIGN_PROVIDERS + + +def get_community_token_field(platform: str) -> str: + """从兼容签到注册表读取社区凭据字段。""" + + for provider in get_community_sign_providers(): + if provider.log_name == platform: + return provider.token_field + raise ValueError(f"{platform}社区凭据字段尚未登记") + + +_COMMUNITY_SIGN_PROVIDERS = ( + _CommunitySignProvider( + token_field="SklandToken", + log_name="森空岛", + runner=_run_skland_provider, + resolve_platforms=_fixed_platforms("森空岛"), + error_game=_default_error_game, + ), + _CommunitySignProvider( + token_field="MiyousheToken", + log_name="米游社", + runner=_run_miyoushe_provider, + resolve_platforms=_fixed_platforms("米游社"), + error_game=_default_error_game, + ), + _CommunitySignProvider( + token_field="CloudGenshinToken", + log_name="云原神", + runner=_run_cloud_genshin_provider, + resolve_platforms=_fixed_platforms("米游社"), + error_game=_cloud_genshin_error_game, + ), + _CommunitySignProvider( + token_field="KuroToken", + log_name="库街区", + runner=_run_kuro_provider, + resolve_platforms=_fixed_platforms("库街区"), + error_game=_default_error_game, + ), + _CommunitySignProvider( + token_field="TaygedoToken", + log_name="塔吉多", + runner=_run_taygedo_provider, + resolve_platforms=_resolve_taygedo_platforms, + error_game=_taygedo_error_game, + ), +) +COMMUNITY_TOKEN_FIELDS = tuple( + provider.token_field for provider in _COMMUNITY_SIGN_PROVIDERS +) +def read_community_token(account: object, field: str) -> str: + """读取凭据字段,兼容旧版本尚未包含新增字段的账号对象。""" + + try: + value = account.get("GameSignAccount", field) # type: ignore[attr-defined] + except (AttributeError, KeyError): + return "" + return value.strip() if isinstance(value, str) else str(value or "").strip() + + +def has_community_credentials(account: object) -> bool: + """判断账号是否至少配置一个已注册社区凭据。""" + + return any( + is_community_credential_configured( + field, + read_community_token(account, field), + ) + for field in COMMUNITY_TOKEN_FIELDS + ) + + +def _provider_error_results( + provider: _CommunitySignProvider, + *, + platforms: tuple[str, ...], + account_name: str, + account_uid: str, + reason: str, +) -> list[dict[str, object]]: + return [ + CommunitySignResult( + account=f"{account_name}/{platform}", + account_uid=account_uid, + game=provider.error_game(platform), + platform=platform, + status="失败", + reason=reason, + ).to_legacy() + for platform in platforms + ] + + +def _resolved_provider_platforms( + provider: _CommunitySignProvider, token: str +) -> tuple[str, ...]: + try: + platforms = provider.resolve_platforms(token) + except Exception as e: + logger.debug(f"{provider.log_name} 凭据解析跳过: {e}") + return () + return tuple(dict.fromkeys(platform for platform in platforms if platform)) + + +def _decorate_provider_run( + run: _CommunityProviderRun, + *, + fallback_platforms: tuple[str, ...], + account_name: str, + account_uid: str, +) -> _CommunityProviderRun: + platforms = run.platforms or fallback_platforms + normalized = [] + for raw_item in run.results: + if not isinstance(raw_item, dict): + continue + normalized.append( + CommunitySignResult.from_legacy( + raw_item, + fallback_account=account_name, + fallback_uid=account_uid, + ).to_legacy() + ) + + for platform in platforms: + if not any(item.get("platform") == platform for item in normalized): + normalized.append( + _empty_platform_result( + account_name=account_name, + account_uid=account_uid, + platform=platform, + ) + ) + + return _CommunityProviderRun( + results=normalized, + platforms=platforms, + credential_updates=dict(run.credential_updates), + ) + + +def _is_expected_provider_exception(error: Exception) -> bool: + """判断可预期的凭据、上游或网络失败。""" + + if isinstance( + error, + (ValueError, httpx.HTTPError, TimeoutError, ConnectionError), + ): + return True + if not isinstance(error, RuntimeError): + return False + + message = str(error).lower() + return any( + hint in message + for hint in ( + "token", + "cookie", + "凭据", + "登录", + "风控", + "请求", + "接口", + "网络", + "offline", + "timeout", + "timed out", + ) + ) + + +async def run_community_provider( + provider: _CommunitySignProvider, + token: str, + *, + account_name: str, + account_uid: str, + on_credential_update: CredentialUpdateCallback | None = None, +) -> _CommunityProviderRun: + fallback_platforms = _resolved_provider_platforms(provider, token) + logger.info(f"[{account_name}] 开始{provider.log_name}社区签到") + credential_status = validate_community_credential( + provider.token_field, + token, + ) + if not credential_status.locally_valid: + reason = credential_status.reason or f"{provider.log_name}凭据格式无效" + logger.warning(f"[{account_name}] {provider.log_name}凭据校验失败: {reason}") + return _CommunityProviderRun( + results=_provider_error_results( + provider, + platforms=fallback_platforms, + account_name=account_name, + account_uid=account_uid, + reason=reason, + ), + platforms=fallback_platforms, + ) + try: + if on_credential_update is None: + # 保留第三方/历史适配器的三参数 runner 兼容性;内置社区 runner + # 均支持第四个回写回调。 + run = await provider.runner(token, account_name, account_uid) # type: ignore[call-arg] + else: + run = await provider.runner( + token, + account_name, + account_uid, + on_credential_update, + ) + except Exception as e: + expected = _is_expected_provider_exception(e) + reason = format_exception_reason( + e, + stage=f"{provider.log_name}社区签到失败", + include_message=expected, + ) + if expected: + logger.warning(f"[{account_name}] {reason}") + else: + logger.exception(f"{provider.log_name}社区签到程序异常") + return _CommunityProviderRun( + results=_provider_error_results( + provider, + platforms=fallback_platforms, + account_name=account_name, + account_uid=account_uid, + reason=reason, + ), + platforms=fallback_platforms, + ) + return _decorate_provider_run( + run, + fallback_platforms=fallback_platforms, + account_name=account_name, + account_uid=account_uid, + ) + + +def merge_community_sign_results( + existing: dict[str, list[dict[str, object]]], + formatted: dict[str, list[dict[str, object]]], + replace: bool = False, +) -> dict[str, list[dict[str, object]]]: + """将新签到结果合并到已有结果中 + + Args: + existing: 已有的 _game_sign_result_data + formatted: 本次 format_community_sign_results 的新结果 + replace: 保留该参数以兼容现有调用;受影响账号均按 account_uid 替换旧数据。 + + Returns: + 合并后的 _game_sign_result_data + """ + if not existing: + return formatted + + for platform, accounts in formatted.items(): + if platform not in existing: + existing[platform] = accounts + else: + # 手动和自动签到都替换受影响账号,避免旧成功状态遮蔽新失败结果。 + new_uids = {g.get("account_uid") for g in accounts if g.get("account_uid")} + if new_uids: + existing[platform] = [ + g + for g in existing[platform] + if g.get("account_uid") not in new_uids + ] + existing[platform].extend(accounts) + + return existing + + +def _is_taygedo_community_detail_result(item: dict[str, object]) -> bool: + """判断是否为不应进入塔吉多游戏详情的社区级签到结果。""" + + return ( + str(item.get("platform") or "").strip() == "塔吉多" + and str(item.get("game") or "").strip() + in _TAYGEDO_COMMUNITY_DETAIL_GAMES + ) + + +def format_community_sign_results( + results: list[dict[str, object]], +) -> dict[str, list[dict[str, object]]]: + """将签到结果格式化为前端可展示的结构。 + + 按平台分组,平台内按账号 UID 聚合。塔吉多社区级签到仍会执行, + 但不作为游戏签到详情展示。 + + Returns: + {platform: [{account_alias, account_uid, games: [{account, game, status, reward, reason}]}]} + """ + platforms: dict[str, dict[str, dict[str, object]]] = {} + + for item in results: + if item.get("_notification_only") or _is_taygedo_community_detail_result( + item + ): + continue + platform = item.get("platform", "未知") + account = str(item.get("account", "未知")) + account_uid = str(item.get("account_uid", "")) + # 别名 = account 中 '/' 前的部分 + alias = account.split("/")[0] if "/" in account else account + group_key = account_uid or f"alias:{alias}" + + if platform not in platforms: + platforms[platform] = {} + + if group_key not in platforms[platform]: + platforms[platform][group_key] = { + "account_alias": alias, + "account_uid": account_uid, + "games": [], + } + + platforms[platform][group_key]["games"].append( + { + "account": account, + "game": item.get("game", "未知"), + "status": item.get("status", "失败"), + "reward": item.get("reward", ""), + "reason": item.get("reason", ""), + } + ) + + # 转为列表格式 + result: dict[str, list[dict[str, object]]] = {} + for platform, accounts in platforms.items(): + result[platform] = list(accounts.values()) + + return result diff --git a/app/tools/game_sign.py b/app/tools/game_sign.py index 680907fd7..e3c7f88bb 100644 --- a/app/tools/game_sign.py +++ b/app/tools/game_sign.py @@ -20,680 +20,141 @@ # Contact: DLmaster_361@163.com -import asyncio +"""旧游戏签到模块兼容层;社区运行时实现位于 community_sign_provider。""" + import time from contextlib import asynccontextmanager -from contextvars import ContextVar -from dataclasses import dataclass, field -from datetime import datetime -from typing import Awaitable, Callable - -import httpx - -from app.core import Config -from app.utils.constants import UTC8 -from app.utils.logger import get_logger -from app.utils.security import format_exception_reason -from .game_sign_result import build_skland_sign_results - -logger = get_logger("游戏社区签到") - -_game_sign_lock = asyncio.Lock() -_game_sign_flow_lock = asyncio.Lock() -_game_sign_lock_owner: ContextVar[asyncio.Task | None] = ContextVar( - "game_sign_lock_owner", default=None +from .community_contract import CommunitySignInProgressError +from .community_sign_provider import ( + COMMUNITY_TOKEN_FIELDS, + CredentialUpdateCallback, + ErrorGameResolver, + PlatformResolver, + ProviderRunner, + _COMMUNITY_SIGN_PROVIDERS, + _CommunityProviderRun, + _CommunitySignProvider, + _decorate_provider_run, + _default_error_game, + _empty_platform_result, + _fixed_platforms, + _is_expected_provider_exception, + _provider_error_results, + _resolve_taygedo_platforms, + _resolved_provider_platforms, + _run_kuro_provider, + _run_miyoushe_provider, + _run_skland_provider, + _run_taygedo_provider, + _taygedo_error_game, + check_community_system_time as _check_community_system_time_impl, + format_community_sign_results, + get_community_sign_providers, + get_community_token_field, + has_community_credentials, + merge_community_sign_results, + read_community_token, + run_community_provider, ) -_system_time_checked_at = 0.0 -_SYSTEM_TIME_CHECK_INTERVAL = 300.0 - -@dataclass -class _ProviderRun: - """单个社区适配器的结果和需要回写的凭据。""" +GameSignInProgressError = CommunitySignInProgressError - results: list[dict] - platforms: tuple[str, ...] - credential_updates: dict[str, str] = field(default_factory=dict) +# 历史名称只保留为同一对象的兼容引用,不复制社区注册表或执行逻辑。 +_ProviderRun = _CommunityProviderRun +_GameSignProvider = _CommunitySignProvider +_GAME_SIGN_PROVIDERS = _COMMUNITY_SIGN_PROVIDERS +GAME_SIGN_TOKEN_FIELDS = COMMUNITY_TOKEN_FIELDS +_read_game_sign_token = read_community_token +has_game_sign_credentials = has_community_credentials +_run_provider = run_community_provider -ProviderRunner = Callable[[str, str, str], Awaitable[_ProviderRun]] -PlatformResolver = Callable[[str], tuple[str, ...]] -ErrorGameResolver = Callable[[str], str] +async def check_community_system_time() -> None: + """兼容旧时间补丁入口,转发到社区时间校验实现。""" + await _check_community_system_time_impl(time_source=time) -@dataclass(frozen=True) -class _GameSignProvider: - """以配置字段驱动的社区签到适配器描述。""" - token_field: str - log_name: str - runner: ProviderRunner - resolve_platforms: PlatformResolver - error_game: ErrorGameResolver - - -class GameSignInProgressError(RuntimeError): - """游戏社区签到已在执行。""" +_check_system_time = check_community_system_time @asynccontextmanager async def game_sign_flow(): - """保护签到请求及结果落盘,通知由调用方在锁外发送。""" + """兼容旧调用方,转发到社区签到流程锁。""" - if _game_sign_flow_lock.locked(): - raise GameSignInProgressError("游戏社区签到正在执行,请稍后重试") + from app.core.community_sign import community_sign_flow - await _game_sign_flow_lock.acquire() - try: + async with community_sign_flow(): yield - finally: - _game_sign_flow_lock.release() async def _enter_game_sign_lock() -> bool: - """获取全局签到锁;同一任务嵌套调用时复用已持有的锁。""" - - current_task = asyncio.current_task() - if current_task is not None and _game_sign_lock_owner.get() is current_task: - return False + """兼容旧内部调用,获取社区签到执行锁。""" - if _game_sign_lock.locked(): - raise GameSignInProgressError("游戏社区签到正在执行,请稍后重试") + from app.core.community_sign import _enter_community_sign_lock - await _game_sign_lock.acquire() - _game_sign_lock_owner.set(current_task) - return True + return await _enter_community_sign_lock() def _exit_game_sign_lock(acquired: bool) -> None: - if not acquired: - return - _game_sign_lock_owner.set(None) - _game_sign_lock.release() + """兼容旧内部调用,释放社区签到执行锁。""" + + from app.core.community_sign import _exit_community_sign_lock + + _exit_community_sign_lock(acquired) def _all_enabled_platforms_signed( - results: list[dict], + results: list[dict[str, object]], *, account_uid: str, enabled_platforms: list[str], ) -> bool: - """判断账号的全部已配置平台结果是否均已完成。""" - - if not enabled_platforms: - return False - - for platform in enabled_platforms: - platform_results = [ - result - for result in results - if result.get("account_uid") == account_uid - and result.get("platform") == platform - ] - if not platform_results or any( - result.get("status") not in ("成功", "已签到") - and not result.get("_completed") - for result in platform_results - ): - return False - - return True - - -async def _check_system_time() -> None: - """检查系统时间偏差并提示用户,不阻断签到流程。 - - 时间源不可信或不可用时(服务退役、被劫持的网络等)仅记录日志; - 真正对时间敏感的只有米游社 DS 签名,其容差远大于此处阈值, - 因此偏差过大时也只告警,由具体平台的签到结果反映实际影响。 - """ - global _system_time_checked_at - now = time.monotonic() - if now - _system_time_checked_at < _SYSTEM_TIME_CHECK_INTERVAL: - return - # 无论时间服务成功与否,都缓存本次尝试,避免网络异常时每次签到重复等待。 - _system_time_checked_at = now - - try: - async with httpx.AsyncClient(proxy=Config.proxy) as client: - resp = await client.get( - "https://worldtimeapi.org/api/timezone/Asia/Shanghai", timeout=5 - ) - api_time = resp.json().get("unixtime", 0) - if not api_time: - return - local_time = time.time() - offset = abs(api_time - local_time) - if offset > 300: - logger.warning( - f"系统时间与网络时间偏差约 {offset:.0f} 秒,部分平台签到可能失败,建议校准系统时间" - ) - elif offset > 30: - logger.info(f"系统时间偏差 {offset:.0f} 秒,在可接受范围内") - except Exception as e: - logger.debug(f"时间校准跳过: {e}") - - -def _empty_platform_result( - *, account_name: str, account_uid: str, platform: str -) -> dict: - """为没有返回可签到角色的平台保留通知占位,不写入前端结果列表。""" - - return { - "account": account_name, - "account_uid": account_uid, - "game": "", - "platform": platform, - "status": "失败", - "reward": "", - "reason": "未获取到可签到角色", - "_notification_only": True, - } - - -async def run_all_sign_in(force: bool = False) -> list[dict]: - """协调执行游戏社区签到,避免重复签到和重复通知。""" - # 时间检查只提供告警,不应阻塞真实签到或占用签到锁。 - time_check_task = asyncio.create_task(_check_system_time()) - acquired = False - try: - acquired = await _enter_game_sign_lock() - return await _run_all_sign_in(force=force) - finally: - _exit_game_sign_lock(acquired) - if not time_check_task.done(): - time_check_task.cancel() - await asyncio.gather(time_check_task, return_exceptions=True) - - -def _fixed_platforms(platform: str) -> PlatformResolver: - return lambda _token: (platform,) - - -def _default_error_game(platform: str) -> str: - return platform - - -def _taygedo_error_game(platform: str) -> str: - return "幻塔社区" if platform == "塔吉多" else "云异环" - - -def _resolve_taygedo_platforms(raw_token: str) -> tuple[str, ...]: - """根据塔吉多凭据字段确定实际启用的社区。""" - - try: - from .taygedo import parse_taygedo_credential - - credential = parse_taygedo_credential(raw_token) - except Exception: - return ("塔吉多",) - - platforms = [] - if credential.get("refreshToken") or credential.get("accessToken"): - platforms.append("塔吉多") - if credential.get("cloudToken") and credential.get("cloudUserId"): - platforms.append("云异环") - return tuple(platforms) or ("塔吉多",) - - -async def _run_skland_provider( - token: str, account_name: str, account_uid: str -) -> _ProviderRun: - from .skland import skland_sign_in - - updated_token = "" - - async def capture_credential(value: str) -> None: - nonlocal updated_token - updated_token = str(value or "").strip() - - raw_result = await skland_sign_in( - token, - app_code="all", - proxy=getattr(Config, "proxy", None), - on_credential_update=capture_credential, - ) - updates = {"SklandToken": updated_token} if updated_token else {} - return _ProviderRun( - results=build_skland_sign_results( - raw_result, - account_name=account_name, - account_uid=account_uid, - ), - platforms=("森空岛",), - credential_updates=updates, - ) - - -async def _run_miyoushe_provider( - token: str, _account_name: str, _account_uid: str -) -> _ProviderRun: - from .miyoushe import miyoushe_sign_in - - updated_token = "" - - async def capture_credential(value: str) -> None: - nonlocal updated_token - updated_token = str(value or "").strip() - - results = await miyoushe_sign_in( - token, - on_credential_update=capture_credential, - ) - updates = {"MiyousheToken": updated_token} if updated_token else {} - return _ProviderRun( - results=results, - platforms=("米游社",), - credential_updates=updates, - ) - - -async def _run_kuro_provider( - token: str, _account_name: str, _account_uid: str -) -> _ProviderRun: - from .kuro import kuro_sign_in - - return _ProviderRun( - results=await kuro_sign_in(token), - platforms=("库街区",), - ) + """兼容旧内部调用,判断账号的已配置平台是否全部完成。""" + from app.core.community_sign import all_enabled_community_platforms_signed -async def _run_taygedo_provider( - token: str, _account_name: str, _account_uid: str -) -> _ProviderRun: - from .taygedo import ( - parse_taygedo_credential, - serialize_taygedo_credential, - sign_taygedo, - ) - - credential = parse_taygedo_credential(token) - has_community = bool( - credential.get("refreshToken") or credential.get("accessToken") - ) - has_cloud = bool(credential.get("cloudToken") and credential.get("cloudUserId")) - if not has_community and not has_cloud: - raise ValueError( - "塔吉多凭据缺少 refreshToken/accessToken 或 cloudToken/cloudUserId" - ) - - sign_results, refreshed_credential = await sign_taygedo( - token, - proxy=Config.proxy, - ) - refreshed_token = serialize_taygedo_credential(refreshed_credential) - updates = ( - {"TaygedoToken": refreshed_token} - if refreshed_token and refreshed_token != str(token).strip() - else {} - ) - return _ProviderRun( - results=sign_results, - platforms=(), - credential_updates=updates, + return all_enabled_community_platforms_signed( + results, + account_uid=account_uid, + enabled_platforms=enabled_platforms, ) -def _game_sign_providers() -> tuple[_GameSignProvider, ...]: - """返回按通知顺序排列的签到适配器注册表。""" - - return _GAME_SIGN_PROVIDERS - - -_GAME_SIGN_PROVIDERS = ( - _GameSignProvider( - token_field="SklandToken", - log_name="森空岛", - runner=_run_skland_provider, - resolve_platforms=_fixed_platforms("森空岛"), - error_game=_default_error_game, - ), - _GameSignProvider( - token_field="MiyousheToken", - log_name="米游社", - runner=_run_miyoushe_provider, - resolve_platforms=_fixed_platforms("米游社"), - error_game=_default_error_game, - ), - _GameSignProvider( - token_field="KuroToken", - log_name="库街区", - runner=_run_kuro_provider, - resolve_platforms=_fixed_platforms("库街区"), - error_game=_default_error_game, - ), - _GameSignProvider( - token_field="TaygedoToken", - log_name="塔吉多", - runner=_run_taygedo_provider, - resolve_platforms=_resolve_taygedo_platforms, - error_game=_taygedo_error_game, - ), -) -GAME_SIGN_TOKEN_FIELDS = tuple( - provider.token_field for provider in _GAME_SIGN_PROVIDERS -) +async def run_all_sign_in(force: bool = False) -> list[dict[str, object]]: + """兼容旧调用方,转发到社区签到核心编排。""" + from app.core.community_sign import run_community_sign_in -def _read_game_sign_token(account: object, field: str) -> str: - """读取凭据字段,兼容旧版本尚未包含新增字段的账号对象。""" + return await run_community_sign_in(force=force) - try: - value = account.get("GameSignAccount", field) # type: ignore[attr-defined] - except (AttributeError, KeyError): - return "" - return value.strip() if isinstance(value, str) else str(value or "").strip() +async def _run_all_sign_in(force: bool = False) -> list[dict[str, object]]: + """兼容旧内部调用,转发到社区签到核心编排。""" -def has_game_sign_credentials(account: object) -> bool: - """判断账号是否至少配置一个已注册社区凭据。""" + return await run_all_sign_in(force=force) - return any( - _read_game_sign_token(account, field) for field in GAME_SIGN_TOKEN_FIELDS - ) +def _game_sign_providers() -> tuple[_CommunitySignProvider, ...]: + """兼容旧内部调用,返回社区注册表的同一实例。""" -def _provider_error_results( - provider: _GameSignProvider, - *, - platforms: tuple[str, ...], - account_name: str, - account_uid: str, - reason: str, -) -> list[dict]: - return [ - { - "account": f"{account_name}/{platform}", - "account_uid": account_uid, - "game": provider.error_game(platform), - "platform": platform, - "status": "失败", - "reward": "", - "reason": reason, - } - for platform in platforms - ] - - -def _resolved_provider_platforms( - provider: _GameSignProvider, token: str -) -> tuple[str, ...]: - try: - platforms = provider.resolve_platforms(token) - except Exception as e: - logger.debug(f"{provider.log_name} 凭据解析跳过: {e}") - return () - return tuple(dict.fromkeys(platform for platform in platforms if platform)) - - -def _decorate_provider_run( - run: _ProviderRun, - *, - fallback_platforms: tuple[str, ...], - account_name: str, - account_uid: str, -) -> _ProviderRun: - platforms = run.platforms or fallback_platforms - normalized = [] - for raw_item in run.results: - if not isinstance(raw_item, dict): - continue - item = dict(raw_item) - if not item.get("account") or item.get("account") == "未知用户": - item["account"] = account_name - item["account_uid"] = account_uid - normalized.append(item) - - for platform in platforms: - if not any(item.get("platform") == platform for item in normalized): - normalized.append( - _empty_platform_result( - account_name=account_name, - account_uid=account_uid, - platform=platform, - ) - ) - - return _ProviderRun( - results=normalized, - platforms=platforms, - credential_updates=dict(run.credential_updates), - ) + return _GAME_SIGN_PROVIDERS -def _is_expected_provider_exception(error: Exception) -> bool: - """判断可预期的凭据、上游或网络失败。""" - - if isinstance( - error, - (ValueError, httpx.HTTPError, TimeoutError, ConnectionError), - ): - return True - if not isinstance(error, RuntimeError): - return False - - message = str(error).lower() - return any( - hint in message - for hint in ( - "token", - "cookie", - "凭据", - "登录", - "风控", - "请求", - "接口", - "网络", - "offline", - "timeout", - "timed out", - ) - ) +def merge_sign_results( + existing: dict[str, list[dict[str, object]]], + formatted: dict[str, list[dict[str, object]]], + replace: bool = False, +) -> dict[str, list[dict[str, object]]]: + """兼容旧调用方,合并社区签到结果。""" + return merge_community_sign_results(existing, formatted, replace=replace) -async def _run_provider( - provider: _GameSignProvider, - token: str, - *, - account_name: str, - account_uid: str, -) -> _ProviderRun: - fallback_platforms = _resolved_provider_platforms(provider, token) - logger.info(f"[{account_name}] 开始{provider.log_name}签到") - try: - run = await provider.runner(token, account_name, account_uid) - except Exception as e: - expected = _is_expected_provider_exception(e) - reason = format_exception_reason( - e, - stage=f"{provider.log_name}签到失败", - include_message=expected, - ) - if expected: - logger.warning(f"[{account_name}] {reason}") - else: - logger.exception(f"{provider.log_name}签到程序异常") - return _ProviderRun( - results=_provider_error_results( - provider, - platforms=fallback_platforms, - account_name=account_name, - account_uid=account_uid, - reason=reason, - ), - platforms=fallback_platforms, - ) - return _decorate_provider_run( - run, - fallback_platforms=fallback_platforms, - account_name=account_name, - account_uid=account_uid, - ) +def format_sign_results( + results: list[dict[str, object]], +) -> dict[str, list[dict[str, object]]]: + """兼容旧调用方,格式化社区签到结果。""" -async def _run_all_sign_in(force: bool = False) -> list[dict]: - """执行所有已配置平台的签到。 - - 平台由凭据字段注册表驱动。同一账号的独立社区并发执行,适配器内部 - 仍自行控制请求间隔和风控策略;凭据刷新结果在并发任务完成后统一落盘。 - """ - results = [] - today = datetime.now(tz=UTC8).strftime("%Y-%m-%d") - - providers = _game_sign_providers() - for uid, account in Config.ToolsConfig.GameSign_Accounts.items(): - account_name = account.get("GameSignAccount", "Name") or "默认账号" - account_enabled = account.get("GameSignAccount", "Enabled") - account_uid = str(uid) - - # 跳过已禁用的用户 - if not account_enabled: - continue - - # 非强制模式:跳过今日已签到的用户 - if not force: - user_last_sign = account.get("GameSignAccount", "LastSignDate") - if user_last_sign == today: - logger.debug(f"[{account_name}] 今日已签到,跳过") - continue - - tokens = { - provider.token_field: _read_game_sign_token(account, provider.token_field) - for provider in providers - } - configured = [ - provider for provider in providers if tokens.get(provider.token_field) - ] - if not configured: - continue - - # 不同社区互不依赖,按注册顺序并发执行,完成后仍按固定顺序合并结果。 - provider_runs = await asyncio.gather( - *( - _run_provider( - provider, - tokens[provider.token_field], - account_name=account_name, - account_uid=account_uid, - ) - for provider in configured - ) - ) - enabled_platforms = [] - for provider, run in zip(configured, provider_runs): - for platform in run.platforms: - if platform not in enabled_platforms: - enabled_platforms.append(platform) - results.extend(run.results) - for token_field, updated_token in run.credential_updates.items(): - if not updated_token or updated_token == tokens.get(token_field, ""): - continue - try: - await account.set("GameSignAccount", token_field, updated_token) - except Exception as e: - logger.warning(f"[{account_name}] 保存{token_field}失败: {e}") - - # 自动签到每天只尝试一次。失败也要记住当天的尝试,避免后续 MAS 任务反复请求; - # 手动签到使用 force=True,仍只在所有已配置平台完成后更新日期。 - all_platforms_signed = _all_enabled_platforms_signed( - results, - account_uid=account_uid, - enabled_platforms=enabled_platforms, - ) - should_mark_signed = bool(enabled_platforms) and ( - not force or all_platforms_signed - ) - if should_mark_signed: - try: - # 多账号串行签到可能跨越 0 点,写入时重新取当前日期, - # 避免把新一天的签到记成旧日期导致次日误判。 - sign_date = datetime.now(tz=UTC8).strftime("%Y-%m-%d") - await account.set("GameSignAccount", "LastSignDate", sign_date) - except Exception as e: - logger.warning(f"[{account_name}] 保存签到完成日期失败: {e}") - - if not results: - logger.info("没有配置任何签到平台") - - return results - - -def merge_sign_results(existing: dict, formatted: dict, replace: bool = False) -> dict: - """将新签到结果合并到已有结果中 - - Args: - existing: 已有的 _game_sign_result_data - formatted: 本次 format_sign_results 的新结果 - replace: 保留该参数以兼容现有调用;受影响账号均按 account_uid 替换旧数据。 - - Returns: - 合并后的 _game_sign_result_data - """ - if not existing: - return formatted - - for platform, accounts in formatted.items(): - if platform not in existing: - existing[platform] = accounts - else: - # 手动和自动签到都替换受影响账号,避免旧成功状态遮蔽新失败结果。 - new_uids = {g.get("account_uid") for g in accounts if g.get("account_uid")} - if new_uids: - existing[platform] = [ - g - for g in existing[platform] - if g.get("account_uid") not in new_uids - ] - existing[platform].extend(accounts) - - return existing - - -def format_sign_results(results: list[dict]) -> dict: - """将签到结果格式化为前端可展示的结构 - - 按平台分组,平台内按账号 UID 聚合 - - Returns: - {platform: [{account_alias, account_uid, games: [{account, game, status, reward, reason}]}]} - """ - platforms: dict[str, dict[str, dict]] = {} - - for item in results: - if item.get("_notification_only"): - continue - platform = item.get("platform", "未知") - account = str(item.get("account", "未知")) - account_uid = str(item.get("account_uid", "")) - # 别名 = account 中 '/' 前的部分 - alias = account.split("/")[0] if "/" in account else account - group_key = account_uid or f"alias:{alias}" - - if platform not in platforms: - platforms[platform] = {} - - if group_key not in platforms[platform]: - platforms[platform][group_key] = { - "account_alias": alias, - "account_uid": account_uid, - "games": [], - } - - platforms[platform][group_key]["games"].append( - { - "account": account, - "game": item.get("game", "未知"), - "status": item.get("status", "失败"), - "reward": item.get("reward", ""), - "reason": item.get("reason", ""), - } - ) - - # 转为列表格式 - result = {} - for platform, accounts in platforms.items(): - result[platform] = list(accounts.values()) - - return result + return format_community_sign_results(results) diff --git a/app/tools/game_sign_activity.py b/app/tools/game_sign_activity.py new file mode 100644 index 000000000..e194d9c6d --- /dev/null +++ b/app/tools/game_sign_activity.py @@ -0,0 +1,30 @@ +# 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 + + +"""历史游戏签到活动解析兼容入口。""" + +from .community_activity_parser import ( + ACTIVITY_GAME_DEFINITIONS, + parse_activity_snapshot, +) + +__all__ = ["ACTIVITY_GAME_DEFINITIONS", "parse_activity_snapshot"] diff --git a/app/tools/game_sign_contract.py b/app/tools/game_sign_contract.py new file mode 100644 index 000000000..2e414dc7e --- /dev/null +++ b/app/tools/game_sign_contract.py @@ -0,0 +1,41 @@ +# 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 + + +"""历史游戏签到结果契约兼容入口。""" + +from .community_contract import ( + ActivityState, + CommunityActivitySnapshot, + CommunitySignInProgressError, + CommunitySignResult, + CredentialState, + CredentialStatus, +) + +__all__ = [ + "ActivityState", + "CommunityActivitySnapshot", + "CommunitySignInProgressError", + "CommunitySignResult", + "CredentialState", + "CredentialStatus", +] diff --git a/app/tools/game_sign_credentials.py b/app/tools/game_sign_credentials.py new file mode 100644 index 000000000..5fd6deb37 --- /dev/null +++ b/app/tools/game_sign_credentials.py @@ -0,0 +1,45 @@ +# 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 + + +"""历史游戏签到凭据解析兼容入口。""" + +from .community_credentials import ( + CommunityCredentialInfo, + GameSignCredentialInfo, + is_community_credential_configured, + is_game_sign_credential_configured, + parse_community_credential, + parse_game_sign_credential, + validate_community_credential, + validate_game_sign_credential, +) + +__all__ = [ + "CommunityCredentialInfo", + "GameSignCredentialInfo", + "is_community_credential_configured", + "is_game_sign_credential_configured", + "parse_community_credential", + "parse_game_sign_credential", + "validate_community_credential", + "validate_game_sign_credential", +] diff --git a/app/tools/game_sign_notify.py b/app/tools/game_sign_notify.py index 5d0df310c..0dd167e49 100644 --- a/app/tools/game_sign_notify.py +++ b/app/tools/game_sign_notify.py @@ -10,9 +10,9 @@ # 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. +# 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 . @@ -20,182 +20,32 @@ # Contact: DLmaster_361@163.com -import dataclasses -from html import escape +"""历史游戏签到通知兼容入口。""" -from app.core import Config from app.core.notify import ( DispatchResult, - NotifyPayload, - NotifyTarget, - dispatch, - global_target, - target_channel_names, + dispatch_task_report, ) -from app.core.ws import Publisher, protocol -from app.models.schema import WSTaskNoticeData from app.utils.logger import get_logger -logger = get_logger("游戏签到通知") - -NOTIFICATION_SEND_ATTEMPTS = 2 -NOTIFICATION_RETRY_DELAY_SECONDS = 1 -_SUCCESS_STATUSES = {"成功", "已签到"} -_PLATFORM_ORDER = ("森空岛", "米游社", "库街区", "塔吉多", "云异环") - - -def _result_status_text(item: dict) -> str: - """将单条签到结果转换为模板中的短状态。""" - - status = str(item.get("status", "失败")) - if status == "已签到": - return "已签" - if status == "成功": - return "签到成功" - if status == "风控": - return "签到失败-风控" - - reason = str(item.get("reason", "") or "").strip() - if not reason or reason in {"失败", "签到失败"}: - return "签到失败" - if reason.startswith("签到失败-"): - return reason - return f"签到失败-{reason}" - - -def _result_account(item: dict) -> str: - """返回优先使用角色名/UID 的账号标识。""" - - account = str(item.get("account", "") or "").strip() - if account: - return account - account_uid = str(item.get("account_uid", "") or "").strip() - return account_uid or "未知用户" - - -def _result_identity(item: dict) -> str: - """生成通知中的用户标识,森空岛优先显示游戏名和真实昵称。""" - - account = _result_account(item) - platform = str(item.get("platform", "未知") or "未知") - game = str(item.get("game", "") or "").strip() - if platform != "森空岛" or not game: - return account - - nickname = account.split("/", 1)[0].strip() - if nickname and nickname != "未知用户": - return f"{game}({nickname})" - return game - - -def _notification_results(results: list[dict]) -> list[dict]: - """过滤没有实际签到角色的平台占位结果。""" - - return [item for item in results if not item.get("_notification_only")] - - -def _ordered_platforms(grouped: dict[str, list[dict]]) -> list[str]: - """按通知模板固定社区顺序,并保留未知平台结果。""" - - return [ - *[platform for platform in _PLATFORM_ORDER if platform in grouped], - *[platform for platform in grouped if platform not in _PLATFORM_ORDER], - ] - - -def _format_notification_item(item: dict) -> str: - """格式化通知列表中的一条签到结果。""" - - platform = str(item.get("platform", "未知") or "未知") - status = _result_status_text(item) - identity = _result_identity(item) - if platform == "森空岛": - return f"{identity}:{status}" - - game = str(item.get("game", "") or "").strip() - game_text = f" {game}" if game else "" - reward = str(item.get("reward", "") or "").strip() - reward_text = f" {reward}" if platform == "云异环" and reward else "" - return f"{identity}{game_text} {status}{reward_text}" - - -def format_game_sign_notification(results: list[dict]) -> str: - """按社区分组生成手动/启动时签到通知正文(不含通知标题)。""" - - results = _notification_results(results) - if not results: - return "" - - grouped: dict[str, list[dict]] = {} - for item in results: - platform = str(item.get("platform", "未知") or "未知") - grouped.setdefault(platform, []).append(item) - - lines: list[str] = [] - for platform in _ordered_platforms(grouped): - items = grouped[platform] - total = len(items) - success_count = sum( - 1 for item in items if item.get("status") in _SUCCESS_STATUSES - ) - marker = "✅" if total and success_count == total else "❌" - if lines: - lines.append("") - lines.extend([f"{marker}{platform}({success_count}/{total}):", ""]) - for item in items: - lines.append(f"- {_format_notification_item(item)}") - - lines.extend(["", "AUTO-MAS 敬上"]) - return "\n".join(lines) - - -def format_game_sign_task_summary(results: list[dict]) -> str: - """生成附加到 MAS 任务报告末尾的一行签到汇总。""" - - results = _notification_results(results) - if not results: - return "" - - grouped: dict[str, list[dict]] = {} - for item in results: - platform = str(item.get("platform", "未知") or "未知") - grouped.setdefault(platform, []).append(item) - - parts = [] - previous_platform = None - for platform in _ordered_platforms(grouped): - for item in grouped[platform]: - platform_prefix = f"{platform}-" if platform != previous_platform else "" - label = f"{platform_prefix}{_result_identity(item)}" - status = _result_status_text(item) - if platform != "森空岛": - game = str(item.get("game", "") or "").strip() - if game: - label = f"{label} {game}" - separator = ":" if platform == "森空岛" else " " - parts.append(f"{label}{separator}{status}") - previous_platform = platform - - return "签到情况: " + " | ".join(parts) - - -def get_task_game_sign_summary(task_info: object) -> str: - """读取尚未发送的任务签到汇总。""" - - if getattr(task_info, "game_sign_summary_consumed", False): - return "" - - results = list(getattr(task_info, "game_sign_results", []) or []) - if not results: - return "" - - return format_game_sign_task_summary(results) - +from .community_notify import ( + append_task_community_summary, + detect_community_notification_format, + format_community_notification, + format_community_task_summary, + get_task_community_summary, + mark_task_community_summary_consumed, + push_community_notification, +) -def mark_task_game_sign_summary_consumed(task_info: object) -> None: - """在任务报告发送完成后标记签到汇总已消费。""" +logger = get_logger("游戏社区通知兼容入口") - setattr(task_info, "game_sign_summary_consumed", True) +format_game_sign_notification = format_community_notification +format_game_sign_task_summary = format_community_task_summary +get_task_game_sign_summary = get_task_community_summary +mark_task_game_sign_summary_consumed = mark_task_community_summary_consumed +append_task_game_sign_summary = append_task_community_summary +push_game_sign_notification = push_community_notification def finalize_task_game_sign_notification( @@ -217,149 +67,14 @@ def finalize_task_game_sign_notification( mark_task_game_sign_summary_consumed(task_info) -def append_task_game_sign_summary(task_info: object, result: str) -> str: - """将尚未发送的签到汇总附加到任务报告。""" - - if not Config.ToolsConfig.get("GameSign", "NotifyEnabled"): - return result - - summary = get_task_game_sign_summary(task_info) - return f"{result}\n\n{summary}" if summary else result - - -async def dispatch_task_report( - payload: NotifyPayload, - targets: list[NotifyTarget], - task_info: object | None, - *, - summary_text: str = "", - attempts: int = 1, - retry_delay: float = 0, -) -> DispatchResult: - """分发带签到汇总的任务报告,并按渠道维护汇总投递状态。 - - 含汇总的完整载荷只发送给尚未送达汇总的渠道;已送达汇总的渠道只补发 - 去掉汇总的载荷,避免多脚本任务中已成功渠道收到重复摘要。渠道级状态 - 记录在 ``task_info`` 上(``game_sign_summary_delivered`` / - ``game_sign_summary_pending``),后续脚本据此只重试失败渠道。 - """ - - delivered = set(getattr(task_info, "game_sign_summary_delivered", ())) - if not summary_text: - result = await dispatch( - payload, targets, attempts=attempts, retry_delay=retry_delay - ) - await _publish_task_notification_failure(task_info, result) - return result - - channels = [ - channel for target in targets for channel in target_channel_names(target) - ] - with_summary = await dispatch( - payload, - targets, - attempts=attempts, - retry_delay=retry_delay, - skip_channels=delivered, - ) - delivered = delivered | set(with_summary.succeeded) - - if delivered: - pending = [channel for channel in channels if channel not in delivered] - clean_text = payload.text.replace(summary_text, "").rstrip() - clean_html = ( - payload.html.replace(summary_text, "").rstrip() - if payload.html is not None - else None - ) - without_summary = await dispatch( - dataclasses.replace(payload, text=clean_text, html=clean_html), - targets, - attempts=attempts, - retry_delay=retry_delay, - skip_channels=pending, - ) - else: - without_summary = DispatchResult() - - if task_info is not None: - setattr(task_info, "game_sign_summary_delivered", delivered) - setattr(task_info, "game_sign_summary_pending", with_summary.failed) - result = DispatchResult( - attempted=with_summary.attempted + without_summary.attempted, - succeeded=with_summary.succeeded + without_summary.succeeded, - failed=with_summary.failed + without_summary.failed, - ) - await _publish_task_notification_failure(task_info, result) - return result - - -async def _publish_task_notification_failure( - task_info: object | None, result: DispatchResult -) -> None: - """把任务报告的渠道失败同步提示到当前任务页面。""" - - if not result.failed or task_info is None: - return - task_id = getattr(task_info, "task_id", None) - if not task_id: - return - - failed_channels = tuple(dict.fromkeys(result.failed)) - title = "部分通知发送失败" if result.succeeded else "通知发送失败" - message = f"{title}:{'、'.join(failed_channels)}" - try: - await Publisher.send( - id=str(task_id), - type=protocol.TASK_NOTICE, - data=WSTaskNoticeData(level="warning", message=message), - ) - except Exception as exc: # noqa: BLE001 - logger.warning(f"发送通知失败提示到前端时出现异常: {exc}") - - -async def push_game_sign_notification(results: list[dict]) -> DispatchResult: - """推送手动或启动时触发的游戏签到结果通知。""" - results = _notification_results(results) - if not results: - return DispatchResult() - - title = "社区签到通知:" - plain_text = format_game_sign_notification(results) - - # 邮件按同一正文生成 HTML,角色名和原因均需要转义。 - grouped: dict[str, list[dict]] = {} - for item in results: - platform = str(item.get("platform", "未知") or "未知") - grouped.setdefault(platform, []).append(item) - - html_lines = [] - for platform in _ordered_platforms(grouped): - items = grouped[platform] - total = len(items) - success_count = sum( - 1 for item in items if item.get("status") in _SUCCESS_STATUSES - ) - marker = "✅" if total and success_count == total else "❌" - html_lines.append( - f"

{marker}{escape(platform)}({success_count}/{total}):

" - ) - html_lines.append("
    ") - for item in items: - html_lines.append(f"
  • {escape(_format_notification_item(item))}
  • ") - html_lines.append("
") - html_lines.append("

AUTO-MAS 敬上

") - html_content = "".join(html_lines) - return await dispatch( - NotifyPayload( - title=title, - text=plain_text, - html=html_content, - append_signature=False, - serverchan_text=plain_text, - koishi_text=f"{title}\n{plain_text}", - ), - [global_target(include_system=True, empty_policy="warn")], - attempts=NOTIFICATION_SEND_ATTEMPTS, - retry_delay=NOTIFICATION_RETRY_DELAY_SECONDS, - ) +__all__ = [ + "append_task_game_sign_summary", + "detect_community_notification_format", + "dispatch_task_report", + "finalize_task_game_sign_notification", + "format_game_sign_notification", + "format_game_sign_task_summary", + "get_task_game_sign_summary", + "mark_task_game_sign_summary_consumed", + "push_game_sign_notification", +] diff --git a/app/tools/game_sign_result.py b/app/tools/game_sign_result.py index 99fcd9640..7d600328f 100644 --- a/app/tools/game_sign_result.py +++ b/app/tools/game_sign_result.py @@ -1,3 +1,5 @@ +from collections.abc import Mapping + SKLAND_GAME_MAPPING = { "arknights": "明日方舟", "endfield": "终末地", @@ -9,9 +11,9 @@ def build_skland_sign_results( *, account_name: str, account_uid: str, -) -> list[dict]: +) -> list[dict[str, object]]: """将森空岛返回值归一化为游戏社区签到结果。""" - results = [] + results: list[dict[str, object]] = [] status_mapping = { "成功": "成功", "重复": "已签到", @@ -47,6 +49,9 @@ def build_skland_sign_results( } ) continue + reward_map = game_result.get("奖励") or {} + if not isinstance(reward_map, dict): + reward_map = {} failures = game_result.get("失败", []) if game_result.get("总计") == 0 and failures: reason = str(failures[0]) @@ -64,14 +69,16 @@ def build_skland_sign_results( continue for source_status, status in status_mapping.items(): for item in game_result.get(source_status, []): + account_label = item if isinstance(item, str) else str(item) + reward = str(reward_map.get(account_label) or "") results.append( { - "account": item if isinstance(item, str) else str(item), + "account": account_label, "account_uid": account_uid, "game": game_name, "platform": "森空岛", "status": status, - "reward": "", + "reward": reward, "reason": "签到失败" if status == "失败" else "", } ) @@ -90,3 +97,44 @@ def build_skland_sign_results( "reason": reason, } ] + + +def merge_community_sign_result( + game_result: Mapping[str, object], + community_result: Mapping[str, object], + *, + include_reward: bool = True, +) -> dict[str, object]: + """合并同一账号的游戏签到和社区打卡,失败与奖励均不能被覆盖。""" + result = dict(game_result) + signed_statuses = {"成功", "已签到"} + game_status = str(game_result.get("status") or "失败") + community_status = str(community_result.get("status") or "失败") + if game_status in signed_statuses and community_status in signed_statuses: + result["status"] = ( + "成功" if "成功" in (game_status, community_status) else "已签到" + ) + else: + reasons = [ + str( + game_result.get("reason") + or ("游戏签到失败" if game_status not in signed_statuses else "") + ).strip() + ] + if community_status not in signed_statuses: + reason = str(community_result.get("reason") or "签到失败") + reasons.append(f"社区打卡:{reason}") + if game_status in signed_statuses: + reasons.insert(0, "游戏签到已完成") + result["status"] = community_status + else: + reasons.append("社区打卡已完成") + result["reason"] = ";".join(reason for reason in reasons if reason) + result.pop("_completed", None) + if include_reward: + result["reward"] = "、".join( + str(reward) + for reward in (game_result.get("reward"), community_result.get("reward")) + if reward + ) + return result diff --git a/app/tools/kuro.py b/app/tools/kuro.py index 5682e8861..666f32f76 100644 --- a/app/tools/kuro.py +++ b/app/tools/kuro.py @@ -10,6 +10,12 @@ # # Kuro-API-Collection Copyright © 2024 TomyJan # https://github.com/TomyJan/Kuro-API-Collection +# +# Kuro_login Copyright (c) 2025 MX +# https://github.com/mxyooR/Kuro_login +# +# Yunzai-Kuro-Plugin Copyright © 2024 TomyJan (MPL-2.0) +# https://github.com/TomyJan/Yunzai-Kuro-Plugin # This file is part of AUTO-MAS. @@ -40,7 +46,9 @@ from app.utils.logger import get_logger from app.utils.security import format_exception_reason -logger = get_logger("库街区签到任务") +from .game_sign_result import merge_community_sign_result + +logger = get_logger("库街区社区") def _log_kuro_exception(stage: str, error: Exception) -> str: @@ -62,12 +70,23 @@ def _log_kuro_exception(stage: str, error: Exception) -> str: return reason -def _safe_json(response: httpx.Response) -> dict: +def _safe_json(response: httpx.Response) -> dict[str, object]: """解析库街区响应;非 JSON(通常是风控/维护页)时给出可读错误。""" try: data = response.json() except ValueError as exc: + try: + response_preview = response.text[:512].lower() + except Exception: + response_preview = "" + if any( + marker in response_preview + for marker in ("captcha", "geetest", "安全验证", "风控", "challenge") + ): + raise _KuroRiskError( + "库街区请求触发风控或安全验证,请稍后重试" + ) from exc raise ValueError( f"库街区返回了非 JSON 响应(HTTP {response.status_code}),疑似风控或服务维护" ) from exc @@ -76,12 +95,237 @@ def _safe_json(response: httpx.Response) -> dict: return data +def _is_kuro_code(value: object, expected: int) -> bool: + """兼容库街区接口以数字或字符串返回业务码。""" + + return str(value).strip() == str(expected) + + +class _KuroAuthError(ValueError): + """库街区凭据或用户会话已不能继续使用。""" + + +class _KuroRiskError(ValueError): + """库街区请求被风控或安全校验拦截。""" + + +_KURO_RISK_MARKERS = ( + "风控", + "安全验证", + "短信验证", + "需要验证", + "频繁", + "captcha", + "security", + "risk", +) + + +def _kuro_device_identifiers(token: str) -> tuple[str, str]: + """从同一 Token 派生稳定的设备码和请求标识,不改变配置存储格式。""" + + return ( + str(uuid.uuid3(uuid.NAMESPACE_URL, f"auto-mas:kuro:devcode:{token}")), + str(uuid.uuid3(uuid.NAMESPACE_URL, f"auto-mas:kuro:distinct:{token}")), + ) + + +def _kuro_request_headers( + template: dict[str, str], + token: str, + dev_code: str, + distinct_id: str = "", +) -> dict[str, str]: + """为一次请求注入同一组运行期认证和设备字段。""" + + headers = template.copy() + headers.update({"token": token, "devcode": dev_code}) + if distinct_id: + headers["distinct_id"] = distinct_id + return headers + + +def _raise_kuro_response_error( + response: httpx.Response, + data: dict[str, object], + stage: str, + *, + allow_already_signed: bool = False, +) -> None: + """将库街区业务码转换为可区分的领域异常。""" + + code = data.get("code") + message = _kuro_response_message(data, f"HTTP {response.status_code}") + lowered_message = message.lower() + if response.status_code in (403, 429) and ( + response.status_code == 429 + or any(marker in lowered_message for marker in _KURO_RISK_MARKERS) + ): + raise _KuroRiskError( + f"库街区请求触发频率限制或风控(HTTP {response.status_code}),请稍后重试" + ) + if response.status_code in (401, 403): + raise _KuroAuthError("库街区 Token 已失效,请重新获取 Token") + if not response.is_success: + raise ValueError( + f"{stage}失败(HTTP {response.status_code},code={code}):{message}" + ) + if _is_kuro_code(code, 200) or ( + allow_already_signed and _is_kuro_code(code, 1511) + ): + return + + if _is_kuro_code(code, 220): + if any(marker in lowered_message for marker in _KURO_RISK_MARKERS): + raise _KuroRiskError( + "库街区请求触发风控或安全验证(code=220),请稍后重试" + ) + raise _KuroAuthError( + "库街区 Token 已失效(code=220);库街区 APP 端再次登录可能使旧 Token 失效,请重新获取 Token" + ) + if _is_kuro_code(code, 1513): + raise _KuroAuthError( + "库街区用户信息异常(code=1513),请重新获取 Token" + ) + raise ValueError( + f"{stage}失败(HTTP {response.status_code},code={code}):{message}" + ) + + +def _kuro_result_status(error: Exception) -> str: + """将库街区领域异常映射为现有通知结果状态。""" + + return "风控" if isinstance(error, _KuroRiskError) else "失败" + + +def _kuro_response_message(data: dict[str, object], fallback: str) -> str: + """读取接口给出的短错误信息,不回显完整响应正文。""" + + message = data.get("msg", data.get("message", fallback)) + return str(message or "").strip() or fallback + + +def _extract_kuro_role_records(value: object) -> list[dict[str, object]]: + """兼容角色列表的数组及已确认的常见包装字段。""" + + if value is None: + return [] + if isinstance(value, list): + records: list[dict[str, object]] = [] + for item in value: + if isinstance(item, (dict, list)): + records.extend(_extract_kuro_role_records(item)) + return records + if not isinstance(value, dict): + raise ValueError("库街区角色列表响应格式无效") + + if any( + value.get(key) not in (None, "") + for key in ("roleId", "role_id", "roleID") + ): + return [value] + + for key in ("roles", "roleList", "role_list", "list", "data", "roleInfo", "role"): + if key in value: + return _extract_kuro_role_records(value[key]) + return [] + + +def _normalise_kuro_role( + raw_role: dict[str, object], + requested_game_id: str, +) -> dict[str, object] | None: + """按本次请求的游戏 ID 归一化角色,拒绝跨游戏猜测。""" + + reported_game_id = str( + raw_role.get("gameId") + or raw_role.get("game_id") + or raw_role.get("gameID") + or "" + ).strip() + if reported_game_id and reported_game_id != requested_game_id: + return None + + game_id = reported_game_id or requested_game_id + if game_id not in GAME_CONFIG: + return None + + role_id = str( + raw_role.get("roleId") + or raw_role.get("role_id") + or raw_role.get("roleID") + or "" + ).strip() + if not role_id: + return None + + role = dict(raw_role) + role["gameId"] = game_id + role["gameName"] = GAME_CONFIG[game_id]["name"] + role["roleId"] = role_id + for source, target in ( + ("server_id", "serverId"), + ("server_name", "serverName"), + ("role_name", "roleName"), + ): + if target not in role and role.get(source) not in (None, ""): + role[target] = role[source] + for key in ("serverId", "serverName", "roleName"): + if role.get(key) not in (None, ""): + role[key] = str(role[key]).strip() + return role + + +def _kuro_game_failure_results( + account: str, + reason: str, + game_ids: tuple[str, ...] | None = None, + status: str = "失败", +) -> list[dict[str, object]]: + """为有明确游戏范围的失败生成逐游戏结果。""" + + selected_game_ids = game_ids or tuple(GAME_CONFIG) + return [ + { + "account": f"{account}/{GAME_CONFIG[game_id]['name']}", + "game": GAME_CONFIG[game_id]["name"], + "platform": "库街区", + "status": status, + "reward": "", + "reason": reason, + } + for game_id in selected_game_ids + if game_id in GAME_CONFIG + ] + + +def _kuro_role_failure_result( + account: str, + game_name: str, + reason: str, + *, + status: str = "失败", +) -> dict[str, object]: + """构造单个库街区游戏的失败结果。""" + + return { + "account": account, + "game": game_name, + "platform": "库街区", + "status": status, + "reward": "", + "reason": reason, + } + + # ==================== 常量 ==================== # API 端点 USER_INFO_URL = "https://api.kurobbs.com/user/mineV2" ROLE_LIST_URL = "https://api.kurobbs.com/user/role/findRoleList" SIGN_URL = "https://api.kurobbs.com/encourage/signIn/v2" +SIGN_RECORD_URL = "https://api.kurobbs.com/encourage/signIn/queryRecordV2" +COMMUNITY_SIGN_URL = "https://api.kurobbs.com/user/signIn" # 游戏配置 GAME_CONFIG = { @@ -91,28 +335,53 @@ def _safe_json(response: httpx.Response) -> dict: # 请求头模板 BBS_HEADERS = { - "User-Agent": "Kuro/2.2.0 (Android;13) okhttp/4.11.0", + "Accept": "*/*", + "Accept-Language": "zh-CN,zh-Hans;q=0.9", + "User-Agent": "okhttp/3.11.0", "Host": "api.kurobbs.com", "Connection": "keep-alive", - "Accept-Encoding": "gzip", + "Accept-Encoding": "gzip, deflate, br", "Content-Type": "application/x-www-form-urlencoded", + "source": "android", + "osversion": "Android", + "model": "2211133C", + "version": "2.2.0", + "versioncode": "2200", + "channelid": "1", + "lang": "zh-Hans", + "countrycode": "CN", } GAME_HEADERS = { - "User-Agent": "Kuro/2.2.0 (Android;13) okhttp/4.11.0", - "Host": "api.kurobbs.com", - "Connection": "keep-alive", - "Accept-Encoding": "gzip", - "Content-Type": "application/x-www-form-urlencoded", - "devCode": "", + **BBS_HEADERS, + "Accept": "application/json, text/plain, */*", + "Origin": "https://web-static.kurobbs.com", + "X-Requested-With": "com.kurogame.kjq", + "Sec-Fetch-Site": "same-site", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Dest": "empty", + "User-Agent": ( + "Mozilla/5.0 (Linux; Android 13; 2211133C Build/TKQ1.220905.001; wv) " + "AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/114.0.5735.131 " + "Mobile Safari/537.36 Kuro/1.0.9 KuroGameBox/1.0.9" + ), } +def validate_kuro_credential(token: str) -> str: + """校验库街区 Token 的本地非空条件,不探测上游有效期。""" + + value = str(token or "").strip() + if not value: + raise ValueError("库街区 Token 为空") + return value + + # ==================== 签到主流程 ==================== -async def kuro_sign_in(token: str, proxy: str | None = None) -> list[dict]: - """库街区游戏签到 +async def kuro_sign_in(token: str, proxy: str | None = None) -> list[dict[str, object]]: + """库街区社区签到 Args: token: 库街区 JWT Token 字符串 @@ -125,20 +394,10 @@ async def kuro_sign_in(token: str, proxy: str | None = None) -> list[dict]: if not token or not token.strip(): logger.warning("库街区 Token 为空") - return [ - { - "account": "未知/库街区", - "game": "库街区", - "platform": "库街区", - "status": "失败", - "reward": "", - "reason": "Token 为空", - } - ] + return _kuro_game_failure_results("未知/库街区", "Token 为空") token = token.strip() - dev_code = str(uuid.uuid4()) - distinct_id = str(uuid.uuid4()) + dev_code, distinct_id = _kuro_device_identifiers(token) resolved_proxy = proxy if proxy is not None else Config.proxy async with httpx.AsyncClient(proxy=resolved_proxy, trust_env=False) as client: @@ -147,16 +406,11 @@ async def kuro_sign_in(token: str, proxy: str | None = None) -> list[dict]: user_info = await _get_user_info(token, dev_code, distinct_id, client) except Exception as e: reason = _log_kuro_exception("获取库街区用户信息失败", e) - return [ - { - "account": "未知/库街区", - "game": "库街区", - "platform": "库街区", - "status": "失败", - "reward": "", - "reason": reason, - } - ] + return _kuro_game_failure_results( + "未知/库街区", + reason, + status=_kuro_result_status(e), + ) user_id = user_info.get("userId", "") nick_name = user_info.get("nickName", user_id) @@ -166,25 +420,44 @@ async def kuro_sign_in(token: str, proxy: str | None = None) -> list[dict]: roles = await _get_role_list(token, dev_code, distinct_id, user_id, client) except Exception as e: reason = _log_kuro_exception("获取库街区游戏角色失败", e) - return [ + return _kuro_game_failure_results( + f"{nick_name}/库街区", + reason, + status=_kuro_result_status(e), + ) + + query_failures = [ + role + for role in roles + if role.get("_queryError") and str(role.get("gameId", "")) in GAME_CONFIG + ] + for failure in query_failures: + game_id = str(failure["gameId"]) + game_name = GAME_CONFIG[game_id]["name"] + results.append( { - "account": f"{nick_name}/库街区", - "game": "库街区", + "account": f"{nick_name}/{game_name}", + "game": game_name, "platform": "库街区", "status": "失败", "reward": "", - "reason": reason, + "reason": str(failure["_queryError"]), } - ] + ) signable_roles = [ - role for role in roles if str(role.get("gameId", "")) in GAME_CONFIG + role + for role in roles + if not role.get("_queryError") + and str(role.get("gameId", "")) in GAME_CONFIG ] if not signable_roles: logger.warning("未找到库街区绑定的游戏角色") return results - # 逐游戏签到 + # 逐游戏执行签到;账号级库洛币打卡在收尾合并,不新增重复结果。 + game_results_start = len(results) + community_result: dict[str, object] | None = None for index, role in enumerate(signable_roles): game_id = str(role.get("gameId", "")) server_id = role.get("serverId", "") @@ -211,57 +484,160 @@ async def kuro_sign_in(token: str, proxy: str | None = None) -> list[dict]: account, game_cfg, client, + distinct_id=distinct_id, ) results.append(sign_result) + except (_KuroAuthError, _KuroRiskError) as e: + reason = _log_kuro_exception( + f"{game_cfg['name']}签到失败", + e, + ) + community_result = { + "status": _kuro_result_status(e), + "reason": f"未执行,{reason}", + } + results.append( + _kuro_role_failure_result( + account, + game_cfg["name"], + reason, + status=_kuro_result_status(e), + ) + ) + # 220/1513/风控已经说明当前会话不可继续,停止撞击剩余角色。 + for remaining_role in signable_roles[index + 1 :]: + remaining_game_id = str(remaining_role.get("gameId", "")) + remaining_cfg = GAME_CONFIG.get(remaining_game_id) + if remaining_cfg is None: + continue + remaining_role_id = remaining_role.get("roleId", "") + remaining_account = ( + f"{nick_name}/{remaining_role.get('roleName', '')}" + f"({remaining_role_id})" + if remaining_role_id + else f"{nick_name}/库街区" + ) + results.append( + _kuro_role_failure_result( + remaining_account, + remaining_cfg["name"], + reason, + status=_kuro_result_status(e), + ) + ) + break except Exception as e: reason = _log_kuro_exception( f"{game_cfg['name']}签到失败", e, ) results.append( - { - "account": account, - "game": game_cfg["name"], - "platform": "库街区", - "status": "失败", - "reward": "", - "reason": reason, - } + _kuro_role_failure_result(account, game_cfg["name"], reason) ) if index < len(signable_roles) - 1: await asyncio.sleep(3) + if community_result is None: + try: + community_result = await _do_community_sign( + token=token, + dev_code=dev_code, + distinct_id=distinct_id, + game_id=str(signable_roles[0]["gameId"]), + client=client, + ) + except Exception as error: + community_result = { + "status": _kuro_result_status(error), + "reason": _log_kuro_exception("库街区社区打卡失败", error), + } + for index in range(game_results_start, len(results)): + results[index] = merge_community_sign_result( + results[index], + community_result, + include_reward=index == game_results_start, + ) + return results +async def _do_community_sign( + *, + token: str, + dev_code: str, + distinct_id: str, + game_id: str, + client: httpx.AsyncClient, +) -> dict[str, object]: + """执行账号级社区打卡,解析上游确认的库洛币奖励。""" + response = await client.post( + COMMUNITY_SIGN_URL, + headers=_kuro_request_headers(BBS_HEADERS, token, dev_code, distinct_id), + data={"gameId": game_id}, + timeout=30.0, + ) + payload = _safe_json(response) + _raise_kuro_response_error(response, payload, "社区打卡", allow_already_signed=True) + already_signed = _is_kuro_code(payload.get("code"), 1511) + if not already_signed and payload.get("success") is False: + raise ValueError("库街区社区打卡未成功") + + data = payload.get("data") + rewards = data.get("gainVoList") if isinstance(data, dict) else None + coins = 0 + if isinstance(rewards, list): + for reward in rewards: + if not isinstance(reward, dict) or not _is_kuro_code( + reward.get("gainTyp"), 2 + ): + continue + amount = str(reward.get("gainValue") or "") + if amount.isascii() and amount.isdecimal(): + coins += int(amount) + return { + "status": "已签到" if already_signed else "成功", + "reward": f"库洛币x{coins}" if coins > 0 else "", + "reason": "", + } + + async def _get_user_info( token: str, dev_code: str, distinct_id: str, client: httpx.AsyncClient, -) -> dict: +) -> dict[str, object]: """获取用户信息""" - headers = BBS_HEADERS.copy() - headers["token"] = token - headers["devcode"] = dev_code - headers["distinct_id"] = distinct_id + headers = _kuro_request_headers(BBS_HEADERS, token, dev_code, distinct_id) response = await client.post( USER_INFO_URL, headers=headers, - data="", + content="", timeout=30.0, ) rsp = _safe_json(response) - if rsp.get("code") != 200: - raise ValueError(f"获取用户信息失败: {rsp.get('msg', rsp.get('message', ''))}") + _raise_kuro_response_error(response, rsp, "获取用户信息") data = rsp.get("data", {}) if not isinstance(data, dict): raise ValueError("库街区用户信息响应格式无效") - return data + profile = data.get("mine") + if not isinstance(profile, dict): + profile = data + user_id = profile.get("userId") or data.get("userId") + if user_id in (None, ""): + raise ValueError("库街区用户信息缺少 userId") + nick_name = ( + profile.get("nickName") + or profile.get("userName") + or data.get("nickName") + or data.get("userName") + or user_id + ) + return {"userId": str(user_id).strip(), "nickName": str(nick_name).strip()} async def _get_role_list( @@ -270,38 +646,96 @@ async def _get_role_list( distinct_id: str, user_id: str, client: httpx.AsyncClient, -) -> list[dict]: +) -> list[dict[str, object]]: """获取游戏角色列表""" - headers = BBS_HEADERS.copy() - headers["token"] = token - headers["devcode"] = dev_code - headers["distinct_id"] = distinct_id + headers = _kuro_request_headers(BBS_HEADERS, token, dev_code, distinct_id) all_roles = [] for game_id in GAME_CONFIG: + game_name = GAME_CONFIG[game_id]["name"] + try: + response = await client.post( + ROLE_LIST_URL, + headers=headers, + data={"gameId": game_id, "userId": user_id}, + timeout=30.0, + ) + rsp = _safe_json(response) + + _raise_kuro_response_error(response, rsp, f"获取{game_name}角色") + + raw_roles = _extract_kuro_role_records(rsp.get("data", [])) + for raw_role in raw_roles: + role = _normalise_kuro_role(raw_role, game_id) + if role is not None: + all_roles.append(role) + except (_KuroAuthError, _KuroRiskError): + raise + except Exception as e: + reason = _log_kuro_exception(f"获取{game_name}角色失败", e) + all_roles.append({"gameId": game_id, "_queryError": reason}) + + return all_roles + + +async def _get_kuro_sign_reward( + token: str, + dev_code: str, + distinct_id: str, + game_id: str, + server_id: str, + role_id: str, + user_id: str, + client: httpx.AsyncClient, +) -> str: + """查询库街区今日签到记录,把今日物品格式化为 `名称x数量`。""" + + headers = _kuro_request_headers(GAME_HEADERS, token, dev_code, distinct_id) + body = { + "gameId": game_id, + "serverId": server_id, + "roleId": role_id, + "userId": user_id, + } + + try: response = await client.post( - ROLE_LIST_URL, + SIGN_RECORD_URL, headers=headers, - data=f"gameId={game_id}&userId={user_id}", + data=body, timeout=30.0, ) rsp = _safe_json(response) - - if rsp.get("code") != 200: - logger.warning(f"获取 gameId={game_id} 角色失败: {rsp.get('msg', '')}") + _raise_kuro_response_error(response, rsp, "获取库街区签到奖励") + except Exception as e: + logger.debug(f"获取库街区签到奖励失败: {e}") + return "" + + records = rsp.get("data") + if not isinstance(records, list): + return "" + + today = datetime.now(tz=UTC8).strftime("%Y-%m-%d") + rewards: list[str] = [] + for record in records: + if not isinstance(record, dict): + continue + sign_date = str(record.get("sigInDate") or "").strip() + if sign_date.split(" ", 1)[0] != today: + continue + name = str(record.get("goodsName") or "").strip() + if not name: continue + try: + count = int(record.get("goodsNum", 1)) + except (TypeError, ValueError): + count = 1 + reward = f"{name}x{count}" + if reward not in rewards: + rewards.append(reward) - raw_roles = rsp.get("data", []) - if not isinstance(raw_roles, list): - raise ValueError(f"gameId={game_id} 角色列表响应格式无效") - for role in raw_roles: - if not isinstance(role, dict): - continue - role["gameId"] = game_id - all_roles.append(role) - - return all_roles + return "、".join(rewards) async def _do_sign( @@ -312,20 +746,24 @@ async def _do_sign( role_id: str, user_id: str, account: str, - game_cfg: dict, + game_cfg: dict[str, object], client: httpx.AsyncClient, -) -> dict: + *, + distinct_id: str = "", +) -> dict[str, object]: """执行库街区签到""" - headers = GAME_HEADERS.copy() - headers["token"] = token - headers["devcode"] = ( - f"{dev_code}, Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) " - ) + headers = _kuro_request_headers(GAME_HEADERS, token, dev_code, distinct_id) # 库街区服务端按北京时间计月,本地时区可能不同,统一使用 UTC+8 req_month = datetime.now(tz=UTC8).strftime("%m") - body = f"gameId={game_id}&serverId={server_id}&roleId={role_id}&userId={user_id}&reqMonth={req_month}" + body = { + "gameId": game_id, + "serverId": server_id, + "roleId": role_id, + "userId": user_id, + "reqMonth": req_month, + } response = await client.post( SIGN_URL, @@ -337,15 +775,31 @@ async def _do_sign( code = rsp.get("code", -1) - if code == 200: - # 尝试获取奖励 - reward = "" - data = rsp.get("data", {}) - if isinstance(data, dict): - reward_name = data.get("rewardName", "") - reward_cnt = data.get("rewardCnt", 1) - if reward_name: - reward = f"{reward_name}x{reward_cnt}" + _raise_kuro_response_error( + response, + rsp, + f"{game_cfg['name']}签到", + allow_already_signed=True, + ) + + if _is_kuro_code(code, 200): + reward = await _get_kuro_sign_reward( + token, + dev_code, + distinct_id, + game_id, + server_id, + role_id, + user_id, + client, + ) + if not reward: + data = rsp.get("data", {}) + if isinstance(data, dict): + reward_name = data.get("rewardName", "") + reward_cnt = data.get("rewardCnt", 1) + if reward_name: + reward = f"{reward_name}x{reward_cnt}" logger.info(f"{account} {game_cfg['name']} 签到成功") return { "account": account, @@ -355,24 +809,27 @@ async def _do_sign( "reward": reward, "reason": "", } - elif code == 1511: + elif _is_kuro_code(code, 1511): + reward = await _get_kuro_sign_reward( + token, + dev_code, + distinct_id, + game_id, + server_id, + role_id, + user_id, + client, + ) # 今日已签到 return { "account": account, "game": game_cfg["name"], "platform": "库街区", "status": "已签到", - "reward": "", + "reward": reward, "reason": "", } else: - message = rsp.get("msg", rsp.get("message", f"错误码 {code}")) + message = _kuro_response_message(rsp, f"错误码 {code}") logger.warning(f"{account} {game_cfg['name']} 签到失败: {message}") - return { - "account": account, - "game": game_cfg["name"], - "platform": "库街区", - "status": "失败", - "reward": "", - "reason": message, - } + return _kuro_role_failure_result(account, game_cfg["name"], message) diff --git a/app/tools/miyoushe.py b/app/tools/miyoushe.py index e8dffd246..6c50cab6e 100644 --- a/app/tools/miyoushe.py +++ b/app/tools/miyoushe.py @@ -10,6 +10,12 @@ # # MYS_Game_Singin Copyright © 2023 GildedFlames # https://github.com/GildedFlames/MYS_Game_Singin +# +# MihoyoBBSTools Copyright (c) 2021-2022 Womsxd (MIT License) +# https://github.com/Womsxd/MihoyoBBSTools +# +# A-game_checkin Copyright (c) 2025 yoshino-xiao7 (MIT License) +# https://github.com/yoshino-xiao7/A-game_checkin # This file is part of AUTO-MAS. @@ -37,6 +43,7 @@ import time import uuid from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field from typing import Dict import httpx @@ -64,7 +71,7 @@ ) # DS 签名 Salt(对齐 MihoyoBBSTools) -SALT_WEB = "DlOUwIupfU6YespEUWDJmXtutuXV6owG" # web 端 salt(游戏签到 GET 请求) +SALT_WEB = "DlOUwIupfU6YespEUWDJmXtutuXV6owG" # web 端 salt(游戏社区 GET 请求) SALT_DATA = "t0qEgfub6cvueAPgR5m9aQWWVciEer7v" # 有 body/query 的请求(x6) # 验证码重试配置 @@ -72,6 +79,55 @@ CAPTCHA_RETRY_DELAY = (6, 15) # 秒,随机范围 +@dataclass(frozen=True) +class MiyousheSessionCapabilities: + """米游社会话的脱敏能力状态,不包含任何凭据值。""" + + has_uid: bool + has_cookie_token: bool + has_stoken: bool + has_stoken_v2: bool + has_mid: bool + + @property + def activity_missing_fields(self) -> tuple[str, ...]: + """返回实时便笺缺失的必要凭据字段名。""" + + missing = [] + if not self.has_stoken_v2: + missing.append("stoken_v2") + if not self.has_mid: + missing.append("mid") + return tuple(missing) + + @property + def activity_ready(self) -> bool: + """是否具备实时便笺所需的 v2 stoken 能力。""" + + return self.has_uid and not self.activity_missing_fields + + @property + def activity_reason(self) -> str: + """返回可展示但不含凭据值的受限原因。""" + + missing = self.activity_missing_fields + if not self.has_uid: + return "米游社凭据缺少 UID 字段" + if not missing: + return "" + return f"米游社实时便笺需要完整的 {' 和 '.join(missing)},当前凭据能力受限" + + +@dataclass(frozen=True) +class MiyousheSession: + """一次米游社运行使用的内存会话。""" + + cookies: dict[str, str] = field(repr=False) + uid: str + device_id: str = field(repr=False) + capabilities: MiyousheSessionCapabilities + + class _SignOutcome(tuple): """签到结果与刷新凭据的兼容返回值。 @@ -79,7 +135,7 @@ class _SignOutcome(tuple): ``outcome["status"]`` 时仍会得到签到结果字段。 """ - def __new__(cls, result: dict, cookie: str): + def __new__(cls, result: dict[str, object], cookie: str): return super().__new__(cls, (result, cookie)) def __getitem__(self, key): @@ -159,7 +215,7 @@ def get(self, key, default=None): }, } -# 通用请求头(对齐 MihoyoBBSTools 游戏签到 headers) +# 通用请求头(对齐 MihoyoBBSTools 游戏社区 headers) BASE_HEADERS = { "Accept": "application/json, text/plain, */*", "Origin": "https://webstatic.mihoyo.com", @@ -213,7 +269,7 @@ def _log_miyoushe_exception(stage: str, error: Exception) -> str: return reason -def _safe_json_parse(response: httpx.Response) -> dict: +def _safe_json_parse(response: httpx.Response) -> dict[str, object]: """安全解析 API 响应 JSON 当响应为空或非 JSON 时(通常是风控拦截),抛出 _RiskControlError。 @@ -257,10 +313,26 @@ def _build_cookie_str(cookies: Dict[str, str]) -> str: return "; ".join(f"{k}={v}" for k, v in cookies.items()) +def merge_miyoushe_cookie_update(cookie: str, updated_cookie: str) -> str: + """合并刷新结果,避免新增 Cookie 字段时丢失原始认证字段。""" + current = _parse_cookie(cookie) + refreshed = _parse_cookie(updated_cookie) + if not refreshed: + return cookie + if not current: + return updated_cookie + + merged = current.copy() + for key, value in refreshed.items(): + if value: + merged[key] = value + return _build_cookie_str(merged) + + def _generate_ds(body: str = "", query: str = "") -> str: """生成 DS (Dynamic Secret) 签名 - 对齐 MihoyoBBSTools:游戏签到 GET 请求使用 SALT_WEB, + 对齐 MihoyoBBSTools:游戏社区 GET 请求使用 SALT_WEB, POST 请求(有 body)使用 SALT_DATA。 Args: @@ -350,18 +422,40 @@ def _ensure_auth_aliases(cookies: Dict[str, str]) -> None: cookies[target] = cookies[source] -def validate_miyoushe_cookie(cookie: str) -> None: - """校验米游社凭据包含签到所需的 UID 和认证字段。""" +def prepare_miyoushe_session(cookie: str) -> MiyousheSession: + """统一解析米游社 Cookie、认证别名、设备 ID 和脱敏能力。""" cookies = _parse_cookie(cookie) _ensure_auth_aliases(cookies) - if not _get_stuid(cookies): + uid = _get_stuid(cookies) + stoken = str(cookies.get("stoken") or "").strip() + stoken_v2 = str(cookies.get("stoken_v2") or "").strip() + capabilities = MiyousheSessionCapabilities( + has_uid=bool(uid), + has_cookie_token=bool(cookies.get("cookie_token")), + has_stoken=bool(stoken), + has_stoken_v2=bool(stoken_v2 or stoken.startswith("v2_")), + has_mid=bool(cookies.get("mid")), + ) + return MiyousheSession( + cookies=cookies, + uid=uid, + device_id=_generate_device_id(cookie), + capabilities=capabilities, + ) + + +def validate_miyoushe_cookie(cookie: str) -> None: + """校验米游社凭据包含签到所需的 UID 和认证字段。""" + + session = prepare_miyoushe_session(cookie) + if not session.capabilities.has_uid: raise ValueError("Cookie 缺少 UID 字段") - if cookies.get("cookie_token"): + if session.capabilities.has_cookie_token: return - if cookies.get("stoken") and cookies.get("mid"): + if session.capabilities.has_stoken and session.capabilities.has_mid: return - if cookies.get("stoken"): + if session.capabilities.has_stoken: raise ValueError("stoken Cookie 缺少 mid 字段") raise ValueError("Cookie 缺少认证字段 (cookie_token 或 stoken)") @@ -369,7 +463,7 @@ def validate_miyoushe_cookie(cookie: str) -> None: # ==================== Token 派生 ==================== -async def _derive_cookie_token( +async def derive_miyoushe_cookie_token( stoken: str, mid: str, stuid: str, @@ -431,9 +525,11 @@ async def miyoushe_sign_in( cookie: str, proxy: str | None = None, *, + account_name: str = "", + account_uid: str = "", on_credential_update: Callable[[str], Awaitable[None]] | None = None, -) -> list[dict]: - """米游社游戏签到 +) -> list[dict[str, object]]: + """米游社社区签到 支持多种 cookie 认证策略: 1. cookie_token + UID → 直接使用 @@ -444,6 +540,8 @@ async def miyoushe_sign_in( Args: cookie: cookie 字符串,至少包含 UID 字段 + (cookie_token 或 stoken) proxy: 代理地址 + account_name: AUTO-MAS 账号组别名 + account_uid: AUTO-MAS 账号组 UUID on_credential_update: 凭据被替换后的可选回写回调 Returns: @@ -460,9 +558,10 @@ async def report_credential_update() -> None: except Exception as e: _log_miyoushe_exception("米游社凭据回写失败", e) - cookies = _parse_cookie(cookie) - _ensure_auth_aliases(cookies) - stuid = _get_stuid(cookies) + session = prepare_miyoushe_session(cookie) + cookies = dict(session.cookies) + stuid = session.uid + device_id = session.device_id if not stuid: logger.warning("Cookie 缺少 UID 字段 (stuid/ltuid/account_id)") @@ -490,7 +589,7 @@ async def report_credential_update() -> None: # 策略 3: stoken_v2 + mid,派生 cookie_token logger.info("缺少 cookie_token,尝试从 stoken 派生") try: - derived_token, derived_uid = await _derive_cookie_token( + derived_token, derived_uid = await derive_miyoushe_cookie_token( cookies["stoken"], cookies["mid"], stuid, @@ -547,13 +646,31 @@ async def report_credential_update() -> None: _ensure_uid_aliases(effective_cookies, stuid) effective_cookie = _build_cookie_str(effective_cookies) + # 米游币是账号级任务,不依赖任何游戏角色;复用本轮已经补齐的 Cookie + # 和稳定设备 ID,并把结果并入同一社区结果链。 + from .miyoushe_coin import run_miyoushe_coin_tasks + + results.append( + await run_miyoushe_coin_tasks( + effective_cookies, + account_name=account_name or stuid, + account_uid=account_uid, + device_id=device_id, + proxy=proxy, + ) + ) + # 获取游戏角色列表 try: - roles = await _get_game_roles(effective_cookie, proxy=proxy) + roles = await _get_game_roles( + effective_cookie, + proxy=proxy, + device_id=device_id, + ) except _RiskControlError: logger.warning("获取米游社游戏角色被风控") await report_credential_update() - return [ + results.append( { "account": f"{stuid}/米游社", "game": "米游社", @@ -562,11 +679,12 @@ async def report_credential_update() -> None: "reward": "", "reason": "账号被风控,接口返回异常", } - ] + ) + return results except Exception as e: reason = _log_miyoushe_exception("获取米游社游戏角色失败", e) await report_credential_update() - return [ + results.append( { "account": f"{stuid}/米游社", "game": "米游社", @@ -575,14 +693,15 @@ async def report_credential_update() -> None: "reward": "", "reason": reason, } - ] + ) + return results if not roles: logger.warning("未找到米游社绑定的游戏角色") await report_credential_update() return results - # 逐游戏签到 + # 逐游戏执行社区签到 for index, role in enumerate(roles): game_biz = role.get("game_biz", "") region = role.get("region", "") @@ -598,23 +717,39 @@ async def report_credential_update() -> None: f"{nickname}/{nickname}({game_uid})" if game_uid else f"{nickname}/米游社" ) - # 检查今日是否已签到 + # 检查今日是否已签到,并解析已签到日的奖励 try: - is_signed = await _check_sign_info( + sign_info = await _fetch_miyoushe_sign_info( effective_cookie, game_cfg, region, game_uid, proxy=proxy, + device_id=device_id, ) + is_signed = bool(sign_info and sign_info.get("is_sign")) if is_signed: + sign_day = _parse_miyoushe_sign_day( + sign_info.get("total_sign_day") if sign_info else None + ) + reward = ( + await _fetch_miyoushe_sign_reward( + effective_cookie, + game_cfg, + sign_day, + proxy=proxy, + device_id=device_id, + ) + if sign_day + else "" + ) results.append( { "account": account, "game": game_cfg["name"], "platform": "米游社", "status": "已签到", - "reward": "", + "reward": reward, "reason": "", } ) @@ -645,7 +780,33 @@ async def report_credential_update() -> None: game_uid, account, proxy=proxy, + device_id=device_id, ) + if sign_result.get("status") in ("成功", "已签到"): + try: + sign_info = await _fetch_miyoushe_sign_info( + effective_cookie, + game_cfg, + region, + game_uid, + proxy=proxy, + device_id=device_id, + ) + sign_day = _parse_miyoushe_sign_day( + sign_info.get("total_sign_day") if sign_info else None + ) + if sign_day: + reward = await _fetch_miyoushe_sign_reward( + effective_cookie, + game_cfg, + sign_day, + proxy=proxy, + device_id=device_id, + ) + if reward: + sign_result["reward"] = reward + except Exception as e: + logger.debug(f"解析{game_cfg['name']}签到奖励失败: {e}") results.append(sign_result) # cookie_token 过期被刷新后,同一轮后续游戏复用新 cookie, # 避免每个游戏重复走派生接口增加风控概率。 @@ -693,7 +854,8 @@ async def _get_game_roles( cookie: str, *, proxy: str | None = None, -) -> list[dict]: + device_id: str = "", +) -> list[dict[str, object]]: """获取游戏角色列表 兼容两种 API 返回结构: @@ -702,7 +864,7 @@ async def _get_game_roles( """ headers = BASE_HEADERS.copy() headers["DS"] = _generate_ds() - headers["x-rpc-device_id"] = _generate_device_id(cookie) + headers["x-rpc-device_id"] = device_id or _generate_device_id(cookie) resolved_proxy = proxy if proxy is not None else Config.proxy async with httpx.AsyncClient(proxy=resolved_proxy, trust_env=False) as client: @@ -739,19 +901,31 @@ async def _get_game_roles( return roles -async def _check_sign_info( +def _parse_miyoushe_sign_day(value: object) -> int: + """把签到信息中的连续签到天数转成非负整数,无法解析时返回 0。""" + + try: + parsed = int(value or 0) + except (TypeError, ValueError): + return 0 + return parsed if parsed > 0 else 0 + + +async def _fetch_miyoushe_sign_info( cookie: str, - game_cfg: dict, + game_cfg: dict[str, object], region: str, uid: str, *, proxy: str | None = None, -) -> bool: - """检查今日是否已签到""" + device_id: str = "", +) -> dict[str, object] | None: + """获取米游社签到信息;业务失败返回 None,网络异常仍上抛。""" + headers = BASE_HEADERS.copy() query = f"lang=zh-cn&act_id={game_cfg['act_id']}®ion={region}&uid={uid}" headers["DS"] = _generate_ds(query=query) - headers["x-rpc-device_id"] = _generate_device_id(cookie) + headers["x-rpc-device_id"] = device_id or _generate_device_id(cookie) headers.update(game_cfg.get("extra_headers", {})) url = f"{game_cfg.get('info_url', INFO_URL)}?{query}" @@ -767,22 +941,105 @@ async def _check_sign_info( rsp = _safe_json_parse(response) if rsp.get("retcode") != 0: - return False + return None + + sign_info = rsp.get("data") + if not isinstance(sign_info, dict): + return None + return sign_info + + +async def _check_sign_info( + cookie: str, + game_cfg: dict[str, object], + region: str, + uid: str, + *, + proxy: str | None = None, + device_id: str = "", +) -> bool: + """检查今日是否已签到(保留给旧调用方使用)。""" + + sign_info = await _fetch_miyoushe_sign_info( + cookie, + game_cfg, + region, + uid, + proxy=proxy, + device_id=device_id, + ) + return bool(sign_info and sign_info.get("is_sign")) + + +async def _fetch_miyoushe_sign_reward( + cookie: str, + game_cfg: dict[str, object], + sign_day: int, + *, + proxy: str | None = None, + device_id: str = "", +) -> str: + """按连续签到天数从 home 奖励列表解析今日奖励。""" + + if sign_day < 1: + return "" + + query = f"lang=zh-cn&act_id={game_cfg['act_id']}" + headers = BASE_HEADERS.copy() + headers["DS"] = _generate_ds(query=query) + headers["x-rpc-device_id"] = device_id or _generate_device_id(cookie) + headers.update(game_cfg.get("extra_headers", {})) + + info_url = str(game_cfg.get("info_url", INFO_URL)) + base_url = info_url.rsplit("/", 1)[0] if info_url.endswith("/info") else None + url = f"{base_url or HOME_URL}/home?{query}" + + try: + resolved_proxy = proxy if proxy is not None else Config.proxy + async with httpx.AsyncClient(proxy=resolved_proxy, trust_env=False) as client: + response = await client.get( + url, + headers=headers, + cookies=_parse_cookie(cookie), + timeout=30.0, + ) + rsp = _safe_json_parse(response) + except Exception as e: + logger.debug(f"获取{game_cfg['name']}签到奖励失败: {e}") + return "" + + if rsp.get("retcode") != 0: + return "" + + data = rsp.get("data") + if not isinstance(data, dict): + return "" + + awards = data.get("awards") or [] + if not isinstance(awards, list) or sign_day > len(awards): + return "" + + award = awards[sign_day - 1] + if not isinstance(award, dict): + return "" - sign_info = rsp.get("data", {}) - is_sign = sign_info.get("is_sign", False) - return is_sign + name = award.get("name") or "" + count = award.get("cnt", 1) + if not name: + return "" + return f"{name}x{count}" async def _do_sign( cookie: str, - game_cfg: dict, + game_cfg: dict[str, object], region: str, uid: str, account: str = "", *, proxy: str | None = None, -) -> tuple[dict, str]: + device_id: str = "", +) -> tuple[dict[str, object], str]: """执行签到(含验证码重试) 当签到返回极验(Geetest)验证码时,自动重试最多 CAPTCHA_MAX_RETRIES 次。 @@ -813,7 +1070,7 @@ async def _do_sign( for attempt in range(CAPTCHA_MAX_RETRIES + 1): headers = BASE_HEADERS.copy() headers["DS"] = _generate_ds(body=body) - headers["x-rpc-device_id"] = _generate_device_id(cookie) + headers["x-rpc-device_id"] = device_id or _generate_device_id(cookie) headers.update(game_cfg.get("extra_headers", {})) resolved_proxy = proxy if proxy is not None else Config.proxy @@ -879,7 +1136,7 @@ async def _do_sign( f"尝试用 stoken 刷新" ) try: - new_token, new_uid = await _derive_cookie_token( + new_token, new_uid = await derive_miyoushe_cookie_token( parsed["stoken"], parsed["mid"], stuid, diff --git a/app/tools/miyoushe_coin.py b/app/tools/miyoushe_coin.py new file mode 100644 index 000000000..9f55474a6 --- /dev/null +++ b/app/tools/miyoushe_coin.py @@ -0,0 +1,550 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2024-2025 DLmaster361 +# Copyright © 2025-2026 AUTO-MAS Team + +# This file incorporates protocol references covered by the following +# copyright and permission notice: +# +# nonebot-plugin-mystool Copyright © 2023-2025 Ljzd-PRO +# https://github.com/Ljzd-PRO/nonebot-plugin-mystool + +# 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 + + +"""米游社米游币每日任务,复用平台层准备好的 Cookie 和设备身份。""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import random +import string +import time +from collections.abc import Mapping +from dataclasses import dataclass + +import httpx + +from app.core import Config +from app.utils.logger import get_logger +from app.utils.security import format_exception_reason + +from .community_contract import CommunitySignResult + +logger = get_logger("米游币每日任务") + +_SIGN_URL = "https://bbs-api.mihoyo.com/apihub/app/api/signIn" +_POSTS_URL = ( + "https://bbs-api.miyoushe.com/post/api/feeds/posts" + "?fresh_action=1&gids=5&is_first_initialize=false&last_id=" +) +_READ_URL = "https://bbs-api.miyoushe.com/post/api/getPostFull?post_id={}" +_LIKE_URL = "https://bbs-api.miyoushe.com/post/api/post/upvote" +_SHARE_URL = ( + "https://bbs-api.miyoushe.com/apihub/api/getShareConf" + "?entity_id={}&entity_type=1" +) +_MISSIONS_URL = ( + "https://api-takumi.mihoyo.com/apihub/wapi/getMissions?point_sn=myb" +) +_MISSION_STATE_URL = ( + "https://api-takumi.mihoyo.com/apihub/wapi/getUserMissionsState?point_sn=myb" +) + +_SALT_ANDROID = "BIPaooxbWZW02fGHZL1If26mYCljPgst" +_SALT_DATA = "t0qEgfub6cvueAPgR5m9aQWWVciEer7v" +_TASK_ORDER = ( + "continuous_sign", + "view_post_0", + "post_up_0", + "share_post_0", +) +_TASK_LABELS = { + "continuous_sign": "讨论区签到", + "view_post_0": "阅读帖子", + "post_up_0": "点赞帖子", + "share_post_0": "分享帖子", +} + + +@dataclass(frozen=True) +class MiyousheCoinMission: + """服务端已确认的单项米游币任务进度。""" + + key: str + name: str + points: int + threshold: int + happened_times: int + + @property + def remaining(self) -> int: + """返回本轮仍需完成的次数。""" + + return max(0, self.threshold - self.happened_times) + + +@dataclass(frozen=True) +class _MiyoushePost: + post_id: str + liked: bool + + +def _nonnegative_int(value: object, field: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ValueError(f"米游币任务字段 {field} 格式无效") + return value + + +def build_miyoushe_coin_task_plan( + missions: object, + states: object, +) -> tuple[MiyousheCoinMission, ...]: + """按白名单合并任务定义与账号进度,未知任务不会被执行。""" + + if not isinstance(missions, list) or not isinstance(states, list): + raise ValueError("米游币任务列表格式无效") + + progress: dict[str, int] = {} + for raw_state in states: + if not isinstance(raw_state, Mapping): + continue + key = raw_state.get("mission_key") + if key not in _TASK_ORDER: + continue + progress[str(key)] = _nonnegative_int( + raw_state.get("happened_times"), + f"{key}.happened_times", + ) + + definitions: dict[str, MiyousheCoinMission] = {} + for raw_mission in missions: + if not isinstance(raw_mission, Mapping): + continue + key = raw_mission.get("mission_key") + if key not in _TASK_ORDER or key in definitions: + continue + key_text = str(key) + threshold = _nonnegative_int( + raw_mission.get("threshold"), + f"{key_text}.threshold", + ) + if threshold == 0: + raise ValueError(f"米游币任务 {key_text} 的目标次数无效") + name = raw_mission.get("name") + definitions[key_text] = MiyousheCoinMission( + key=key_text, + name=str(name).strip() if isinstance(name, str) else _TASK_LABELS[key_text], + points=_nonnegative_int( + raw_mission.get("points"), + f"{key_text}.points", + ), + threshold=threshold, + happened_times=progress.get(key_text, 0), + ) + + plan = tuple(definitions[key] for key in _TASK_ORDER if key in definitions) + if not plan: + raise ValueError("未发现已确认的米游币每日任务") + return plan + + +def _android_ds() -> str: + timestamp = str(int(time.time())) + nonce = "".join(random.sample(string.ascii_lowercase + string.digits, 6)) + digest = hashlib.md5( + f"salt={_SALT_ANDROID}&t={timestamp}&r={nonce}".encode() + ).hexdigest() + return f"{timestamp},{nonce},{digest}" + + +def _data_ds(body: str) -> str: + timestamp = str(int(time.time())) + nonce = str(random.randint(100000, 200000)) + digest = hashlib.md5( + f"salt={_SALT_DATA}&t={timestamp}&r={nonce}&b={body}&q=".encode() + ).hexdigest() + return f"{timestamp},{nonce},{digest}" + + +def _android_headers(device_id: str, *, ds: str = "") -> dict[str, str]: + headers = { + "Accept": "application/json, text/plain, */*", + "Accept-Encoding": "gzip", + "Connection": "Keep-Alive", + "Content-Type": "application/json;charset=utf-8", + "Referer": "https://app.mihoyo.com", + "User-Agent": "okhttp/4.9.3", + "x-rpc-app_version": "2.63.1", + "x-rpc-channel": "miyousheluodi", + "x-rpc-client_type": "2", + "x-rpc-device_id": device_id, + "x-rpc-device_model": "MI 8 SE", + "x-rpc-device_name": "Xiaomi MI 8 SE", + "x-rpc-sys_version": "11", + } + if ds: + headers["DS"] = ds + return headers + + +def _mission_headers() -> dict[str, str]: + return { + "Accept": "application/json, text/plain, */*", + "Accept-Encoding": "gzip, deflate, br", + "Accept-Language": "zh-CN,zh-Hans;q=0.9", + "Connection": "keep-alive", + "Origin": "https://webstatic.mihoyo.com", + "Referer": "https://webstatic.mihoyo.com/", + "User-Agent": ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_4 like Mac OS X) " + "AppleWebKit/605.1.15 (KHTML, like Gecko) miHoYoBBS/2.55.1" + ), + } + + +def _post_headers(device_id: str) -> dict[str, str]: + return { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Accept-Language": "zh-CN,zh-Hans;q=0.9", + "Connection": "keep-alive", + "Referer": "https://app.mihoyo.com", + "User-Agent": "Hyperion/275 CFNetwork/1402.0.8 Darwin/22.2.0", + "x-rpc-app_version": "2.63.1", + "x-rpc-channel": "appstore", + "x-rpc-client_type": "1", + "x-rpc-device_id": device_id, + "x-rpc-device_name": "iPhone", + "x-rpc-sys_version": "16.2", + } + + +def _response_data(payload: Mapping[str, object], stage: str) -> Mapping[str, object]: + retcode = payload.get("retcode", 0) + if retcode in (-100, 10001): + raise ValueError( + f"{stage}认证未通过(错误码 {retcode})," + "当前凭据可能已失效或不支持社区任务" + ) + if retcode == 1034: + raise ValueError("米游币任务触发人机验证") + if retcode not in (0, None): + code = retcode if isinstance(retcode, (int, str)) else "未知" + raise ValueError(f"{stage}失败(错误码 {code})") + data = payload.get("data") + if not isinstance(data, Mapping): + raise ValueError(f"{stage}返回数据格式无效") + return data + + +async def _request_json( + client: httpx.AsyncClient, + method: str, + url: str, + *, + headers: Mapping[str, str], + cookies: Mapping[str, str], + content: str | None = None, + json_body: Mapping[str, object] | None = None, +) -> Mapping[str, object]: + response = await client.request( + method, + url, + headers=dict(headers), + cookies=dict(cookies), + content=content, + json=dict(json_body) if json_body is not None else None, + timeout=30.0, + ) + text = response.text.strip() + if not text: + raise ValueError("米游币接口返回空响应,疑似被风控") + try: + payload = response.json() + except ValueError as exc: + raise ValueError("米游币接口返回非 JSON 内容,疑似被风控") from exc + if not isinstance(payload, Mapping): + raise ValueError("米游币接口返回数据格式无效") + return payload + + +async def _load_task_state( + client: httpx.AsyncClient, + cookies: Mapping[str, str], +) -> tuple[tuple[MiyousheCoinMission, ...], int]: + missions_payload = await _request_json( + client, + "GET", + _MISSIONS_URL, + headers=_mission_headers(), + cookies=cookies, + ) + missions_data = _response_data(missions_payload, "获取米游币任务定义") + state_payload = await _request_json( + client, + "GET", + _MISSION_STATE_URL, + headers=_mission_headers(), + cookies=cookies, + ) + state_data = _response_data(state_payload, "获取米游币任务状态") + plan = build_miyoushe_coin_task_plan( + missions_data.get("missions"), + state_data.get("states"), + ) + total_points = _nonnegative_int(state_data.get("total_points"), "total_points") + return plan, total_points + + +async def _load_posts( + client: httpx.AsyncClient, + cookies: Mapping[str, str], + device_id: str, +) -> tuple[_MiyoushePost, ...]: + payload = await _request_json( + client, + "GET", + _POSTS_URL, + headers=_post_headers(device_id), + cookies=cookies, + ) + data = _response_data(payload, "获取米游社帖子") + raw_posts = data.get("list") + if not isinstance(raw_posts, list): + raise ValueError("米游社帖子列表格式无效") + + posts = [] + for raw_item in raw_posts: + if not isinstance(raw_item, Mapping): + continue + post = raw_item.get("post") + if not isinstance(post, Mapping): + continue + post_id = post.get("post_id") + if not isinstance(post_id, (str, int)) or not str(post_id).strip(): + continue + operation = raw_item.get("self_operation") + attitude = operation.get("attitude") if isinstance(operation, Mapping) else 0 + posts.append(_MiyoushePost(str(post_id), attitude not in (0, None, False))) + if not posts: + raise ValueError("米游社综合分区没有可用帖子") + return tuple(posts) + + +async def _wait_task_interval() -> None: + await asyncio.sleep(random.uniform(1.0, 2.0)) + + +async def _run_sign( + client: httpx.AsyncClient, + cookies: Mapping[str, str], + device_id: str, +) -> None: + body = json.dumps({"gids": "5"}, separators=(",", ":")) + payload = await _request_json( + client, + "POST", + _SIGN_URL, + headers=_android_headers(device_id, ds=_data_ds(body)), + cookies=cookies, + content=body, + ) + if payload.get("retcode") == 1008: + return + _response_data(payload, "米游社讨论区签到") + + +async def _run_reads( + client: httpx.AsyncClient, + cookies: Mapping[str, str], + device_id: str, + posts: tuple[_MiyoushePost, ...], + count: int, +) -> None: + if len(posts) < count: + raise ValueError("米游社综合分区可阅读帖子数量不足") + for index, post in enumerate(posts[:count]): + payload = await _request_json( + client, + "GET", + _READ_URL.format(post.post_id), + headers=_android_headers(device_id, ds=_android_ds()), + cookies=cookies, + ) + _response_data(payload, "阅读米游社帖子") + if index + 1 < count: + await _wait_task_interval() + + +async def _run_likes( + client: httpx.AsyncClient, + cookies: Mapping[str, str], + device_id: str, + posts: tuple[_MiyoushePost, ...], + count: int, +) -> None: + candidates = tuple(post for post in posts if not post.liked) + if len(candidates) < count: + raise ValueError("米游社综合分区可点赞帖子数量不足") + for index, post in enumerate(candidates[:count]): + payload = await _request_json( + client, + "POST", + _LIKE_URL, + headers=_android_headers(device_id, ds=_android_ds()), + cookies=cookies, + json_body={"is_cancel": False, "post_id": post.post_id}, + ) + _response_data(payload, "点赞米游社帖子") + if index + 1 < count: + await _wait_task_interval() + + +async def _run_share( + client: httpx.AsyncClient, + cookies: Mapping[str, str], + device_id: str, + post: _MiyoushePost, +) -> None: + payload = await _request_json( + client, + "GET", + _SHARE_URL.format(post.post_id), + headers=_android_headers(device_id, ds=_android_ds()), + cookies=cookies, + ) + _response_data(payload, "分享米游社帖子") + + +def _result( + account_name: str, + account_uid: str, + *, + status: str, + reward: str = "", + reason: str = "", +) -> dict[str, object]: + return CommunitySignResult( + account=f"{account_name or account_uid}/米游社", + account_uid=account_uid, + game="米游币任务", + platform="米游社", + status=status, + reward=reward, + reason=reason, + ).to_legacy() + + +async def run_miyoushe_coin_tasks( + cookies: Mapping[str, str], + *, + account_name: str = "", + account_uid: str, + device_id: str, + proxy: str | None = None, +) -> dict[str, object]: + """按账号级服务端进度完成四类已确认米游币任务。""" + + cookies = dict(cookies) + # 与参考 BBSCookies 的社区请求一致:优先使用 v2,内部别名不发给上游。 + if cookies.get("stoken_v2"): + cookies["stoken"] = cookies["stoken_v2"] + cookies.pop("stoken_v1", None) + cookies.pop("stoken_v2", None) + resolved_proxy = proxy if proxy is not None else Config.proxy + try: + async with httpx.AsyncClient( + proxy=resolved_proxy, + trust_env=False, + ) as client: + plan, points_before = await _load_task_state(client, cookies) + pending = tuple(task for task in plan if task.remaining) + if not pending: + return _result( + account_name, + account_uid, + status="已签到", + reward="新增 0 米游币", + ) + + if not cookies.get("stoken"): + raise ValueError("当前凭据缺少社区任务所需的 stoken") + if cookies["stoken"].startswith("v2_") and not cookies.get("mid"): + raise ValueError("社区任务的 v2 stoken 缺少配套 mid") + + posts: tuple[_MiyoushePost, ...] | None = None + for task in pending: + if task.key == "continuous_sign": + await _run_sign(client, cookies, device_id) + else: + if posts is None: + posts = await _load_posts(client, cookies, device_id) + if task.key == "view_post_0": + await _run_reads( + client, + cookies, + device_id, + posts, + task.remaining, + ) + elif task.key == "post_up_0": + await _run_likes( + client, + cookies, + device_id, + posts, + task.remaining, + ) + elif task.key == "share_post_0": + await _run_share(client, cookies, device_id, posts[0]) + await _wait_task_interval() + + final_plan, points_after = await _load_task_state(client, cookies) + incomplete = tuple(task.name for task in final_plan if task.remaining) + gained = max(0, points_after - points_before) + if incomplete: + return _result( + account_name, + account_uid, + status="失败", + reward=f"新增 {gained} 米游币" if gained else "", + reason=f"米游币任务未全部完成:{'、'.join(incomplete)}", + ) + return _result( + account_name, + account_uid, + status="成功", + reward=f"新增 {gained} 米游币", + ) + except Exception as error: + expected = isinstance( + error, + (ValueError, httpx.HTTPError, TimeoutError, ConnectionError), + ) + reason = format_exception_reason( + error, + stage="米游币任务失败", + include_message=expected, + ) + if expected: + logger.warning(reason) + else: + logger.exception("米游币任务程序异常") + return _result(account_name, account_uid, status="失败", reason=reason) diff --git a/app/tools/miyoushe_qr.py b/app/tools/miyoushe_qr.py index 3b2ab0da4..69c068cbb 100644 --- a/app/tools/miyoushe_qr.py +++ b/app/tools/miyoushe_qr.py @@ -1,6 +1,13 @@ # AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software # Copyright © 2024-2025 DLmaster361 # Copyright © 2025-2026 AUTO-MAS Team +# +# GameToken API compatibility knowledge: +# nonebot-plugin-mystool Copyright (c) 2022 Ljzd-PRO (MIT License) +# https://github.com/Ljzd-PRO/nonebot-plugin-mystool +# Passport App QR compatibility knowledge: +# PizzaHelperUnited Copyright (c) 2024 and onwards Pizza Studio +# https://github.com/pizza-studio/PizzaHelperUnited (AGPL-3.0-or-later) # This file is part of AUTO-MAS. @@ -26,17 +33,24 @@ 可安全删除本文件及 app/api/qr_login.py、前端扫码按钮, 不会影响任何已有功能。 -扫码流程(Passport 模式,参考 thesadru/genshin.py): - 1. createQRLogin → POST 获取二维码 URL + ticket - 2. queryQRLoginStatus → POST 轮询状态,确认后从响应头或响应体获取 cookies - 3. cookies 中通常包含 cookie_token_v2、ltuid_v2 等 Passport 字段; - 保存时同时补充签到模块兼容的 cookie_token、stuid 等别名 +扫码流程(Passport App 优先,Passport Web 兼容回退): + 1. Passport 创建二维码,返回二维码 URL + ticket + 2. 轮询状态,App 接口确认后直接取得 stoken,Web 接口从响应头或响应体获取 cookies + 3. 使用 stoken 补取 LToken、CookieToken,保存兼容认证与 UID 别名 + +旧 GameToken ticket 仅保留轮询兼容,不再用于新建二维码。 参考项目: - https://github.com/thesadru/genshin.py (2026-06 最新) + - https://github.com/Marchen-orz/MiyoQian (Passport App QR 与 Token 派生) """ +import hashlib +import json +import random +import time from http.cookies import CookieError, SimpleCookie +from urllib.parse import parse_qs, urlparse import httpx @@ -54,6 +68,37 @@ CHECK_QRCODE_URL = ( "https://passport-api.miyoushe.com/account/ma-cn-passport/web/queryQRLoginStatus" ) +# ---- Passport App QR 登录 API(替代已废弃的 Panda GameToken QR) ---- +PASSPORT_APP_CREATE_URL = ( + "https://passport-api.mihoyo.com/account/ma-cn-passport/app/createQRLogin" +) +PASSPORT_APP_CHECK_URL = ( + "https://passport-api.mihoyo.com/account/ma-cn-passport/app/queryQRLoginStatus" +) +PASSPORT_APP_ID = "bll8iq97cem8" +PASSPORT_APP_CLIENT_TYPE = "3" +PASSPORT_APP_VERSION = "2.90.1" +PASSPORT_APP_USER_AGENT = "Mozilla/5.0 miHoYoBBS/2.90.1 Capture/2.2.0" +_PASSPORT_APP_TICKET_PREFIX = "passport-bbs:" +_LEGACY_PASSPORT_APP_TICKET_PREFIX = "passport-app:" +_LEGACY_PASSPORT_APP_ID = "ddxf5dufpuyo" +PASSPORT_LTOKEN_URL = ( + "https://passport-api.mihoyo.com/account/auth/api/getLTokenBySToken" +) +# ---- GameToken QR 登录 API(参考项目已确认的请求/响应形状) ---- +GAME_TOKEN_CREATE_URL = "https://hk4e-sdk.mihoyo.com/hk4e_cn/combo/panda/qrcode/fetch" +GAME_TOKEN_CHECK_URL = "https://hk4e-sdk.mihoyo.com/hk4e_cn/combo/panda/qrcode/query" +GAME_TOKEN_STOKEN_URL = ( + "https://api-takumi.mihoyo.com/account/ma-cn-session/app/getTokenByGameToken" +) +GAME_TOKEN_COOKIE_URL = ( + "https://api-takumi.mihoyo.com/auth/api/getCookieAccountInfoByGameToken" +) +GAME_TOKEN_APP_ID = "2" +_GAME_TOKEN_TICKET_PREFIX = "game-token:" +# Passport 扫码不保证下发 stoken;缺少时用 login_ticket 换取多类型 Token 补全, +# stoken_v2 + mid 是部分小组件接口的必要凭据,可用性仍以上游实际响应为准。 +MULTI_TOKEN_URL = "https://api-takumi.mihoyo.com/auth/api/getMultiTokenByLoginTicket" # ---- 请求头(对齐 genshin.py QRCODE_HEADERS) ---- @@ -81,6 +126,7 @@ } _QR_COOKIE_FIELDS = ( + "uni_web_token", "cookie_token", "cookie_token_v2", "stoken", @@ -98,6 +144,14 @@ "account_id", "account_id_v2", "login_uid", + # login_ticket 仅用于缺少 stoken 时换取 stoken,不参与签到请求。 + "login_ticket", +) + +_QR_V2_COOKIE_PAIRS = ( + ("cookie_token", "cookie_token_v2"), + ("stoken", "stoken_v2"), + ("ltoken", "ltoken_v2"), ) @@ -121,11 +175,107 @@ def _is_expired_message(message: object) -> bool: def _add_qr_cookie_aliases(cookie_parts: dict[str, str]) -> None: """补全签到模块使用的旧 Cookie 字段名,不覆盖服务端原值。""" + # 部分扫码响应只使用旧字段名,但值已经是 v2 格式;补出 v2 字段, + # 使后续便笺查询可以识别完整认证形态。 + for source, target in _QR_V2_COOKIE_PAIRS: + value = cookie_parts.get(source) + if not cookie_parts.get(target) and value and value.startswith("v2_"): + cookie_parts[target] = value for source, target in _QR_COOKIE_ALIASES.items(): if not cookie_parts.get(target) and cookie_parts.get(source): cookie_parts[target] = cookie_parts[source] +async def _supplement_stoken( + cookie_parts: dict[str, str], proxy: str | None = None +) -> None: + """用 login_ticket 补全缺失的 stoken,失败时保持原有字段不变。 + + Passport 扫码返回的 Cookie 字段并不稳定:同一条链路有时只下发 v1 + stoken,有时只给 cookie_token/ltoken。缺少 stoken_v2 时活跃度查询只能 + 退回实时便笺并触发验证码风控,因此在这里做一次补全。 + + Args: + cookie_parts: 已从扫码响应解析出的 Cookie 字段,就地补全。 + proxy: 代理地址,与扫码请求保持一致。 + """ + login_ticket = cookie_parts.get("login_ticket") + uid = next( + ( + cookie_parts[key] + for key in ( + "stuid", + "stuid_v2", + "ltuid", + "ltuid_v2", + "account_id", + "account_id_v2", + "login_uid", + ) + if cookie_parts.get(key) + ), + "", + ) + if not login_ticket or not uid: + return + + try: + async with httpx.AsyncClient( + proxy=proxy or Config.proxy, + trust_env=False, + ) as client: + resp = await client.get( + MULTI_TOKEN_URL, + params={ + "login_ticket": login_ticket, + "token_types": "3", + "uid": uid, + }, + timeout=10.0, + ) + payload = resp.json() + except (httpx.HTTPError, OSError, ValueError) as e: + logger.warning( + format_exception_reason(e, stage="补全 stoken 请求失败") + ) + return + + if not isinstance(payload, dict) or payload.get("retcode") != 0: + logger.warning("补全 stoken 失败:login_ticket 可能已过期") + return + + data = payload.get("data") + token_list = data.get("list") if isinstance(data, dict) else None + if not isinstance(token_list, list): + return + for token_info in token_list: + if not isinstance(token_info, dict): + continue + name = token_info.get("name") + token = token_info.get("token") + if not isinstance(token, str) or not token: + continue + if name in ("stoken", "stoken_v2"): + cookie_parts.setdefault("stoken", token) + if name == "stoken_v2" or token.startswith("v2_"): + # v2 stoken 必须配套 mid,否则保存校验会直接拒绝。 + cookie_parts.setdefault("stoken_v2", token) + mid = token_info.get("mid") + if isinstance(mid, str) and mid: + cookie_parts.setdefault("mid", mid) + elif name in ("ltoken", "ltoken_v2"): + cookie_parts.setdefault("ltoken", token) + if name == "ltoken_v2": + cookie_parts.setdefault("ltoken_v2", token) + # 上游可能返回空列表,只有确实补到 stoken 才记成功,避免误导排查。 + if cookie_parts.get("stoken_v2"): + logger.info("已通过 login_ticket 补全 stoken_v2") + elif cookie_parts.get("stoken"): + logger.warning("login_ticket 仅返回 stoken_v1,未补全 stoken_v2") + else: + logger.warning("补全 stoken 未获得可用 Token") + + def _has_qr_auth_cookie(cookie_parts: dict[str, str]) -> bool: return any( cookie_parts.get(key) @@ -148,6 +298,26 @@ def _has_qr_uid_cookie(cookie_parts: dict[str, str]) -> bool: ) +def _has_complete_qr_credential(cookie_parts: dict[str, str]) -> bool: + """确认便笺所需的 v2 会话 Token 与配套 mid 均已取得。""" + + return bool( + cookie_parts.get("stoken_v2") + and any( + cookie_parts.get(key) + for key in ("mid", "mid_v2", "ltmid_v2", "account_mid_v2") + ) + ) + + +def _serialize_cookie_parts(cookie_parts: dict[str, str]) -> str: + """完整序列化 Cookie 字段,不裁剪字段和值。""" + + return "; ".join( + f"{key}={value}" for key, value in cookie_parts.items() if value + ) + + def _qr_headers(device: str) -> dict: """构建带 device_id 的请求头""" headers = QR_HEADERS.copy() @@ -155,17 +325,288 @@ def _qr_headers(device: str) -> dict: return headers +def _passport_app_qr_headers( + device: str, + *, + body: str = "{}", + app_id: str = PASSPORT_APP_ID, +) -> dict[str, str]: + """构建与扫码所属应用一致的 Passport 请求头。""" + + headers = { + "x-rpc-app_id": app_id, + "x-rpc-client_type": PASSPORT_APP_CLIENT_TYPE, + "x-rpc-device_id": device, + "User-Agent": PASSPORT_APP_USER_AGENT, + "Content-Type": "application/json", + } + if app_id == _LEGACY_PASSPORT_APP_ID: + headers["User-Agent"] = "HYPContainer/1.3.3.182" + return headers + + # MiyoQian 的米游社 App QR 使用 4X 签名;签名正文与实际发送字节一致。 + timestamp = str(int(time.time())) + nonce = str(random.randint(100000, 200000)) + digest = hashlib.md5( + ( + f"salt=xV8v4Qu54lUKrEYFZkJhB8cuOh9Asafs&t={timestamp}&r={nonce}&b={body}&q=" + ).encode() + ).hexdigest() + headers.update( + { + "x-rpc-app_version": PASSPORT_APP_VERSION, + "x-rpc-sdk_version": PASSPORT_APP_VERSION, + "x-rpc-account_version": PASSPORT_APP_VERSION, + "x-rpc-game_biz": "bbs_cn", + "DS": f"{timestamp},{nonce},{digest}", + } + ) + return headers + + +async def _supplement_qr_tokens( + cookie_parts: dict[str, str], device: str, proxy: str | None = None +) -> None: + """按 Passport 协议补取缺少的 Token,派生失败不丢弃已取得的凭据。""" + + from .miyoushe import PASSPORT_COOKIE_URL + + _add_qr_cookie_aliases(cookie_parts) + stoken = cookie_parts.get("stoken_v2") or cookie_parts.get("stoken") + mid = cookie_parts.get("mid") + uid = next( + ( + cookie_parts[key] + for key in ("stuid", "ltuid", "account_id", "login_uid") + if cookie_parts.get(key) + ), + "", + ) + if not stoken or not mid or not uid: + return + pending = tuple( + (field, url) + for field, url in ( + ("ltoken", PASSPORT_LTOKEN_URL), + ("cookie_token", PASSPORT_COOKIE_URL), + ) + if not cookie_parts.get(field) + ) + if not pending: + return + + headers = { + "x-rpc-client_type": "1", + "x-rpc-device_id": device, + "x-rpc-game_biz": "bbs_cn", + "x-rpc-app_version": "2.63.1", + "User-Agent": "Hyperion/275 CFNetwork/1402.0.8 Darwin/22.2.0", + } + async with httpx.AsyncClient( + proxy=proxy or Config.proxy, + trust_env=False, + cookies={"stoken": stoken, "mid": mid, "stuid": uid}, + ) as client: + for field, url in pending: + try: + response = await client.get(url, headers=headers, timeout=10.0) + response.raise_for_status() + payload = response.json() + data = payload.get("data") if isinstance(payload, dict) else None + if ( + not isinstance(payload, dict) + or payload.get("retcode") not in (0, "0") + or not isinstance(data, dict) + ): + raise ValueError("Token 派生响应无效") + token = data.get(field) + if not isinstance(token, str) or not token.strip(): + raise ValueError("Token 派生响应缺少认证字段") + if field == "cookie_token" and str(data.get("uid") or "") != uid: + raise ValueError("Token 派生账号不一致") + except (httpx.HTTPError, OSError, ValueError) as error: + logger.warning( + format_exception_reason( + error, stage=f"扫码补全 {field} 失败", include_message=False + ) + ) + continue + cookie_parts[field] = token.strip() + _add_qr_cookie_aliases(cookie_parts) + + +def _passport_app_qr_data(payload: object) -> tuple[str, str] | None: + """从 Passport App 创建响应中提取可信二维码 URL 和 ticket。""" + + if not isinstance(payload, dict): + return None + qr_url = str(payload.get("url") or "").strip() + parsed = urlparse(qr_url) + if ( + parsed.scheme.lower() != "https" + or parsed.netloc.lower() != "user.mihoyo.com" + or parsed.path.lower() != "/login-platform/mobile.html" + ): + return None + ticket = str(payload.get("ticket") or "").strip() + if not ticket: + ticket_values = parse_qs(parsed.query).get("tk", []) + ticket = str(ticket_values[0] if ticket_values else "").strip() + if not ticket: + return None + return qr_url, ticket + + +def _passport_app_cookie_parts(payload: object) -> dict[str, str]: + """将 Passport App 确认响应转换为完整 stoken_v2 凭据。""" + + if not isinstance(payload, dict): + return {} + user_info = payload.get("user_info") + tokens = payload.get("tokens") + if not isinstance(user_info, dict) or not isinstance(tokens, list): + return {} + + uid = str( + user_info.get("aid") + or user_info.get("uid") + or user_info.get("account_id") + or "" + ).strip() + mid = str(user_info.get("mid") or "").strip() + stoken_v2 = "" + for token_info in tokens: + if not isinstance(token_info, dict): + continue + if str(token_info.get("token_type")) != "1": + continue + stoken_v2 = str(token_info.get("token") or "").strip() + if stoken_v2: + break + if not uid or not mid or not stoken_v2: + return {} + + return { + "stoken_v2": stoken_v2, + "stoken": stoken_v2, + "mid": mid, + "mid_v2": mid, + "ltmid_v2": mid, + "account_mid_v2": mid, + "stuid": uid, + "stuid_v2": uid, + "ltuid": uid, + "ltuid_v2": uid, + "account_id": uid, + "account_id_v2": uid, + "login_uid": uid, + } + + +def _game_token_qr_data(payload: object) -> tuple[str, str] | None: + """从 GameToken 二维码响应中提取二维码 URL 和 ticket。""" + + if not isinstance(payload, dict): + return None + qr_url = str(payload.get("url") or "").strip() + if not qr_url: + return None + parsed = urlparse(qr_url) + path = parsed.path.lower() + host = parsed.netloc.lower() + known_sdk_qr = host == "hk4e-sdk.mihoyo.com" and ( + "/qrcode/" in path or path.endswith("/qrcode.html") + ) + known_account_qr = host == "user.mihoyo.com" and path == "/qr_code_in_game.html" + if parsed.scheme.lower() != "https" or not (known_sdk_qr or known_account_qr): + return None + + ticket = str(payload.get("ticket") or "").strip() + if not ticket: + ticket_values = parse_qs(parsed.query).get("ticket", []) + ticket = str(ticket_values[0] if ticket_values else "").strip() + if not ticket: + return None + return qr_url, ticket + + +async def _create_game_token_qr( + device: str, proxy: str | None = None +) -> tuple[str, str] | None: + """尝试创建参考项目使用的 GameToken 二维码。""" + + async with httpx.AsyncClient( + proxy=proxy or Config.proxy, + trust_env=False, + ) as client: + response = await client.post( + GAME_TOKEN_CREATE_URL, + json={"app_id": GAME_TOKEN_APP_ID, "device": device}, + timeout=15.0, + ) + payload = response.json() + if not isinstance(payload, dict) or payload.get("retcode") != 0: + return None + return _game_token_qr_data(payload.get("data")) + + +async def _create_passport_app_qr( + device: str, proxy: str | None = None +) -> tuple[str, str] | None: + """创建新版 Passport App 扫码二维码。""" + + async with httpx.AsyncClient( + proxy=proxy or Config.proxy, + trust_env=False, + ) as client: + response = await client.post( + PASSPORT_APP_CREATE_URL, + headers=_passport_app_qr_headers(device), + content="{}", + timeout=15.0, + ) + payload = response.json() + if not isinstance(payload, dict) or payload.get("retcode") not in (0, "0"): + return None + return _passport_app_qr_data(payload.get("data")) + + async def create_qr_login(proxy: str | None = None) -> dict: - """创建米游社扫码登录二维码(Passport 模式) + """创建米游社扫码登录二维码(Passport App 优先) - POST /createQRLogin(无需 body) + 优先使用可直接返回 stoken 的 Passport App 链路;其不可用时继续使用 + 原 Passport Web 兼容链路。 Returns: {ticket, qr_url, device} 或 {error} """ from uuid import uuid4 - device = str(uuid4()) + device = str(uuid4()).upper() + + try: + passport_app_qr = await _create_passport_app_qr(device, proxy) + except (httpx.HTTPError, OSError, ValueError) as error: + logger.debug( + f"Passport App QR 不可用,回退 Web: {type(error).__name__}" + ) + passport_app_qr = None + except Exception as error: + logger.debug( + f"Passport App QR 回退 Web: {type(error).__name__}" + ) + passport_app_qr = None + + if passport_app_qr is not None: + qr_url, passport_app_ticket = passport_app_qr + logger.info("Passport App QR 创建成功") + return { + "ticket": ( + f"{_PASSPORT_APP_TICKET_PREFIX}{passport_app_ticket}" + ), + "qr_url": qr_url, + "device": device, + } try: headers = _qr_headers(device) @@ -217,6 +658,234 @@ async def create_qr_login(proxy: str | None = None) -> dict: return {"error": "创建二维码失败,请稍后重试"} +async def _check_passport_app_qr_status( + ticket: str, + device: str, + proxy: str | None = None, + *, + app_id: str = PASSPORT_APP_ID, +) -> dict: + """查询 Passport App 二维码并返回可保存的完整 stoken 凭据。""" + + body = json.dumps({"ticket": ticket}, separators=(",", ":")) + try: + async with httpx.AsyncClient( + proxy=proxy or Config.proxy, + trust_env=False, + ) as client: + response = await client.post( + PASSPORT_APP_CHECK_URL, + headers=_passport_app_qr_headers(device, body=body, app_id=app_id), + content=body, + timeout=30.0, + ) + data = response.json() + except (httpx.HTTPError, OSError) as error: + logger.warning( + format_exception_reason( + error, + stage="查询 Passport App 扫码状态网络请求失败", + ) + ) + return { + "status": "Error", + "error": "查询二维码状态网络请求失败,请检查网络或代理设置", + } + except ValueError: + logger.warning("解析 Passport App 扫码状态响应失败") + return {"status": "Error", "error": "二维码状态响应解析失败"} + except Exception: + logger.exception("查询 Passport App 扫码状态程序异常") + return {"status": "Error", "error": "查询二维码状态失败,请稍后重试"} + + if not isinstance(data, dict): + return {"status": "Error", "error": "二维码状态响应格式无效"} + retcode = data.get("retcode", 0) + response_message = data.get("message") + if not isinstance(response_message, str): + response_message = "" + if retcode not in (0, "0"): + if str(retcode) == "-106" or _is_expired_message(response_message): + return { + "status": "Expired", + "message": response_message or QR_EXPIRED_MESSAGE, + } + return {"status": "Error", "error": response_message or "查询失败"} + + qr_data = data.get("data") + if not isinstance(qr_data, dict): + return {"status": "Error", "error": "二维码状态响应格式无效"} + status = qr_data.get("status") + if status in ("Created", "Init"): + return {"status": "Init"} + if status == "Scanned": + return {"status": "Scanned"} + if status in ("Expired", "Canceled"): + return { + "status": status, + "message": ( + QR_EXPIRED_MESSAGE if status == "Expired" else "登录已取消" + ), + } + if status != "Confirmed": + return {"status": "Error", "error": "收到未知扫码状态"} + + cookie_parts = _passport_app_cookie_parts(qr_data) + if not _has_complete_qr_credential(cookie_parts): + return { + "status": "Error", + "error": "扫码确认成功但响应缺少 stoken_v2 或配套 mid", + } + if not _has_qr_uid_cookie(cookie_parts): + return { + "status": "Error", + "error": "扫码确认成功但响应未包含用户 UID", + } + await _supplement_qr_tokens(cookie_parts, device, proxy) + cookies_str = _serialize_cookie_parts(cookie_parts) + cookie_parts.clear() + logger.info("Passport App QR 确认成功,已取得扫码凭据") + return {"status": "Confirmed", "cookies_str": cookies_str} + + +async def _check_game_token_qr_status( + ticket: str, + device: str, + proxy: str | None = None, +) -> dict: + """查询 GameToken 二维码并兑换为可保存的完整 Cookie。""" + + try: + async with httpx.AsyncClient( + proxy=proxy or Config.proxy, + trust_env=False, + ) as client: + response = await client.post( + GAME_TOKEN_CHECK_URL, + json={ + "app_id": GAME_TOKEN_APP_ID, + "device": device, + "ticket": ticket, + }, + timeout=30.0, + ) + data = response.json() + except (httpx.HTTPError, OSError) as error: + logger.warning( + format_exception_reason( + error, + stage="查询 GameToken 扫码状态网络请求失败", + ) + ) + return { + "status": "Error", + "error": "查询二维码状态网络请求失败,请检查网络或代理设置", + } + except ValueError: + logger.warning("解析 GameToken 扫码状态响应失败") + return {"status": "Error", "error": "二维码状态响应解析失败"} + except Exception: + logger.exception("查询 GameToken 扫码状态程序异常") + return {"status": "Error", "error": "查询二维码状态失败,请稍后重试"} + + if not isinstance(data, dict): + return {"status": "Error", "error": "二维码状态响应格式无效"} + + retcode = data.get("retcode", 0) + response_message = data.get("message") + if not isinstance(response_message, str): + response_message = "" + if retcode not in (0, "0"): + if str(retcode) == "-106" or _is_expired_message(response_message): + return { + "status": "Expired", + "message": response_message or QR_EXPIRED_MESSAGE, + } + return {"status": "Error", "error": response_message or "查询失败"} + + qr_data = data.get("data") + if not isinstance(qr_data, dict): + return {"status": "Error", "error": "二维码状态响应格式无效"} + + status = qr_data.get("stat") + if status in ("Init", "Created"): + return {"status": "Init"} + if status == "Scanned": + return {"status": "Scanned"} + if status in ("Expired", "Canceled"): + return { + "status": status, + "message": ( + QR_EXPIRED_MESSAGE + if status == "Expired" + else "登录已取消" + ), + } + + payload = qr_data.get("payload") + raw_payload = payload.get("raw") if isinstance(payload, dict) else None + if not isinstance(raw_payload, str) or not raw_payload.strip(): + return { + "status": "Error", + "error": "二维码确认响应缺少登录信息", + } + try: + login_payload = json.loads(raw_payload) + except (TypeError, ValueError): + return {"status": "Error", "error": "二维码登录信息格式无效"} + if not isinstance(login_payload, dict): + return {"status": "Error", "error": "二维码登录信息格式无效"} + + uid = str(login_payload.get("uid") or "").strip() + game_token = str(login_payload.get("token") or "").strip() + if not uid or not game_token: + return {"status": "Error", "error": "二维码确认响应缺少登录信息"} + + try: + exchanged = await exchange_stoken(game_token, uid, proxy) + except (httpx.HTTPError, OSError, ValueError) as error: + logger.warning( + format_exception_reason( + error, + stage="GameToken 换取完整凭据失败", + ) + ) + return { + "status": "Error", + "error": "GameToken 换取完整凭据失败,请检查登录状态或网络", + } + except Exception: + logger.exception("GameToken 换取完整凭据程序异常") + return { + "status": "Error", + "error": "GameToken 换取完整凭据失败,请稍后重试", + } + finally: + # GameToken 只在本次兑换中使用,不返回、不落盘。 + game_token = "" + raw_payload = "" + login_payload.clear() + + cookies_str = exchanged.get("cookies_str") if isinstance(exchanged, dict) else "" + if not isinstance(cookies_str, str) or not cookies_str: + return {"status": "Error", "error": "未获取到完整登录凭据"} + cookie_parts = _parse_cookie_string(cookies_str) + _add_qr_cookie_aliases(cookie_parts) + if not _has_qr_auth_cookie(cookie_parts): + return {"status": "Error", "error": "完整登录凭据缺少认证字段"} + if not _has_qr_uid_cookie(cookie_parts): + return {"status": "Error", "error": "完整登录凭据缺少用户 UID"} + if not _has_complete_qr_credential(cookie_parts): + return { + "status": "Error", + "error": "完整登录凭据缺少 stoken_v2 或配套 mid", + } + await _supplement_qr_tokens(cookie_parts, device, proxy) + cookies_str = _serialize_cookie_parts(cookie_parts) + logger.info("GameToken QR 确认成功,已换取扫码凭据") + return {"status": "Confirmed", "cookies_str": cookies_str} + + async def check_qr_status( ticket: str, device: str, @@ -232,6 +901,28 @@ async def check_qr_status( {status: "Init"|"Scanned"|"Confirmed"|"Expired"|"Error", cookies_str?, error?} """ + if isinstance(ticket, str) and ticket.startswith(_PASSPORT_APP_TICKET_PREFIX): + return await _check_passport_app_qr_status( + ticket[len(_PASSPORT_APP_TICKET_PREFIX) :], + device, + proxy, + ) + if isinstance(ticket, str) and ticket.startswith(_GAME_TOKEN_TICKET_PREFIX): + return await _check_game_token_qr_status( + ticket[len(_GAME_TOKEN_TICKET_PREFIX) :], + device, + proxy, + ) + if isinstance(ticket, str) and ticket.startswith( + _LEGACY_PASSPORT_APP_TICKET_PREFIX + ): + return await _check_passport_app_qr_status( + ticket[len(_LEGACY_PASSPORT_APP_TICKET_PREFIX) :], + device, + proxy, + app_id=_LEGACY_PASSPORT_APP_ID, + ) + try: headers = _qr_headers(device) async with httpx.AsyncClient(proxy=proxy or Config.proxy) as client: @@ -281,6 +972,17 @@ async def check_qr_status( # Confirmed — 从响应头或确认响应体提取 cookies cookies_str = _extract_cookies_from_headers(resp, qr_data) cookie_parts = _parse_cookie_string(cookies_str) + # 缺少 stoken_v2 时先补全;已有 v1 stoken 也不能满足便笺认证。 + if not cookie_parts.get("stoken_v2"): + await _supplement_stoken(cookie_parts, proxy) + _add_qr_cookie_aliases(cookie_parts) + # login_ticket 只用于换取 stoken,不参与签到请求,保存前移除。 + cookie_parts.pop("login_ticket", None) + if not _has_complete_qr_credential(cookie_parts): + return { + "status": "Error", + "error": "扫码凭据缺少 stoken_v2 或配套 mid,登录链路未完成", + } if not _has_qr_auth_cookie(cookie_parts): return { "status": "Error", @@ -288,6 +990,8 @@ async def check_qr_status( } if not _has_qr_uid_cookie(cookie_parts): return {"status": "Error", "error": "扫码确认成功但响应未包含用户 UID"} + await _supplement_qr_tokens(cookie_parts, device, proxy) + cookies_str = _serialize_cookie_parts(cookie_parts) logger.info( f"QR 确认成功, 获取到 cookies: {bool(cookies_str)}, " f"fields={sorted(cookie_parts)}" @@ -319,7 +1023,9 @@ async def check_qr_status( return {"status": "Error", "error": "查询二维码状态失败,请稍后重试"} -def _extract_cookie_payload(payload: object) -> dict[str, str]: +def _extract_cookie_payload( + payload: object, *, include_nested: bool = True, _depth: int = 0 +) -> dict[str, str]: """从确认响应体提取已知 Cookie 字段,不记录或信任其它业务字段。""" if not isinstance(payload, dict): return {} @@ -330,15 +1036,57 @@ def _extract_cookie_payload(payload: object) -> dict[str, str]: if isinstance(value, str) and value: cookie_parts[key] = value + user_info = payload.get("user_info") + if isinstance(user_info, dict): + aid = user_info.get("aid") or user_info.get("account_id") + mid = user_info.get("mid") + if isinstance(aid, str) and aid: + cookie_parts.setdefault("account_id_v2", aid) + cookie_parts.setdefault("ltuid_v2", aid) + if isinstance(mid, str) and mid: + cookie_parts.setdefault("mid", mid) + + tokens = payload.get("tokens") + if isinstance(tokens, dict): + for name in _QR_COOKIE_FIELDS: + token = tokens.get(name) + if isinstance(token, str) and token: + cookie_parts.setdefault(name, token) + elif isinstance(tokens, list): + for token_info in tokens: + if not isinstance(token_info, dict): + continue + name = token_info.get("name") + token = token_info.get("token") + if name in _QR_COOKIE_FIELDS and isinstance(token, str) and token: + cookie_parts.setdefault(name, token) + for key in ("cookie", "cookies", "cookie_str", "cookies_str"): raw_value = payload.get(key) if isinstance(raw_value, str): - cookie_parts.update(_parse_cookie_string(raw_value)) + for cookie_key, value in _parse_cookie_string(raw_value).items(): + cookie_parts.setdefault(cookie_key, value) elif isinstance(raw_value, dict): - for cookie_key in _QR_COOKIE_FIELDS: - value = raw_value.get(cookie_key) - if isinstance(value, str) and value: + # 已知 Cookie 封套中的字符串字段均原样保留,避免后续新增字段 + # 因本地白名单未更新而在“全量”返回中丢失。 + for cookie_key, value in raw_value.items(): + if isinstance(cookie_key, str) and isinstance(value, str) and value: cookie_parts.setdefault(cookie_key, value) + if include_nested and _depth < 2: + # Passport 的不同确认响应会把同一份 Cookie 放入已知业务封套中。 + # 只展开有限深度,兼容序列化 JSON 封套并避免扫描无关响应内容。 + for key in ("data", "result", "payload", "login_data"): + nested = payload.get(key) + if isinstance(nested, str): + try: + nested = json.loads(nested) + except (TypeError, ValueError): + nested = None + if isinstance(nested, dict): + for nested_key, nested_value in _extract_cookie_payload( + nested, include_nested=True, _depth=_depth + 1 + ).items(): + cookie_parts.setdefault(nested_key, nested_value) return cookie_parts @@ -399,3 +1147,131 @@ def _parse_cookie_string(cookie_str: str) -> dict[str, str]: if key.strip() and value.strip(): cookies[key.strip()] = value.strip() return cookies + + +async def exchange_stoken( + game_token: str, + uid: str, + proxy: str | None = None, +) -> dict: + """通过 GameToken 获取 stoken_v2/mid 和可选 cookie_token。""" + + token_value = str(game_token or "").strip() + uid_value = str(uid or "").strip() + if not token_value or not uid_value: + raise ValueError("GameToken 登录信息为空") + try: + account_id = int(uid_value) + except (TypeError, ValueError) as error: + raise ValueError("GameToken 登录返回的 UID 无效") from error + + request_body = { + "account_id": account_id, + "game_token": token_value, + } + headers = {"x-rpc-app_id": "bll8iq97cem8"} + + try: + async with httpx.AsyncClient( + proxy=proxy or Config.proxy, + trust_env=False, + ) as client: + response = await client.post( + GAME_TOKEN_STOKEN_URL, + headers=headers, + json=request_body, + timeout=30.0, + ) + try: + data = response.json() + except ValueError as error: + raise ValueError("GameToken 换取 stoken 接口返回无效 JSON") from error + + if ( + not isinstance(data, dict) + or data.get("retcode") not in (0, "0") + ): + raise ValueError("GameToken 换取 stoken 失败") + token_data = data.get("data") + token_info = ( + token_data.get("token") + if isinstance(token_data, dict) + else None + ) + user_info = ( + token_data.get("user_info") + if isinstance(token_data, dict) + else None + ) + stoken_v2 = ( + token_info.get("token") + if isinstance(token_info, dict) + else None + ) + mid = user_info.get("mid") if isinstance(user_info, dict) else None + if not isinstance(stoken_v2, str) or not stoken_v2.strip(): + raise ValueError("GameToken 换取 stoken 响应缺少 stoken") + if not isinstance(mid, str) or not mid.strip(): + raise ValueError("GameToken 换取 stoken 响应缺少 mid") + + stoken_v2 = stoken_v2.strip() + mid = mid.strip() + cookie_parts = { + "stoken_v2": stoken_v2, + "stoken": stoken_v2, + "mid": mid, + "mid_v2": mid, + "ltmid_v2": mid, + "account_mid_v2": mid, + "stuid": uid_value, + "stuid_v2": uid_value, + "ltuid": uid_value, + "ltuid_v2": uid_value, + "account_id": uid_value, + "account_id_v2": uid_value, + "login_uid": uid_value, + } + + # CookieToken 不是兑换 stoken 的必要条件;上游暂时不可用时, + # 仍保留 stoken_v2 + mid 供签到模块派生。 + try: + cookie_response = await client.post( + GAME_TOKEN_COOKIE_URL, + headers=headers, + json=request_body, + timeout=30.0, + ) + cookie_data = cookie_response.json() + except (httpx.HTTPError, OSError, ValueError): + logger.debug("GameToken 获取 cookie_token 跳过") + else: + cookie_payload = cookie_data.get("data") if isinstance( + cookie_data, dict + ) else None + cookie_info = ( + cookie_payload.get("token") + if isinstance(cookie_payload, dict) + else None + ) + cookie_token = ( + cookie_info.get("token") + if isinstance(cookie_info, dict) + else None + ) + if ( + isinstance(cookie_data, dict) + and cookie_data.get("retcode") in (0, "0") + and isinstance(cookie_token, str) + and cookie_token.strip() + ): + cookie_token = cookie_token.strip() + cookie_parts["cookie_token"] = cookie_token + if cookie_token.startswith("v2_"): + cookie_parts["cookie_token_v2"] = cookie_token + + return { + "cookies_str": _serialize_cookie_parts(cookie_parts) + } + finally: + request_body["game_token"] = "" + token_value = "" diff --git a/app/tools/skland.py b/app/tools/skland.py index 749153835..247fac874 100644 --- a/app/tools/skland.py +++ b/app/tools/skland.py @@ -12,6 +12,20 @@ # skland-daily-attendance Copyright © 2023-2025 enpitsuLin # https://github.com/enpitsuLin/skland-daily-attendance +# QR login protocol compatibility knowledge: +# rhodes-headquarters Copyright (c) 2023 enpitsulin (MIT License) +# https://github.com/AEtherside/rhodes-headquarters +# +# zmd-plugin (scan login protocol reference) +# https://github.com/Anon-deisu/zmd-plugin + +# Attendance reward response parsing adapted from: +# arknights-plugin Copyright (c) 2023 gxy12345 (MIT License) +# https://github.com/gxy12345/arknights-plugin +# +# multi-game-auto-sign Copyright (c) 2023 xxyz30 (MIT License) +# https://github.com/Kilaers/multi-game-auto-sign + # This file is part of AUTO-MAS. # AUTO-MAS is free software: you can redistribute it and/or modify @@ -39,7 +53,7 @@ import time import uuid from datetime import datetime, timedelta -from typing import Any, Awaitable, Callable, Dict +from typing import Awaitable, Callable from urllib import parse import httpx @@ -58,20 +72,20 @@ SKLAND_APP_CODE = "4ca99fa6b56cc2ba" SKLAND_GRANT_CODE_URL = "https://as.hypergryph.com/user/oauth2/v2/grant" -SKLAND_PASSWORD_LOGIN_URL = ( - "https://as.hypergryph.com/user/auth/v1/token_by_phone_password" -) SKLAND_CRED_CODE_URL = "https://zonai.skland.com/web/v1/user/auth/generate_cred_by_code" SKLAND_REFRESH_URL = "https://zonai.skland.com/web/v1/auth/refresh" SKLAND_BINDING_URL = "https://zonai.skland.com/api/v1/game/player/binding" SKLAND_ARKNIGHTS_SIGN_URL = "https://zonai.skland.com/api/v1/game/attendance" SKLAND_ENDFIELD_SIGN_URL = "https://zonai.skland.com/web/v1/game/endfield/attendance" SKLAND_SIGN_INTERVAL = 1.0 +SKLAND_SCAN_CREATE_URL = "https://as.hypergryph.com/general/v1/gen_scan/login" +SKLAND_SCAN_STATUS_URL = "https://as.hypergryph.com/general/v1/scan_status" +SKLAND_SCAN_TOKEN_URL = "https://as.hypergryph.com/user/auth/v1/token_by_scan_code" logger = get_logger("森空岛签到任务") -def is_skland_already_signed(response: dict) -> bool: +def is_skland_already_signed(response: dict[str, object]) -> bool: """判断森空岛签到响应是否表示今日已签到。""" message = str(response.get("message", "")) return response.get("code") == 10001 or any( @@ -79,19 +93,72 @@ def is_skland_already_signed(response: dict) -> bool: ) -def _get_arknights_game_id(character: dict[str, Any]) -> Any: +def _get_arknights_game_id(character: dict[str, object]) -> object: """读取方舟绑定对象的游戏 ID,兼容旧响应中的 channelMasterId。""" return character.get("gameId") or character.get("channelMasterId") +def _format_skland_arknights_awards(payload: object) -> str: + """格式化明日方舟签到返回的奖励列表。""" + + if not isinstance(payload, dict): + return "" + awards = payload.get("awards") or [] + if not isinstance(awards, list): + return "" + + parts: list[str] = [] + for award in awards: + if not isinstance(award, dict): + continue + resource = award.get("resource") + name = "" + if isinstance(resource, dict): + name = str(resource.get("name") or "").strip() + elif resource not in (None, ""): + name = str(resource).strip() + if not name: + continue + count = award.get("count") + count_text = str(count).strip() if count not in (None, "") else "1" + parts.append(f"{name}×{count_text}") + return "、".join(parts) + + +def _format_skland_endfield_awards(payload: object) -> str: + """格式化终末地签到返回的奖励项。""" + + if not isinstance(payload, dict): + return "" + award_ids = payload.get("awardIds", []) + resource_map = payload.get("resourceInfoMap", {}) + if not isinstance(award_ids, list) or not isinstance(resource_map, dict): + return "" + + parts: list[str] = [] + for award in award_ids: + if not isinstance(award, dict): + continue + award_id = award.get("id") + if not award_id or award_id not in resource_map: + continue + resource = resource_map[award_id] + if not isinstance(resource, dict) or not resource.get("name"): + continue + count = resource.get("count", 1) + count_text = str(count).strip() if count not in (None, "") else "1" + parts.append(f"{resource['name']}×{count_text}") + return "、".join(parts) + + def _create_skland_client(proxy: str | None = None) -> httpx.AsyncClient: """创建不继承本地环境代理的森空岛 HTTP 客户端。""" return httpx.AsyncClient(proxy=proxy, trust_env=False) -def _parse_json_object(response: httpx.Response) -> dict[str, Any]: +def _parse_json_object(response: httpx.Response) -> dict[str, object]: """解析森空岛响应,并拒绝空值或非对象 JSON。""" payload = response.json() @@ -100,10 +167,10 @@ def _parse_json_object(response: httpx.Response) -> dict[str, Any]: return payload -def parse_skland_credential(raw: str | dict[str, Any]) -> dict[str, str]: +def parse_skland_credential(raw: str | dict[str, object]) -> dict[str, str]: """解析森空岛旧 Token 或统一凭据 JSON。""" - payload: dict[str, Any] = {} + payload: dict[str, object] = {} raw_value = raw if isinstance(raw, dict) else str(raw or "").strip() if isinstance(raw_value, dict): payload = raw_value @@ -146,7 +213,7 @@ def parse_skland_credential(raw: str | dict[str, Any]) -> dict[str, str]: } -def validate_skland_credential(raw: str | dict[str, Any]) -> dict[str, str]: +def validate_skland_credential(raw: str | dict[str, object]) -> dict[str, str]: """校验旧纯 Token 或统一凭据 JSON 的明显格式错误。""" if isinstance(raw, dict): @@ -201,7 +268,7 @@ def validate_skland_credential(raw: str | dict[str, Any]) -> dict[str, str]: return credential -def serialize_skland_credential(credential: dict[str, Any]) -> str: +def serialize_skland_credential(credential: dict[str, object]) -> str: """序列化森空岛凭据,不写入手机号、密码等登录信息。""" normalized = parse_skland_credential(credential) @@ -240,7 +307,7 @@ def get_sm_id() -> str: return f"{v}{smsk_web}0" -def get_tn(obj: Dict[str, Any]) -> str: +def get_tn(obj: dict[str, object]) -> str: """计算tn值""" sorted_keys = sorted(obj.keys()) result_list = [] @@ -286,7 +353,7 @@ def encrypt_des(message: str, key: str) -> str: return base64.b64encode(encrypted).decode() -def gzip_compress_object(obj: Dict[str, Any]) -> str: +def gzip_compress_object(obj: dict[str, object]) -> str: """GZIP压缩对象""" json_str = json.dumps(obj, separators=(", ", ": ")) compressed = gzip.compress(json_str.encode()) @@ -307,8 +374,8 @@ def encrypt_aes(message: str, key: str) -> str: def encrypt_object_by_des_rules( - obj: Dict[str, Any], rules: Dict[str, Dict[str, Any]] -) -> Dict[str, Any]: + obj: dict[str, object], rules: dict[str, dict[str, object]] +) -> dict[str, object]: """根据DES规则加密对象""" result = {} @@ -508,7 +575,7 @@ async def _get_cred_by_code( client: httpx.AsyncClient, grant_code: str, device_id: str, -) -> dict[str, Any]: +) -> dict[str, object]: """使用授权码获取森空岛 cred 和接口签名 Token。""" web_headers = { @@ -540,33 +607,258 @@ async def _get_cred_by_code( return data -async def login_skland_with_password( - phone: str, - password: str, +def build_skland_signed_headers( + sign_token: str, + *, + path: str, + body_or_query: str, + device_id: str, + headers: dict[str, str], + platform: str = "1", + version: str = "1.21.0", +) -> dict[str, str]: + """为森空岛 API 请求生成统一签名头,不修改输入字典。""" + + timestamp = str(int(time.time() * 1000 - 2000))[:-3] + signature_headers = { + "platform": platform, + "timestamp": timestamp, + "dId": device_id, + "vName": version, + } + signature_text = path + body_or_query + timestamp + json.dumps( + signature_headers, + separators=(",", ":"), + ) + digest = hashlib.md5( + hmac.new( + sign_token.encode("utf-8"), + signature_text.encode("utf-8"), + hashlib.sha256, + ) + .hexdigest() + .encode("utf-8") + ).hexdigest() + result = dict(headers) + result.update(signature_headers) + result["sign"] = digest + result.pop("token", None) + return result + + +def _skland_credential_headers( + cred: str, + sign_token: str = "", +) -> dict[str, str]: + """构造森空岛凭据请求头,兼容既有 iOS 客户端标识。""" + + headers = { + "cred": cred, + "User-Agent": "Skland/1.21.0 (com.hypergryph.skland; build:102100065; iOS 17.6.0; ) Alamofire/5.7.1", + "Accept-Encoding": "gzip", + "Connection": "keep-alive", + "Content-Type": "application/json", + } + if sign_token: + headers["token"] = sign_token + return headers + + +async def prepare_skland_session_credential( + client: httpx.AsyncClient, + raw_credential: str | dict[str, object], + device_id: str, +) -> dict[str, str]: + """解析森空岛凭据,并在缺少 cred/token 时完成 OAuth 授权。""" + + # 直调签到历史上允许凭据同时携带 OAuth Token 和不完整缓存; + # 此时丢弃缓存并重新授权。活动入口仍会在调用本函数前执行严格校验。 + credential = parse_skland_credential(raw_credential) + if credential["cred"] and credential["token"]: + return credential + if not credential["oauthToken"]: + raise ValueError("森空岛登录凭据为空") + grant_code = await _get_grant_code( + client, + credential["oauthToken"], + device_id, + ) + cred_data = await _get_cred_by_code(client, grant_code, device_id) + return parse_skland_credential( + { + "oauthToken": credential["oauthToken"], + "token": cred_data.get("token"), + "cred": cred_data.get("cred"), + "userId": cred_data.get("userId"), + } + ) + + +async def refresh_skland_session_credential( + client: httpx.AsyncClient, + credential: dict[str, str], + device_id: str, *, + timeout: float | None = None, +) -> dict[str, str]: + """使用当前 cred 刷新森空岛接口签名 Token。""" + + if not credential["cred"] or not credential["token"]: + raise ValueError("森空岛缺少可刷新的 cred") + headers = build_skland_signed_headers( + credential["token"], + path="/web/v1/auth/refresh", + body_or_query="", + device_id=device_id, + headers=_skland_credential_headers( + credential["cred"], + credential["token"], + ), + ) + if timeout is None: + response = await client.get(SKLAND_REFRESH_URL, headers=headers) + else: + response = await client.get( + SKLAND_REFRESH_URL, + headers=headers, + timeout=timeout, + ) + response_data = _parse_json_object(response) + if response_data.get("code") != 0: + raise ValueError( + f"森空岛凭据刷新失败: {response_data.get('message') or '上游拒绝请求'}" + ) + data = response_data.get("data") + if not isinstance(data, dict) or not data.get("token"): + raise ValueError("森空岛凭据刷新响应格式无效") + return parse_skland_credential({**credential, "token": data["token"]}) + + +async def create_skland_qr_login( + proxy: str | None = None, +) -> dict[str, object]: + """创建森空岛扫码登录,返回 ticket、二维码内容与设备 ID。""" + + async with _create_skland_client(proxy) as client: + device_id = await get_cached_device_id(proxy, client=client) + response = await client.post( + SKLAND_SCAN_CREATE_URL, + json={}, + headers=_hypergryph_headers(device_id), + ) + response_data = _parse_json_object(response) + if not response.is_success or str(response_data.get("status")) != "0": + message = str( + response_data.get("msg") or response_data.get("message") or "" + ).strip() + raise ValueError(f"创建森空岛扫码登录失败: {message or '上游拒绝请求'}") + data = response_data.get("data") + if not isinstance(data, dict): + raise ValueError("森空岛扫码登录响应格式无效") + scan_id = str(data.get("scanId") or "").strip() + scan_url = str(data.get("scanUrl") or "").strip() + if not scan_id or not scan_url: + raise ValueError("森空岛扫码登录未返回有效二维码") + return { + "ticket": scan_id, + "qr_url": scan_url, + "device": device_id, + } + + +async def check_skland_qr_status( + ticket: str, + device: str, + proxy: str | None = None, +) -> dict[str, object]: + """轮询森空岛扫码状态,确认后返回短时 scanCode。""" + + del device # 当前 scan_status 上游不需要设备头;保留参数兼容本地调用契约。 + if not str(ticket or "").strip(): + raise ValueError("森空岛扫码 ticket 为空") + + async with _create_skland_client(proxy) as client: + response = await client.get( + SKLAND_SCAN_STATUS_URL, + params={"scanId": ticket}, + ) + if not response.is_success: + raise ValueError( + f"查询森空岛扫码状态失败: HTTP {response.status_code}" + ) + response_data = _parse_json_object(response) + + raw_status_value = response_data.get("status") + raw_status = "" if raw_status_value is None else str(raw_status_value) + message = str( + response_data.get("msg") or response_data.get("message") or "" + ).strip() + + if raw_status != "0": + if "未扫码" in message: + status = "Init" + elif "待确认" in message or "已扫码" in message: + status = "Scanned" + elif "失效" in message or "过期" in message: + status = "Expired" + elif "取消" in message or "拒绝" in message: + status = "Canceled" + else: + status = "Init" + return { + "status": status, + "scan_code": "", + "message": message, + } + + data = response_data.get("data") + scan_code = ( + str(data.get("scanCode") or "").strip() + if isinstance(data, dict) + else "" + ) + if not scan_code: + raise ValueError("森空岛扫码已确认,但未返回 scanCode") + return { + "status": "Confirmed", + "scan_code": scan_code, + "message": "", + } + + +async def finalize_skland_qr_login( + scan_code: str, proxy: str | None = None, ) -> str: - """一次性使用手机号和密码获取并校验森空岛凭据。""" + """用扫码确认结果换取完整森空岛凭据并序列化保存。""" - phone_value = str(phone or "").strip() - password_value = str(password or "") - if not phone_value or not password_value: - raise ValueError("手机号和密码不能为空") + scan_code_value = str(scan_code or "").strip() + if not scan_code_value: + raise ValueError("森空岛扫码 scanCode 为空") async with _create_skland_client(proxy) as client: device_id = await get_cached_device_id(proxy, client=client) response = await client.post( - SKLAND_PASSWORD_LOGIN_URL, - json={"phone": phone_value, "password": password_value}, + SKLAND_SCAN_TOKEN_URL, + json={"scanCode": scan_code_value}, headers=_hypergryph_headers(device_id), ) response_data = _parse_json_object(response) - if response_data.get("status") != 0: - message = response_data.get("msg") or response_data.get("message") - raise ValueError(f"森空岛账号密码登录失败: {message or '上游拒绝请求'}") + if not response.is_success or str(response_data.get("status")) != "0": + message = str( + response_data.get("msg") or response_data.get("message") or "" + ).strip() + if "失效" in message or "过期" in message: + raise ValueError( + "森空岛扫码凭证已失效,请重新扫码" + ) + raise ValueError( + f"森空岛扫码换取 Token 失败: {message or '上游拒绝请求'}" + ) + data = response_data.get("data") if not isinstance(data, dict) or not data.get("token"): - raise ValueError("森空岛登录响应未返回有效 Token") + raise ValueError("森空岛扫码响应未返回有效 Token") oauth_token = str(data["token"]) grant_code = await _get_grant_code(client, oauth_token, device_id) @@ -588,7 +880,7 @@ async def skland_sign_in( proxy: str | None = None, *, on_credential_update: Callable[[str], Awaitable[None]] | None = None, -) -> dict: +) -> dict[str, object]: """串行执行森空岛签到,协调旧用户链路与工具链路。""" async with _skland_sign_lock: @@ -606,138 +898,39 @@ async def _run_skland_sign_in( proxy: str | None = None, *, on_credential_update: Callable[[str], Awaitable[None]] | None = None, -) -> dict: +) -> dict[str, object]: """森空岛签到""" binding_url = SKLAND_BINDING_URL arknights_sign_url = SKLAND_ARKNIGHTS_SIGN_URL endfield_sign_url = SKLAND_ENDFIELD_SIGN_URL - 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": "keep-alive", - "Content-Type": "application/json", - } - header_for_sign = { - "platform": "1", - "timestamp": "", - "dId": "", - "vName": "1.21.0", - } client: httpx.AsyncClient | None = None device_id = "" - def generate_signature( - token_for_sign: str, path, body_or_query, custom_header=None - ): - """生成请求签名""" - 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, body, old_header, sign_token): """获取带签名的请求头""" - h = json.loads(json.dumps(old_header)) p = parse.urlparse(url) assert client is not None current_device_id = device_id or await get_cached_device_id( proxy, client=client ) - temp_header_for_sign = dict(header_for_sign) - temp_header_for_sign["dId"] = current_device_id - if method.lower() == "get": - query = p.query or "" - sign, header_ca = generate_signature( - sign_token, p.path, query, temp_header_for_sign - ) + body_or_query = p.query or "" 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 + body_or_query = json.dumps(body) if body else "" + return build_skland_signed_headers( + sign_token, + path=p.path, + body_or_query=body_or_query, + device_id=current_device_id, + headers=old_header, + ) def copy_header(cred, token=None): """复制请求头并添加cred和token""" - v = json.loads(json.dumps(header)) - v["cred"] = cred - if token: - v["token"] = token - return v - - async def get_grant_code(token_value): - """通过token获取grant code""" - assert client is not None - return await _get_grant_code(client, token_value, device_id) - - async def get_cred(grant): - """通过 grant code 获取 cred 和签名 Token""" - assert client is not None - return await _get_cred_by_code(client, grant, device_id) - - async def login_by_token(token_code: str) -> dict[str, str]: - """使用旧 Token 或缓存凭据建立签到会话。""" - credential = parse_skland_credential(token_code) - if credential["cred"] and credential["token"]: - return credential - if not credential["oauthToken"]: - raise ValueError("森空岛登录凭据为空") - grant_code = await get_grant_code(credential["oauthToken"]) - cred_data = await get_cred(grant_code) - return parse_skland_credential( - { - "oauthToken": credential["oauthToken"], - "token": cred_data.get("token"), - "cred": cred_data.get("cred"), - "userId": cred_data.get("userId"), - } - ) - - async def refresh_credential(credential: dict[str, str]) -> dict[str, str]: - """使用当前 cred 刷新接口签名 Token,避免每次重新授权。""" - assert client is not None - if not credential["cred"] or not credential["token"]: - raise ValueError("森空岛缺少可刷新的 cred") - headers = await get_sign_header( - SKLAND_REFRESH_URL, - "get", - None, - copy_header(credential["cred"], credential["token"]), - credential["token"], - ) - response = await client.get(SKLAND_REFRESH_URL, headers=headers) - response_data = _parse_json_object(response) - if response_data.get("code") != 0: - raise ValueError( - f"森空岛凭据刷新失败: {response_data.get('message') or '上游拒绝请求'}" - ) - data = response_data.get("data") - if not isinstance(data, dict) or not data.get("token"): - raise ValueError("森空岛凭据刷新响应格式无效") - return parse_skland_credential( - { - **credential, - "token": data["token"], - } - ) + return _skland_credential_headers(cred, token or "") async def get_binding_list(cred, sign_token, app_code_override: str | None = None): """查询已绑定的角色列表 @@ -840,12 +1033,19 @@ async def check_attendance_today(cred, sign_token, uid, game_id) -> bool: _log_skland_exception("检查森空岛签到状态失败", e) return False - async def sign_for_arknights(cred, sign_token) -> dict: + async def sign_for_arknights(cred, sign_token) -> dict[str, object]: """方舟签到""" characters = await get_binding_list( cred, sign_token, app_code_override="arknights" ) - result = {"成功": [], "重复": [], "失败": [], "总计": len(characters)} + reward_map: dict[str, str] = {} + result = { + "成功": [], + "重复": [], + "失败": [], + "总计": len(characters), + "奖励": reward_map, + } attendance_states = await asyncio.gather( *( @@ -907,8 +1107,13 @@ async def sign_for_arknights(cred, sign_token) -> dict: f"{character_name} 签到失败: {rsp.get('message')}" ) else: + reward_text = _format_skland_arknights_awards(rsp.get("data")) result["成功"].append(character_name) - logger.info(f"{character_name} 签到成功") + reward_map[character_name] = reward_text + if reward_text: + logger.info(f"{character_name} 签到成功: {reward_text}") + else: + logger.info(f"{character_name} 签到成功") except Exception as e: result["失败"].append(character_name) @@ -919,7 +1124,9 @@ async def sign_for_arknights(cred, sign_token) -> dict: return result - async def do_sign_for_endfield(cred, sign_token, role: dict): + async def do_sign_for_endfield( + cred, sign_token, role: dict[str, object] + ) -> dict[str, object]: headers = await get_sign_header( endfield_sign_url, "post", @@ -940,7 +1147,7 @@ async def do_sign_for_endfield(cred, sign_token, role: dict): response = await client.post(endfield_sign_url, headers=headers) return _parse_json_object(response) - async def sign_for_endfield(cred, sign_token) -> dict: + async def sign_for_endfield(cred, sign_token) -> dict[str, object]: """终末地签到""" characters = await get_binding_list( cred, sign_token, app_code_override="endfield" @@ -964,7 +1171,14 @@ async def sign_for_endfield(cred, sign_token) -> dict: ) role_items.append((character, role, character_name, game_name)) - result = {"成功": [], "重复": [], "失败": [], "总计": len(role_items)} + reward_map: dict[str, str] = {} + result = { + "成功": [], + "重复": [], + "失败": [], + "总计": len(role_items), + "奖励": reward_map, + } for index, (_character, role, character_name, game_name) in enumerate( role_items @@ -984,30 +1198,15 @@ async def sign_for_endfield(cred, sign_token) -> dict: data = rsp.get("data") or {} if not isinstance(data, dict): data = {} - award_ids = data.get("awardIds", []) - resource_map = data.get("resourceInfoMap", {}) - awards = [] - award_list = award_ids if isinstance(award_ids, list) else [] - for award in award_list: - if not isinstance(award, dict): - continue - award_id = award.get("id") - if ( - award_id - and isinstance(resource_map, dict) - and award_id in resource_map - ): - resource = resource_map[award_id] - if isinstance(resource, dict) and resource.get("name"): - awards.append( - f"{resource['name']}x{resource.get('count', 1)}" - ) - if awards: + reward_text = _format_skland_endfield_awards(data) + result["成功"].append(character_name) + reward_map[character_name] = reward_text + if reward_text: logger.info( - f"[{game_name}] {character_name} 签到成功: {'、'.join(awards)}" + f"[{game_name}] {character_name} 签到成功: {reward_text}" ) - result["成功"].append(character_name) - logger.info(f"{character_name} 签到成功") + else: + logger.info(f"{character_name} 签到成功") except Exception as e: result["失败"].append(character_name) _log_skland_exception("终末地签到失败", e) @@ -1017,7 +1216,7 @@ async def sign_for_endfield(cred, sign_token) -> dict: return result - async def run_sign(credential: dict[str, str]) -> dict: + async def run_sign(credential: dict[str, str]) -> dict[str, object]: cred = credential["cred"] sign_token = credential["token"] if not cred or not sign_token: @@ -1047,18 +1246,30 @@ async def run_sign(credential: dict[str, str]) -> dict: async with _create_skland_client(proxy) as shared_client: client = shared_client device_id = await get_cached_device_id(proxy, client=client) - credential = await login_by_token(token) + credential = await prepare_skland_session_credential( + client, + token, + device_id, + ) try: result = await run_sign(credential) except SklandCredentialExpiredError: try: - credential = await refresh_credential(credential) + credential = await refresh_skland_session_credential( + client, + credential, + device_id, + ) except Exception as exc: if not _is_expected_skland_exception(exc): raise if not credential["oauthToken"]: raise - credential = await login_by_token(credential["oauthToken"]) + credential = await prepare_skland_session_credential( + client, + credential["oauthToken"], + device_id, + ) result = await run_sign(credential) serialized = serialize_skland_credential(credential) diff --git a/app/tools/taygedo.py b/app/tools/taygedo.py index cd142f8fe..ae05dc918 100644 --- a/app/tools/taygedo.py +++ b/app/tools/taygedo.py @@ -41,8 +41,8 @@ import secrets import string import time -from collections.abc import Mapping -from typing import Any +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass, field import httpx from Crypto.Cipher import AES @@ -51,7 +51,9 @@ from app.utils.logger import get_logger from app.utils.security import format_exception_reason -logger = get_logger("塔吉多签到") +from .game_sign_result import merge_community_sign_result + +logger = get_logger("塔吉多社区") TAYGEDO_BASE_URL = "https://bbs-api.tajiduo.com" LAOHU_BASE_URL = "https://user.laohu.com" @@ -61,23 +63,29 @@ GAME_RECORD_CARDS_URL = f"{TAYGEDO_BASE_URL}/apihub/api/getGameRecordCard" APP_SIGNIN_URL = f"{TAYGEDO_BASE_URL}/apihub/api/signin" GAME_SIGNIN_STATE_URL = f"{TAYGEDO_BASE_URL}/apihub/awapi/signin/state" +GAME_SIGNIN_REWARDS_URL = f"{TAYGEDO_BASE_URL}/apihub/awapi/sign/rewards" GAME_SIGNIN_URL = f"{TAYGEDO_BASE_URL}/apihub/awapi/sign" CLOUD_USER_INFO_URL = "https://user.laohu.com/cloud/game/getUserInfo" DEFAULT_GAME_ID = "1289" -TAYGEDO_GAME_IDS = ("1256", "1257", "1289") +TAYGEDO_GAME_IDS = ("1256", "1289") TAYGEDO_GAME_NAMES = { "1256": "幻塔", - "1257": "异环", "1289": "异环", } +# 1257 未在已核对的角色卡响应中确认,暂不查询该游戏。 APP_VERSION = "1.1.0" -# 用户中心刷新接口仍使用 1.1.0;角色卡、角色列表和社区接口使用当前原生协议版本。 +# 游戏角色列表接口使用 1.1.0;用户中心登录、刷新、角色卡和社区签到接口使用 1.2.5。 TAYGEDO_NATIVE_APP_VERSION = "1.2.5" TAYGEDO_COMMUNITY_IDS = ("1", "2") +# 社区签到 ID 与已绑定游戏 ID 对应;未绑定的游戏不触发对应社区签到。 +TAYGEDO_COMMUNITY_GAME_IDS = { + "1": "1256", + "2": "1289", +} TAYGEDO_COMMUNITY_NAMES = { - "1": "幻塔社区", - "2": "异环社区", + "1": "幻塔", + "2": "异环", } APP_USER_AGENT = "okhttp/4.12.0" TAYGEDO_LOGIN_APP_ID = "10551" @@ -104,6 +112,44 @@ CLOUD_APP_VERSION = "1.1.0" +@dataclass +class _TaygedoRuntimeCredential: + """跟踪一次调用内产生的凭据对象,确保运行期临时数据可清理。""" + + values: list[dict[str, object]] = field(default_factory=list) + refresh_attempted: bool = False + refresh_succeeded: bool = False + credential_update_delivered: bool = False + + def track(self, credential: dict[str, object]) -> dict[str, object]: + if not any(item is credential for item in self.values): + self.values.append(credential) + return credential + + def persistable( + self, + credential: dict[str, object], + *, + drop_access_token: bool = True, + ) -> dict[str, object]: + """复制可持久化快照,按调用方需要保留或移除 accessToken。""" + + self.track(credential) + persisted = dict(credential) + if drop_access_token: + persisted.pop("accessToken", None) + persisted.pop("_gameRoles", None) + persisted.pop("_gameRolesComplete", None) + return persisted + + def clear(self) -> None: + """清理本次调用持有的可变凭据对象。""" + + for credential in self.values: + credential.clear() + self.values.clear() + + def _is_expected_taygedo_exception(error: Exception) -> bool: """判断可预期的凭据、上游或网络失败。""" @@ -129,7 +175,7 @@ def _log_taygedo_exception(stage: str, error: Exception) -> str: return reason -def parse_taygedo_credential(raw: str) -> dict[str, Any]: +def parse_taygedo_credential(raw: str) -> dict[str, object]: """解析纯 refreshToken 或参考项目兼容的 JSON 凭据。""" text = str(raw or "").strip() @@ -192,10 +238,31 @@ def parse_taygedo_credential(raw: str) -> dict[str, Any]: return credential -def serialize_taygedo_credential(credential: Mapping[str, Any]) -> str: +def validate_taygedo_credential(raw: str) -> dict[str, object]: + """校验塔吉多及云异环凭据的本地字段完整性。""" + + credential = parse_taygedo_credential(raw) + if not credential: + raise ValueError("塔吉多凭据不能为空") + + has_community = bool( + credential.get("refreshToken") or credential.get("accessToken") + ) + has_cloud_token = bool(credential.get("cloudToken")) + has_cloud_user = bool(credential.get("cloudUserId")) + if has_cloud_token != has_cloud_user: + raise ValueError("云异环凭据必须同时包含 cloudToken 和 cloudUserId") + if not has_community and not has_cloud_token: + raise ValueError( + "塔吉多凭据缺少 refreshToken/accessToken 或 cloudToken/cloudUserId" + ) + return credential + + +def serialize_taygedo_credential(credential: Mapping[str, object]) -> str: """以稳定、可再次导入的 JSON 保存凭据,不写入日志。""" - persisted: dict[str, Any] = {} + persisted: dict[str, object] = {} for key in ( "refreshToken", "accessToken", @@ -224,7 +291,7 @@ async def login_taygedo_with_password( existing_raw: str = "", device_id: str | None = None, proxy: str | None = None, -) -> dict[str, Any]: +) -> dict[str, object]: """一次性使用账号密码换取塔吉多访问凭据,不保存密码。""" phone_value = str(phone or "").strip() @@ -373,7 +440,7 @@ async def _request_user_center_login( device_id: str, *, compat: bool, -) -> tuple[httpx.Response, dict[str, Any]]: +) -> tuple[httpx.Response, dict[str, object]]: if compat: headers = { "authorization": "", @@ -455,7 +522,7 @@ def _make_login_ds() -> str: return f"{timestamp},{nonce},{signature}" -def _is_code(value: Any, expected: int) -> bool: +def _is_code(value: object, expected: int) -> bool: """兼容上游以数字或字符串返回状态码。""" return str(value).strip() == str(expected) @@ -464,7 +531,7 @@ def _is_code(value: Any, expected: int) -> bool: def _login_api_error( endpoint: str, response: httpx.Response, - data: Mapping[str, Any], + data: Mapping[str, object], ) -> ValueError: # 不带响应正文,防止上游错误内容回显用户身份或认证数据。 message = str(data.get("msg") or data.get("message") or "请求失败").strip() @@ -478,62 +545,181 @@ async def refresh_taygedo_credential( raw: str, *, proxy: str | None = None, -) -> dict[str, Any]: +) -> dict[str, object]: """用已有 refreshToken 获取最新 accessToken,并返回可保存凭据。""" credential = parse_taygedo_credential(raw) refresh_token = str(credential.get("refreshToken") or "").strip() if not refresh_token: raise ValueError("塔吉多凭据缺少 refreshToken") + request_device_id = str( + credential.get("deviceId") or credential.get("cloudDeviceId") or "" + ).strip() + if not request_device_id: + # 参考客户端会复用设备标识;旧的纯 refreshToken 凭据没有该字段时, + # 用 refreshToken 派生稳定值,避免每天以不同设备发起刷新。 + request_device_id = _stable_device_id(refresh_token) + cloud_token = str(credential.get("cloudToken") or "").strip() + cloud_user_id = str(credential.get("cloudUserId") or "").strip() async with httpx.AsyncClient(proxy=proxy, trust_env=False) as client: response = await client.post( REFRESH_TOKEN_URL, headers={ + "accept": "application/json, text/plain, */*", "authorization": refresh_token, - "deviceid": str( - credential.get("deviceId") or _stable_device_id(refresh_token) - ), - "appversion": APP_VERSION, + "deviceId": request_device_id, + "appVersion": TAYGEDO_LOGIN_APP_VERSION, + "platform": "android", + "uid": str(credential.get("uid") or "0"), + "debug-uid": "3", + "ds": _make_login_ds(), "content-type": "application/x-www-form-urlencoded", "user-agent": APP_USER_AGENT, }, timeout=30.0, ) - data = _read_json(response, "塔吉多刷新 Token") - if not _is_code(data.get("code"), 0) or not isinstance(data.get("data"), dict): - raise _api_error("塔吉多刷新 Token", response, data) - - refreshed = data["data"] - if not refreshed.get("accessToken") or not refreshed.get("refreshToken"): - raise ValueError("塔吉多刷新接口未返回完整 token") - credential["accessToken"] = str(refreshed["accessToken"]) - credential["refreshToken"] = str(refreshed["refreshToken"]) + data: dict[str, object] | None = None + try: + data = _read_json(response, "塔吉多刷新 Token") + except ValueError: + if response.status_code not in (401, 402, 403): + raise + + code = data.get("code") if data is not None else None + refresh_rejected = response.status_code in (401, 402, 403) or any( + _is_code(code, value) for value in (22, 401, 402, 403, 4011) + ) + if ( + response.is_success + and data is not None + and _is_code(code, 0) + and isinstance(data.get("data"), dict) + ): + refreshed = data["data"] + elif refresh_rejected and cloud_token and cloud_user_id: + # refreshToken 被明确拒绝时,复用登录时保存的老虎侧会话重建用户中心 + # Token;该路径不需要也不会持久化账号密码。 + refreshed = await _user_center_login( + client, + cloud_token, + cloud_user_id, + request_device_id, + ) + elif data is None: + raise ValueError( + f"塔吉多刷新 Token 被拒绝(HTTP {response.status_code})" + ) + else: + raise _api_error("塔吉多刷新 Token", response, data) + + access_token = str(refreshed.get("accessToken") or "").strip() + if not access_token: + raise ValueError("塔吉多刷新接口未返回 accessToken") + credential["accessToken"] = access_token + # 已核对的刷新协议会成对返回 accessToken/refreshToken;缺少轮换后的 + # refreshToken 时不能继续保存旧值,否则下一次续期可能再次使用已消费令牌。 + refresh_token_value = str( + refreshed.get("refreshToken") or refreshed.get("refresh_token") or "" + ).strip() + if not refresh_token_value: + raise ValueError("塔吉多刷新接口未返回 refreshToken") + credential["refreshToken"] = refresh_token_value if refreshed.get("uid") is not None: credential["uid"] = str(refreshed["uid"]) credential.setdefault("gameId", DEFAULT_GAME_ID) - credential.setdefault("deviceId", _stable_device_id(credential["refreshToken"])) + if not str(credential.get("deviceId") or "").strip(): + credential["deviceId"] = request_device_id + # 刷新只负责认证字段;角色发现由后续社区游戏任务链路统一完成, + # 避免刷新阶段重复请求角色接口并污染持久化元数据。 + return credential + +async def sign_taygedo( + raw: str, + *, + proxy: str | None = None, + on_credential_update: Callable[[str], Awaitable[None]] | None = None, +) -> tuple[list[dict[str, object]], dict[str, object]]: + """执行一次塔吉多社区调用,并按刷新结果处理运行期访问 Token。""" + + runtime = _TaygedoRuntimeCredential() + credential = runtime.track(parse_taygedo_credential(raw)) try: - return await _attach_role_name(credential, proxy=proxy) - except Exception as exc: - # 角色名只用于通知展示,不能阻断有效 refreshToken 的社区签到。 - logger.debug(f"塔吉多角色信息获取跳过: {exc}") - return credential + results, effective_credential = await _run_taygedo( + raw, + credential, + runtime=runtime, + proxy=proxy, + on_credential_update=on_credential_update, + ) + persisted = runtime.persistable( + effective_credential, + # 刷新后的 accessToken 与轮换后的 refreshToken 成对保存;后续调用 + # 先复用当前会话,只有明确鉴权失败才再次触碰 refreshToken。 + drop_access_token=False, + ) + if ( + runtime.refresh_succeeded + and on_credential_update is not None + and not runtime.credential_update_delivered + ): + try: + await on_credential_update(serialize_taygedo_credential(persisted)) + runtime.credential_update_delivered = True + except Exception as exc: + _log_taygedo_exception("塔吉多凭据回写失败", exc) + return results, persisted + finally: + runtime.clear() -async def sign_taygedo( +async def _run_taygedo( raw: str, + credential: dict[str, object], *, + runtime: _TaygedoRuntimeCredential, proxy: str | None = None, -) -> tuple[list[dict[str, str]], dict[str, Any]]: - """执行塔吉多社区、应用内游戏签到和云异环时长查询。""" + on_credential_update: Callable[[str], Awaitable[None]] | None = None, +) -> tuple[list[dict[str, object]], dict[str, object]]: + """执行塔吉多社区签到、应用内游戏日常任务和云异环时长查询。""" - credential = parse_taygedo_credential(raw) refresh_error_reason: str | None = None - if credential.get("refreshToken"): + + async def publish_refreshed_credential( + refreshed_credential: dict[str, object], + ) -> None: + """在刷新返回后立即写穿认证快照,避免取消窗口丢失轮换 Token。""" + + if on_credential_update is None: + return + serialized = serialize_taygedo_credential( + runtime.persistable( + refreshed_credential, + drop_access_token=False, + ) + ) + update_task = asyncio.ensure_future(on_credential_update(serialized)) try: - credential = await refresh_taygedo_credential(raw, proxy=proxy) + # 刷新 Token 的回写必须完成后才能允许外层取消;否则一次性 + # refreshToken 可能已被消费但新值尚未落盘。 + await asyncio.shield(update_task) + except asyncio.CancelledError: + await asyncio.gather(update_task, return_exceptions=True) + raise + except Exception as exc: + _log_taygedo_exception("塔吉多凭据即时回写失败", exc) + return + runtime.credential_update_delivered = True + + if not _has_usable_taygedo_session(credential) and credential.get("refreshToken"): + runtime.refresh_attempted = True + try: + credential = runtime.track( + await refresh_taygedo_credential(raw, proxy=proxy) + ) + runtime.refresh_succeeded = True + await publish_refreshed_credential(credential) except Exception as exc: # 塔吉多 refreshToken 失效时仍继续查询同一凭据中的云异环时长。 refresh_error_reason = _log_taygedo_exception( @@ -541,30 +727,29 @@ async def sign_taygedo( exc, ) - results: list[dict[str, str]] = [] + results: list[dict[str, object]] = [] access_token = str(credential.get("accessToken") or "").strip() uid = str(credential.get("uid") or "").strip() device_id = str(credential.get("deviceId") or "").strip() + if not device_id and uid: + # 旧凭据可能只含 accessToken/uid;在本次运行内保持设备值稳定, + # 后续有 refreshToken 时由 refresh_taygedo_credential 继续复用它。 + device_id = _stable_device_id( + str(credential.get("refreshToken") or uid or access_token) + ) + credential["deviceId"] = device_id account = str(credential.get("roleName") or uid or "未知用户") - community_task: asyncio.Task[list[tuple[str, str, str, str]]] | None = None - game_task: asyncio.Task[list[dict[str, str]]] | None = None cloud_task: asyncio.Task[dict[str, int | None]] | None = None cloud_token = str(credential.get("cloudToken") or "").strip() cloud_user_id = str(credential.get("cloudUserId") or "").strip() cloud_account = account if account != "未知用户" else cloud_user_id cloud_before = _to_optional_int(credential.get("cloudRemainingDuration")) - async def cancel_pending_tasks() -> None: - pending_tasks = [ - task - for task in (community_task, game_task, cloud_task) - if task is not None and not task.done() - ] - for task in pending_tasks: - task.cancel() - if pending_tasks: - await asyncio.gather(*pending_tasks, return_exceptions=True) + async def cancel_cloud_task() -> None: + if cloud_task is not None and not cloud_task.done(): + cloud_task.cancel() + await asyncio.gather(cloud_task, return_exceptions=True) # 云异环每日首次登录时长与两个签到接口互不依赖,并发发起以缩短等待时间。 if cloud_token and cloud_user_id: @@ -582,39 +767,88 @@ async def cancel_pending_tasks() -> None: ) ) - if access_token and uid: - request_device_id = device_id or _stable_device_id(access_token) - cached_roles = credential.pop("_gameRoles", None) - cached_lookup_complete = credential.pop("_gameRolesComplete", None) - if not isinstance(cached_roles, list): - cached_roles = None - # 社区签到和应用内游戏签到互不依赖,同时发起以减少整体等待时间。 + async def run_taygedo_actions( + action_access_token: str, + action_uid: str, + action_device_id: str, + ) -> tuple[list[tuple[str, str, str, str]], list[dict[str, str]]]: + """并发执行一次社区和游戏日常动作,供鉴权恢复复用。""" + + action_roles = cached_roles + action_lookup_complete = ( + bool(cached_lookup_complete) if cached_roles is not None else None + ) + if action_roles is None: + try: + action_roles, action_lookup_complete = await _get_taygedo_game_roles_with_status( + action_access_token, + action_uid, + action_device_id, + proxy=proxy, + ) + except Exception as exc: + role_failure_reason = _log_taygedo_exception( + "塔吉多角色发现失败", + exc, + ) + community_results = [ + ( + TAYGEDO_COMMUNITY_NAMES[community_id], + "失败", + role_failure_reason, + "", + ) + for community_id in TAYGEDO_COMMUNITY_IDS + ] + game_results = [ + { + "account": account, + "game": "应用内游戏", + "platform": "塔吉多", + "status": "失败", + "reward": "", + "reason": role_failure_reason, + } + ] + return community_results, game_results + + action_community_ids = _taygedo_community_ids_for_roles(action_roles) community_task = asyncio.create_task( _community_sign( - access_token, - uid, - request_device_id, + action_access_token, + action_uid, + action_device_id, + community_ids=action_community_ids, proxy=proxy, ) ) game_task = asyncio.create_task( _sign_taygedo_games( - access_token, - uid, - request_device_id, + action_access_token, + action_uid, + action_device_id, account, proxy=proxy, - roles=cached_roles, - lookup_complete=( - bool(cached_lookup_complete) if cached_roles is not None else None - ), + roles=action_roles, + lookup_complete=action_lookup_complete, ) ) + async def cancel_action_tasks() -> None: + pending_tasks = [ + task + for task in (community_task, game_task) + if not task.done() + ] + for task in pending_tasks: + task.cancel() + if pending_tasks: + await asyncio.gather(*pending_tasks, return_exceptions=True) + try: community_results = await community_task except asyncio.CancelledError: - await cancel_pending_tasks() + await cancel_action_tasks() raise except Exception as exc: failure_reason = _log_taygedo_exception("塔吉多社区签到异常", exc) @@ -627,28 +861,17 @@ async def cancel_pending_tasks() -> None: failure_reason, "", ) - for community_id in TAYGEDO_COMMUNITY_IDS + for community_id in action_community_ids ] - for community_name, status, reason, reward in community_results: - results.append( - { - "account": account, - "game": community_name, - "platform": "塔吉多", - "status": status, - "reward": reward, - "reason": reason, - } - ) try: - results.extend(await game_task) + game_results = await game_task except asyncio.CancelledError: - await cancel_pending_tasks() + await cancel_action_tasks() raise except Exception as exc: - reason = _log_taygedo_exception("塔吉多应用内游戏签到异常", exc) - results.append( + reason = _log_taygedo_exception("塔吉多应用内游戏日常任务异常", exc) + game_results = [ { "account": account, "game": "应用内游戏", @@ -657,7 +880,84 @@ async def cancel_pending_tasks() -> None: "reward": "", "reason": reason, } + ] + return community_results, game_results + + if _has_usable_taygedo_session(credential): + request_device_id = device_id or _stable_device_id(access_token) + cached_roles = credential.pop("_gameRoles", None) + cached_lookup_complete = credential.pop("_gameRolesComplete", None) + if not isinstance(cached_roles, list): + cached_roles = None + + # 社区签到和应用内游戏日常任务互不依赖,同时发起以减少整体等待时间。 + community_results, game_results = await run_taygedo_actions( + access_token, + uid, + request_device_id, + ) + + # accessToken 过期时只恢复一次;只有明确鉴权失败才触碰 refreshToken。 + if ( + credential.get("refreshToken") + and not runtime.refresh_attempted + and _taygedo_actions_need_refresh( + community_results, + game_results, ) + ): + runtime.refresh_attempted = True + try: + refreshed_credential = await refresh_taygedo_credential( + raw, + proxy=proxy, + ) + credential = runtime.track(refreshed_credential) + runtime.refresh_succeeded = True + await publish_refreshed_credential(credential) + access_token = str(credential.get("accessToken") or "").strip() + uid = str(credential.get("uid") or "").strip() + request_device_id = str(credential.get("deviceId") or "").strip() + if _has_usable_taygedo_session(credential): + community_results, game_results = await run_taygedo_actions( + access_token, + uid, + request_device_id or _stable_device_id(access_token), + ) + except Exception as exc: + refresh_error_reason = _log_taygedo_exception( + "塔吉多鉴权恢复失败", + exc, + ) + + combined_results: list[dict[str, object]] = [ + dict(item) for item in game_results + ] + for community_name, status, reason, reward in community_results: + community_result: dict[str, object] = { + "account": account, + "game": community_name, + "platform": "塔吉多", + "status": status, + "reward": reward, + "reason": reason, + } + matched = [ + index + for index, item in enumerate(combined_results) + if item.get("game") == community_name + ] + if not matched: + # 角色查询异常时保留社区诊断,不能因无法关联角色而隐藏失败。 + combined_results.append(community_result) + continue + for index in matched: + combined_results[index] = merge_community_sign_result( + combined_results[index], + community_result, + include_reward=index == matched[0], + ) + results.extend(combined_results) elif refresh_error_reason is not None: results.extend( { @@ -718,7 +1018,7 @@ async def cancel_pending_tasks() -> None: } ) except asyncio.CancelledError: - await cancel_pending_tasks() + await cancel_cloud_task() raise except Exception as exc: reason = _log_taygedo_exception("云异环时长查询异常", exc) @@ -736,6 +1036,68 @@ async def cancel_pending_tasks() -> None: return results, credential +def _has_usable_taygedo_session(credential: Mapping[str, object]) -> bool: + """判断本次调用是否已有可直接使用的访问会话。""" + + return bool( + str(credential.get("accessToken") or "").strip() + and str(credential.get("uid") or "").strip() + ) + + +def _taygedo_reason_is_auth_failure(reason: str) -> bool: + """只识别明确的会话失效提示,避免普通业务失败触发刷新。""" + + text = str(reason or "").lower() + return any( + marker in text + for marker in ( + "http 401", + "http 402", + "http 403", + "code=401", + "code=402", + "code=4011", + "code=403", + "auth_expired", + "access token", + "access_token", + "业务码 4011", + "token失效", + "token 已失效", + "令牌失效", + "授权失效", + "登录失效", + "登录已过期", + "未登录", + "未授权", + "invalid token", + "unauthorized", + "invalid_token", + ) + ) + + +def _taygedo_actions_need_refresh( + community_results: list[tuple[str, str, str, str]], + game_results: list[dict[str, str]], +) -> bool: + """判断动作结果是否需要一次受控的 refreshToken 恢复。""" + + entries = [ + (status, reason) + for _name, status, reason, _reward in community_results + ] + entries.extend( + (str(result.get("status", "")), str(result.get("reason", ""))) + for result in game_results + ) + if not entries: + return False + # 社区与游戏请求并发执行;一项成功不能掩盖另一项已经明确失效的会话。 + return any(_taygedo_reason_is_auth_failure(reason) for _status, reason in entries) + + async def _sign_taygedo_games( access_token: str, uid: str, @@ -746,7 +1108,7 @@ async def _sign_taygedo_games( roles: list[dict[str, str]] | None = None, lookup_complete: bool | None = None, ) -> list[dict[str, str]]: - """遍历塔吉多绑定游戏并执行每日游戏签到。""" + """遍历塔吉多绑定游戏并执行每日游戏日常任务。""" if roles is None: roles, lookup_complete = await _get_taygedo_game_roles_with_status( @@ -760,9 +1122,11 @@ async def _sign_taygedo_games( assert lookup_complete is not None if not roles: if lookup_complete: - logger.info("塔吉多未绑定应用内游戏,跳过游戏签到") + logger.info("塔吉多未绑定应用内游戏,跳过游戏日常任务") return [] - logger.warning("塔吉多游戏角色接口未完成,应用内游戏签到跳过") + logger.warning( + "塔吉多游戏角色接口未完成,应用内游戏日常任务跳过" + ) return [ { "account": f"{account}/应用内游戏", @@ -770,7 +1134,7 @@ async def _sign_taygedo_games( "platform": "塔吉多", "status": "失败", "reward": "", - "reason": "游戏角色接口获取失败,无法确认应用内游戏签到", + "reason": "游戏角色接口获取失败,无法确认应用内游戏日常任务", } ] @@ -780,18 +1144,47 @@ async def sign_role( game_id = role["gameId"] role_id = role["roleId"] role_name = role.get("roleName") or role_id - game_name = role.get("gameName") or TAYGEDO_GAME_NAMES.get(game_id, game_id) + game_name = _taygedo_game_name(game_id, role.get("gameName")) # 角色卡是本次结果的权威名称,不能继续沿用旧凭据中的异环别名。 role_account = f"{role_name}/{role_name}({role_id})" try: - state = await _get_game_sign_state( - access_token, - game_id, - client=client, - proxy=proxy, + state_result, rewards_result = await asyncio.gather( + _get_game_sign_state( + access_token, + game_id, + client=client, + proxy=proxy, + ), + _get_game_sign_rewards( + access_token, + game_id, + client=client, + proxy=proxy, + ), + return_exceptions=True, ) + if isinstance(state_result, BaseException): + raise state_result + if not isinstance(state_result, dict): + raise ValueError("塔吉多游戏日常任务状态格式无效") + state = state_result + if isinstance(rewards_result, BaseException): + if isinstance(rewards_result, asyncio.CancelledError): + raise rewards_result + _log_taygedo_exception( + "塔吉多游戏日常任务奖励查询失败", + rewards_result, + ) + reward_data: dict[str, object] = {} + elif isinstance(rewards_result, dict): + reward_data = rewards_result + else: + reward_data = {} + + days = _to_optional_int(state.get("days")) if _is_game_signed(state): status = "已签到" + reward_day_index = days - 1 if days is not None else 0 else: status = await _submit_game_sign( access_token, @@ -800,16 +1193,20 @@ async def sign_role( client=client, proxy=proxy, ) + reward_day_index = days if days is not None else 0 return { "account": role_account, "game": game_name, "platform": "塔吉多", "status": status, - "reward": "", + "reward": _format_taygedo_rewards( + reward_data, + reward_day_index, + ), "reason": "", } except Exception as exc: - reason = _log_taygedo_exception("塔吉多应用内游戏签到异常", exc) + reason = _log_taygedo_exception("塔吉多应用内游戏日常任务异常", exc) return { "account": role_account, "game": game_name, @@ -832,7 +1229,7 @@ async def sign_role( "platform": "塔吉多", "status": "失败", "reward": "", - "reason": "部分游戏角色接口获取失败,无法确认全部应用内游戏签到", + "reason": "部分游戏角色接口获取失败,无法确认全部应用内游戏日常任务", } ) return results @@ -853,14 +1250,21 @@ async def _get_taygedo_game_roles_with_status( response = await client.get( GAME_RECORD_CARDS_URL, params={"uid": uid}, - headers=_native_headers(access_token, uid, device_id), + headers=_native_headers( + access_token, + uid, + device_id, + ), timeout=30.0, ) data = _read_json(response, "塔吉多游戏角色卡") raw_cards = data.get("data") - if not _is_code(data.get("code"), 0) or not _is_game_record_cards_payload( - raw_cards - ): + if not _is_code(data.get("code"), 0): + error = _api_error("塔吉多游戏角色卡", response, data) + if _taygedo_reason_is_auth_failure(str(error)): + raise error + raise ValueError("角色卡响应格式无效") + if not _is_game_record_cards_payload(raw_cards): raise ValueError("角色卡响应格式无效") roles = [ role @@ -871,6 +1275,9 @@ async def _get_taygedo_game_roles_with_status( return _deduplicate_game_roles(roles), True # 角色卡可能只返回未绑定的空卡,继续查询角色列表获取已绑定角色。 except Exception as exc: + # 角色卡的明确鉴权失败不能被静默降级,否则社区签到成功时不会触发恢复。 + if _taygedo_reason_is_auth_failure(str(exc)): + raise logger.debug(f"获取塔吉多游戏角色卡跳过: {type(exc).__name__}") roles: list[dict[str, str]] = [] @@ -883,11 +1290,19 @@ async def load_roles( response = await client.get( GAME_ROLES_URL, params={"gameId": game_id}, - headers=_native_headers(access_token, uid, device_id), + headers=_native_headers( + access_token, + uid, + device_id, + app_version=APP_VERSION, + ), timeout=30.0, ) data = _read_json(response, f"塔吉多角色({game_id})") if not _is_code(data.get("code"), 0): + error = _api_error(f"塔吉多角色({game_id})", response, data) + if _taygedo_reason_is_auth_failure(str(error)): + raise error return [], False # getGameRoles 的响应只返回 roleId/roleName,游戏归属由本次查询参数决定。 roles = [ @@ -902,10 +1317,13 @@ async def load_roles( ] return roles, True except Exception as exc: + # 保留明确的鉴权失败给上层的一次受控 refresh;普通单游戏失败继续部分回退。 + if _taygedo_reason_is_auth_failure(str(exc)): + raise logger.debug(f"获取塔吉多游戏角色 {game_id} 跳过: {type(exc).__name__}") return [], False - # 旧接口按请求参数返回角色;最终按角色 ID 去重,避免重复映射到多个游戏。 + # 旧接口按请求参数返回角色;最终按游戏 ID 和角色 ID 去重,避免重复映射。 role_batches = await asyncio.gather( *(load_roles(game_id) for game_id in TAYGEDO_GAME_IDS) ) @@ -917,7 +1335,7 @@ async def load_roles( return _deduplicate_game_roles(roles), not failed_game_ids -def _is_game_record_cards_payload(value: Any) -> bool: +def _is_game_record_cards_payload(value: object) -> bool: """判断角色卡响应是否是可解析的列表或包装对象。""" if isinstance(value, list): @@ -942,12 +1360,12 @@ def _is_game_record_cards_payload(value: Any) -> bool: def _extract_role_records( - value: Any, fallback_game_id: str = "" -) -> list[dict[str, Any]]: + value: object, fallback_game_id: str = "" +) -> list[dict[str, object]]: """兼容角色卡、roles、cards、list 和 bindRoleInfo 的返回结构。""" if isinstance(value, list): - records: list[dict[str, Any]] = [] + records: list[dict[str, object]] = [] for item in value: records.extend(_extract_role_records(item, fallback_game_id)) return records @@ -960,7 +1378,7 @@ def _extract_role_records( or value.get("gameID") or fallback_game_id ).strip() - records: list[dict[str, Any]] = [] + records: list[dict[str, object]] = [] bind_role = value.get("bindRoleInfo") if isinstance(bind_role, str): try: @@ -998,7 +1416,7 @@ def _extract_role_records( def _normalise_game_role( - raw_role: Mapping[str, Any], + raw_role: Mapping[str, object], fallback_game_id: str = "", *, expected_game_id: str | None = None, @@ -1033,37 +1451,136 @@ def _normalise_game_role( ) return { "gameId": game_id, - # 服务端偶尔会沿用上一个子社区的 gameName,gameId 才是角色归属依据。 - "gameName": TAYGEDO_GAME_NAMES.get( - game_id, - str(raw_role.get("gameName") or game_id).strip(), - ), + # 角色卡的 gameName 是已确认响应中的展示来源;缺失时才使用安全回退。 + "gameName": _taygedo_game_name(game_id, raw_role.get("gameName")), "roleId": role_id, "roleName": role_name, } +def _taygedo_game_name(game_id: str, reported_name: object = "") -> str: + """按已确认游戏 ID 返回稳定名称,未知 ID 才采用服务端名称。""" + + known_name = TAYGEDO_GAME_NAMES.get(game_id) + if known_name: + return known_name + name = str(reported_name or "").strip() + return name or f"游戏({game_id})" + + def _deduplicate_game_roles(roles: list[dict[str, str]]) -> list[dict[str, str]]: - # 角色 ID 在塔吉多账号下是全局唯一的,避免旧接口在多个 gameId 查询中 - # 重复返回同一角色时,把一个角色错误展示为多个游戏角色。 - seen: set[str] = set() + # 只在同一 gameId 内去重;不同游戏即使角色 ID 相同也必须分别展示。 + seen: set[tuple[str, str]] = set() unique: list[dict[str, str]] = [] for role in roles: + game_id = role.get("gameId", "") role_id = role.get("roleId", "") - if not role_id or role_id in seen: + key = (game_id, role_id) + if not role_id or key in seen: continue - seen.add(role_id) + seen.add(key) unique.append(role) return unique +def _reward_records(value: object) -> list[Mapping[str, object]]: + """读取塔吉多奖励接口已确认的列表字段。""" + + if isinstance(value, Mapping): + nested = value.get("data") + if nested is not value: + records = _reward_records(nested) + if records: + return records + for key in ("rewards", "rewardList", "list", "items"): + entries = value.get(key) + if isinstance(entries, list): + return [ + entry for entry in entries if isinstance(entry, Mapping) + ] + return [] + if isinstance(value, list): + return [entry for entry in value if isinstance(entry, Mapping)] + return [] + + +def _format_taygedo_rewards( + payload: Mapping[str, object], day_index: int +) -> str: + """按签到日格式化塔吉多应用内游戏奖励。""" + + rewards = _reward_records(payload) + if not rewards: + return "" + + day = max(1, day_index + 1) + dated = [ + reward + for reward in rewards + if any( + _to_optional_int(reward.get(key)) == day + for key in ("day", "days", "signDay") + ) + ] + selected = ( + dated + or ([rewards[day - 1]] if day <= len(rewards) else rewards) + ) + parts: list[str] = [] + for reward in selected: + name = next( + ( + str(reward.get(key)).strip() + for key in ("name", "rewardName", "goodsName", "itemName") + if reward.get(key) not in (None, "") + ), + "", + ) + if not name: + continue + raw_count = next( + ( + reward.get(key) + for key in ("num", "count", "quantity") + if reward.get(key) not in (None, "") + ), + None, + ) + count = ( + None + if isinstance(raw_count, bool) + else _to_optional_int(raw_count) + ) + count_text = str(count if count is not None else 1) + parts.append(name if count_text == "1" else f"{name}×{count_text}") + return "、".join(parts) + + +def _format_community_reward(value: object) -> str: + """格式化塔吉多社区签到返回的已确认奖励字段。""" + + if isinstance(value, Mapping) and isinstance(value.get("data"), Mapping): + value = value["data"] + if not isinstance(value, Mapping): + return "" + + parts: list[str] = [] + for key, label in (("exp", "经验"), ("goldCoin", "金币")): + raw_value = value.get(key) + if isinstance(raw_value, bool) or raw_value in (None, ""): + continue + if isinstance(raw_value, (int, float, str)): + parts.append(f"{label}{str(raw_value).strip()}") + return "、".join(parts) + + async def _get_game_sign_state( access_token: str, game_id: str, *, client: httpx.AsyncClient | None = None, proxy: str | None, -) -> dict[str, Any]: +) -> dict[str, object]: if client is None: async with httpx.AsyncClient(proxy=proxy, trust_env=False) as owned_client: return await _get_game_sign_state( @@ -1079,13 +1596,47 @@ async def _get_game_sign_state( headers=_h5_headers(access_token), timeout=30.0, ) - data = _read_json(response, f"塔吉多游戏签到状态({game_id})") + data = _read_json(response, f"塔吉多游戏日常任务状态({game_id})") if not _is_code(data.get("code"), 0) or not isinstance(data.get("data"), dict): - raise _api_error(f"塔吉多游戏签到状态({game_id})", response, data) + raise _api_error(f"塔吉多游戏日常任务状态({game_id})", response, data) return data["data"] -def _is_game_signed(state: Mapping[str, Any]) -> bool: +async def _get_game_sign_rewards( + access_token: str, + game_id: str, + *, + client: httpx.AsyncClient | None = None, + proxy: str | None, +) -> dict[str, object]: + """读取塔吉多应用内游戏签到奖励表。""" + + if client is None: + async with httpx.AsyncClient(proxy=proxy, trust_env=False) as owned_client: + return await _get_game_sign_rewards( + access_token, + game_id, + client=owned_client, + proxy=proxy, + ) + + response = await client.get( + GAME_SIGNIN_REWARDS_URL, + params={"gameId": game_id}, + headers=_h5_headers(access_token), + timeout=30.0, + ) + data = _read_json(response, f"塔吉多游戏日常任务奖励({game_id})") + payload = data.get("data") + if not _is_code(data.get("code"), 0) or not isinstance(payload, (dict, list)): + raise _api_error(f"塔吉多游戏日常任务奖励({game_id})", response, data) + # 已确认当前上游把整月奖励作为列表返回;统一包装为对象以复用解析层结构。 + if isinstance(payload, list): + return {"items": payload} + return payload + + +def _is_game_signed(state: Mapping[str, object]) -> bool: """读取不同版本接口返回的“今日已签到”字段。""" for key in ( @@ -1139,26 +1690,35 @@ async def _submit_game_sign( data={"gameId": game_id, "roleId": role_id}, timeout=30.0, ) - data = _read_json(response, f"塔吉多游戏签到({game_id})") + data = _read_json(response, f"塔吉多游戏日常任务({game_id})") message = str(data.get("msg") or data.get("message") or "").strip() if _is_code(data.get("code"), 0): return "成功" if str(data.get("code")) == "5052" or _is_already_signed(message): return "已签到" - raise _api_error(f"塔吉多游戏签到({game_id})", response, data) + raise _api_error(f"塔吉多游戏日常任务({game_id})", response, data) -def _native_headers(access_token: str, uid: str, device_id: str) -> dict[str, str]: - return { +def _native_headers( + access_token: str, + uid: str, + device_id: str, + *, + app_version: str = TAYGEDO_NATIVE_APP_VERSION, +) -> dict[str, str]: + headers = { "accept": "application/json, text/plain, */*", "authorization": access_token, "uid": uid, "deviceid": device_id, - "appversion": TAYGEDO_NATIVE_APP_VERSION, + "appversion": app_version, "platform": "android", - "ds": _make_login_ds(), "user-agent": APP_USER_AGENT, } + # 旧版角色列表不携带 DS;角色卡与社区打卡使用 1.2.5 原生签名。 + if app_version == TAYGEDO_NATIVE_APP_VERSION: + headers["ds"] = _make_login_ds() + return headers def _h5_headers(access_token: str) -> dict[str, str]: @@ -1254,19 +1814,15 @@ def _verify_cloud_duration( async def _attach_role_name( - credential: dict[str, Any], + credential: dict[str, object], *, proxy: str | None, -) -> dict[str, Any]: +) -> dict[str, object]: access_token = str(credential.get("accessToken") or "").strip() uid = str(credential.get("uid") or "").strip() if not access_token or not uid: return credential - # 角色查询失败或返回空列表时不能继续使用旧凭据中的游戏名。 - credential.pop("gameId", None) - credential.pop("roleName", None) - credential.pop("roleIds", None) device_id = str(credential.get("deviceId") or _stable_device_id(access_token)) roles, lookup_complete = await _get_taygedo_game_roles_with_status( access_token, @@ -1276,6 +1832,10 @@ async def _attach_role_name( ) credential["_gameRoles"] = roles credential["_gameRolesComplete"] = lookup_complete + # 角色查询失败或只返回部分结果时保留旧元数据,避免一次异常请求 + # 破坏下次调用仍可用的游戏映射。 + if not lookup_complete: + return credential if not roles: if lookup_complete: credential.pop("gameId", None) @@ -1283,6 +1843,9 @@ async def _attach_role_name( credential.pop("roleIds", None) return credential first = next((role for role in roles if role.get("roleName")), roles[0]) + credential.pop("gameId", None) + credential.pop("roleName", None) + credential.pop("roleIds", None) credential["gameId"] = first["gameId"] if first.get("roleName"): credential["roleName"] = first["roleName"] @@ -1291,14 +1854,30 @@ async def _attach_role_name( return credential +def _taygedo_community_ids_for_roles( + roles: list[dict[str, str]] | None, +) -> tuple[str, ...]: + """按已绑定游戏 ID 过滤需要签到的塔吉多社区。""" + + if not roles: + return () + game_ids = {str(role.get("gameId") or "") for role in roles} + return tuple( + community_id + for community_id in TAYGEDO_COMMUNITY_IDS + if TAYGEDO_COMMUNITY_GAME_IDS.get(community_id) in game_ids + ) + + async def _community_sign( access_token: str, uid: str, device_id: str, *, + community_ids: tuple[str, ...] = TAYGEDO_COMMUNITY_IDS, proxy: str | None, ) -> list[tuple[str, str, str, str]]: - """并发执行塔吉多应用社区和异环社区签到。""" + """并发执行塔吉多应用内签到(随已绑定游戏附带触发)。""" async with httpx.AsyncClient(proxy=proxy, trust_env=False) as client: @@ -1308,7 +1887,12 @@ async def sign_community(community_id: str) -> tuple[str, str, str, str]: response = await client.post( APP_SIGNIN_URL, headers={ - **_native_headers(access_token, uid, device_id), + **_native_headers( + access_token, + uid, + device_id, + app_version=TAYGEDO_NATIVE_APP_VERSION, + ), "content-type": "application/x-www-form-urlencoded", }, data={"communityId": community_id}, @@ -1316,11 +1900,11 @@ async def sign_community(community_id: str) -> tuple[str, str, str, str]: ) data = _read_json(response, f"{community_name}签到") message = str(data.get("msg") or data.get("message") or "").strip() + reward = _format_community_reward(data.get("data")) if _is_code(data.get("code"), 0): - # 社区签到接口会同时返回经验、塔塔币等社区奖励,签到通知不展示这些字段。 - return community_name, "成功", "", "" + return community_name, "成功", "", reward if _is_already_signed(message): - return community_name, "已签到", "", "" + return community_name, "已签到", "", reward return ( community_name, "失败", @@ -1338,13 +1922,13 @@ async def sign_community(community_id: str) -> tuple[str, str, str, str]: await asyncio.gather( *( sign_community(community_id) - for community_id in TAYGEDO_COMMUNITY_IDS + for community_id in community_ids ) ) ) -def _read_json(response: httpx.Response, endpoint: str) -> dict[str, Any]: +def _read_json(response: httpx.Response, endpoint: str) -> dict[str, object]: try: data = response.json() except (ValueError, json.JSONDecodeError) as exc: @@ -1357,10 +1941,11 @@ def _read_json(response: httpx.Response, endpoint: str) -> dict[str, Any]: def _api_error( - endpoint: str, response: httpx.Response, data: Mapping[str, Any] + endpoint: str, response: httpx.Response, data: Mapping[str, object] ) -> ValueError: message = str(data.get("msg") or data.get("message") or "请求失败").strip() - return ValueError(f"{endpoint}失败(HTTP {response.status_code}):{message}") + code = data.get("code", "unknown") + return ValueError(f"{endpoint}失败(HTTP {response.status_code},code={code}):{message}") def _is_already_signed(message: str) -> bool: @@ -1380,14 +1965,14 @@ def _format_duration(duration: Mapping[str, int | None]) -> str: return ",".join(parts) or "时长查询成功" -def _to_int(value: Any) -> int: +def _to_int(value: object) -> int: try: return int(value) except (TypeError, ValueError): return 0 -def _to_optional_int(value: Any) -> int | None: +def _to_optional_int(value: object) -> int | None: if value in (None, ""): return None try: diff --git a/app/utils/community.py b/app/utils/community.py new file mode 100644 index 000000000..486bde70a --- /dev/null +++ b/app/utils/community.py @@ -0,0 +1,48 @@ +# 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 + + +"""游戏社区账号组的纯逻辑辅助函数。""" + +import re +from collections.abc import Iterable + + +_DEFAULT_ACCOUNT_NAME_PATTERN = re.compile(r"^用户\s*(\d+)$") + + +def next_community_account_name(existing_names: Iterable[object]) -> str: + """返回首个未占用的默认账号组名称。 + + 历史版本同时出现过 ``用户1`` 和 ``用户 1``,两种写法视为同一编号。 + 自定义名称不参与默认编号占用。 + """ + + occupied: set[int] = set() + for value in existing_names: + match = _DEFAULT_ACCOUNT_NAME_PATTERN.fullmatch(str(value or "").strip()) + if match is not None: + occupied.add(int(match.group(1))) + + index = 1 + while index in occupied: + index += 1 + return f"用户 {index}" diff --git a/frontend/package.json b/frontend/package.json index 7ae5e029c..ba3e07b53 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -59,7 +59,8 @@ "from": "src/assets", "to": "assets", "filter": [ - "**/*" + "**/*", + "!community-notes/*-background.png" ] } ], diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 7670b2567..fe30f1685 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -31,6 +31,11 @@ export type { ClickOut } from './models/ClickOut'; export type { ClickTextIn } from './models/ClickTextIn'; export type { ComboBoxItem } from './models/ComboBoxItem'; export type { ComboBoxOut } from './models/ComboBoxOut'; +export type { CommunityActivityOut } from './models/CommunityActivityOut'; +export type { CommunityActivityQueryIn } from './models/CommunityActivityQueryIn'; +export type { CommunityActivityResourceOut } from './models/CommunityActivityResourceOut'; +export { CommunityActivitySnapshotOut } from './models/CommunityActivitySnapshotOut'; +export type { CommunityActivityTaskOut } from './models/CommunityActivityTaskOut'; export type { DeviceInfo } from './models/DeviceInfo'; export type { DispatchIn } from './models/DispatchIn'; export type { Emulator2AffectedScript } from './models/Emulator2AffectedScript'; @@ -76,9 +81,11 @@ export type { EmulatorSearchResult } from './models/EmulatorSearchResult'; export type { EmulatorStatusOut } from './models/EmulatorStatusOut'; export type { EmulatorUpdateIn } from './models/EmulatorUpdateIn'; export type { GameSignAccountCreateOut } from './models/GameSignAccountCreateOut'; +export type { GameSignAccountDataOut } from './models/GameSignAccountDataOut'; export type { GameSignAccountDeleteIn } from './models/GameSignAccountDeleteIn'; export type { GameSignAccountGetIn } from './models/GameSignAccountGetIn'; export type { GameSignAccountGroupConfig } from './models/GameSignAccountGroupConfig'; +export type { GameSignAccountInstanceOut } from './models/GameSignAccountInstanceOut'; export type { GameSignAccountReorderIn } from './models/GameSignAccountReorderIn'; export type { GameSignAccountsListOut } from './models/GameSignAccountsListOut'; export type { GameSignAccountUpdateIn } from './models/GameSignAccountUpdateIn'; @@ -308,7 +315,10 @@ export type { ScriptUploadIn } from './models/ScriptUploadIn'; export type { ScriptUrlIn } from './models/ScriptUrlIn'; export type { SettingGetOut } from './models/SettingGetOut'; export type { SettingUpdateIn } from './models/SettingUpdateIn'; -export type { SklandLoginIn } from './models/SklandLoginIn'; +export type { SklandQrCheckIn } from './models/SklandQrCheckIn'; +export type { SklandQrCheckOut } from './models/SklandQrCheckOut'; +export type { SklandQrCreateOut } from './models/SklandQrCreateOut'; +export type { SklandQrSaveIn } from './models/SklandQrSaveIn'; export type { SrcConfig } from './models/SrcConfig'; export type { SrcConfig_Emulator } from './models/SrcConfig_Emulator'; export type { SrcConfig_Info } from './models/SrcConfig_Info'; @@ -426,6 +436,7 @@ export { ActionService } from './services/ActionService'; export { AddService } from './services/AddService'; export { BetterGiService } from './services/BetterGiService'; export { ClawService } from './services/ClawService'; +export { CommunityService } from './services/CommunityService'; export { DeleteService } from './services/DeleteService'; export { Emulator20Service } from './services/Emulator20Service'; export { GameSignService } from './services/GameSignService'; diff --git a/frontend/src/api/models/CommunityActivityOut.ts b/frontend/src/api/models/CommunityActivityOut.ts new file mode 100644 index 000000000..c92c38ea7 --- /dev/null +++ b/frontend/src/api/models/CommunityActivityOut.ts @@ -0,0 +1,27 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CommunityActivitySnapshotOut } from './CommunityActivitySnapshotOut'; +/** + * 游戏社区日常活动查询响应。 + */ +export type CommunityActivityOut = { + /** + * 状态码 + */ + code?: number; + /** + * 操作状态 + */ + status?: string; + /** + * 操作消息 + */ + message?: string; + /** + * 按账号和游戏拆分的活动快照 + */ + data?: Array; +}; + diff --git a/frontend/src/api/models/CommunityActivityQueryIn.ts b/frontend/src/api/models/CommunityActivityQueryIn.ts new file mode 100644 index 000000000..aba25a2ab --- /dev/null +++ b/frontend/src/api/models/CommunityActivityQueryIn.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 游戏社区日常查询请求。 + */ +export type CommunityActivityQueryIn = { + /** + * 指定账号组 UUID 列表;为空时查询全部已配置账号组 + */ + accountIds?: (Array | null); +}; + diff --git a/frontend/src/api/models/CommunityActivityResourceOut.ts b/frontend/src/api/models/CommunityActivityResourceOut.ts new file mode 100644 index 000000000..928e4bfb7 --- /dev/null +++ b/frontend/src/api/models/CommunityActivityResourceOut.ts @@ -0,0 +1,26 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 日常活动中的可用资源。 + */ +export type CommunityActivityResourceOut = { + /** + * 资源名称 + */ + name: string; + /** + * 当前数量 + */ + current: number; + /** + * 容量上限 + */ + target: number; + /** + * 资源状态 + */ + status: string; +}; + diff --git a/frontend/src/api/models/CommunityActivitySnapshotOut.ts b/frontend/src/api/models/CommunityActivitySnapshotOut.ts new file mode 100644 index 000000000..7c0b2c867 --- /dev/null +++ b/frontend/src/api/models/CommunityActivitySnapshotOut.ts @@ -0,0 +1,84 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CommunityActivityResourceOut } from './CommunityActivityResourceOut'; +import type { CommunityActivityTaskOut } from './CommunityActivityTaskOut'; +/** + * 单个游戏角色的日常活动快照。 + */ +export type CommunityActivitySnapshotOut = { + /** + * 账号组名称 + */ + account: string; + /** + * 账号组 UUID + */ + accountUid: string; + /** + * 游戏名称 + */ + game: string; + /** + * 社区平台名称 + */ + platform: string; + /** + * 活动查询状态 + */ + status: CommunityActivitySnapshotOut.status; + /** + * 已完成数量 + */ + completed?: (number | null); + /** + * 目标数量 + */ + target?: (number | null); + /** + * 每日任务 + */ + tasks?: Array; + /** + * 可用资源 + */ + resources?: Array; + /** + * 失败或受限原因 + */ + reason?: string; + /** + * 查询时间 + */ + updatedAt?: string; + /** + * 角色名称 + */ + roleName?: string; + /** + * 角色 UID + */ + roleUid?: string; + /** + * 角色区服 + */ + server?: string; + /** + * 已确认的数据来源路径 + */ + source?: string; +}; +export namespace CommunityActivitySnapshotOut { + /** + * 活动查询状态 + */ + export enum status { + SUCCESS = 'success', + EMPTY = 'empty', + LIMITED = 'limited', + UNAVAILABLE = 'unavailable', + FAILED = 'failed', + } +} + diff --git a/frontend/src/api/models/CommunityActivityTaskOut.ts b/frontend/src/api/models/CommunityActivityTaskOut.ts new file mode 100644 index 000000000..b28cb4e6c --- /dev/null +++ b/frontend/src/api/models/CommunityActivityTaskOut.ts @@ -0,0 +1,30 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 日常活动中的单项任务。 + */ +export type CommunityActivityTaskOut = { + /** + * 任务名称 + */ + name: string; + /** + * 已完成数量 + */ + completed: number; + /** + * 目标数量 + */ + target: number; + /** + * 任务状态 + */ + status: string; + /** + * 任务周期 + */ + period?: string; +}; + diff --git a/frontend/src/api/models/GameSignAccountCreateOut.ts b/frontend/src/api/models/GameSignAccountCreateOut.ts index f00f4792e..f905a9ff9 100644 --- a/frontend/src/api/models/GameSignAccountCreateOut.ts +++ b/frontend/src/api/models/GameSignAccountCreateOut.ts @@ -4,7 +4,7 @@ /* eslint-disable */ import type { GameSignAccountGroupConfig } from './GameSignAccountGroupConfig'; /** - * 游戏签到账号组创建响应 + * 游戏社区账号组创建响应 */ export type GameSignAccountCreateOut = { /** diff --git a/frontend/src/api/models/GameSignAccountDataOut.ts b/frontend/src/api/models/GameSignAccountDataOut.ts new file mode 100644 index 000000000..4bb9e2404 --- /dev/null +++ b/frontend/src/api/models/GameSignAccountDataOut.ts @@ -0,0 +1,15 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { GameSignAccountGroupConfig } from './GameSignAccountGroupConfig'; +/** + * 动态 UUID 键对应的游戏社区账号组数据。 + */ +export type GameSignAccountDataOut = { + /** + * 账号组配置 + */ + GameSignAccount: GameSignAccountGroupConfig; +}; + diff --git a/frontend/src/api/models/GameSignAccountDeleteIn.ts b/frontend/src/api/models/GameSignAccountDeleteIn.ts index 961bf88da..21659e195 100644 --- a/frontend/src/api/models/GameSignAccountDeleteIn.ts +++ b/frontend/src/api/models/GameSignAccountDeleteIn.ts @@ -3,7 +3,7 @@ /* tslint:disable */ /* eslint-disable */ /** - * 游戏签到账号组删除请求 + * 游戏社区账号组删除请求 */ export type GameSignAccountDeleteIn = { /** diff --git a/frontend/src/api/models/GameSignAccountGetIn.ts b/frontend/src/api/models/GameSignAccountGetIn.ts index cd87defe9..1d9ec54ee 100644 --- a/frontend/src/api/models/GameSignAccountGetIn.ts +++ b/frontend/src/api/models/GameSignAccountGetIn.ts @@ -3,7 +3,7 @@ /* tslint:disable */ /* eslint-disable */ /** - * 游戏签到账号组查询请求 + * 游戏社区账号组查询请求 */ export type GameSignAccountGetIn = { /** diff --git a/frontend/src/api/models/GameSignAccountGroupConfig.ts b/frontend/src/api/models/GameSignAccountGroupConfig.ts index da20083b4..e5dbf7343 100644 --- a/frontend/src/api/models/GameSignAccountGroupConfig.ts +++ b/frontend/src/api/models/GameSignAccountGroupConfig.ts @@ -3,7 +3,7 @@ /* tslint:disable */ /* eslint-disable */ /** - * 游戏签到账号组配置 + * 游戏社区账号组配置 */ export type GameSignAccountGroupConfig = { /** @@ -18,6 +18,18 @@ export type GameSignAccountGroupConfig = { * 米游社登录凭证 */ MiyousheToken?: (string | null); + /** + * 米游社安卓设备 ID,仅用于绝区零便笺 + */ + MiyousheDeviceId?: (string | null); + /** + * 米游社安卓设备指纹,仅用于绝区零便笺 + */ + MiyousheDeviceFp?: (string | null); + /** + * 云原神 combo token + */ + CloudGenshinToken?: (string | null); /** * 库街区登录凭证 */ @@ -30,5 +42,9 @@ export type GameSignAccountGroupConfig = { * 塔吉多及云异环登录凭证 */ TaygedoToken?: (string | null); + /** + * 账号组上次签到日期 + */ + LastSignDate?: (string | null); }; diff --git a/frontend/src/api/models/GameSignAccountInstanceOut.ts b/frontend/src/api/models/GameSignAccountInstanceOut.ts new file mode 100644 index 000000000..5ae72036d --- /dev/null +++ b/frontend/src/api/models/GameSignAccountInstanceOut.ts @@ -0,0 +1,18 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * 游戏社区账号组顺序项。 + */ +export type GameSignAccountInstanceOut = { + /** + * 账号组 UUID + */ + uid: string; + /** + * 账号组配置类型 + */ + type: string; +}; + diff --git a/frontend/src/api/models/GameSignAccountReorderIn.ts b/frontend/src/api/models/GameSignAccountReorderIn.ts index d27631d54..c4303e1e3 100644 --- a/frontend/src/api/models/GameSignAccountReorderIn.ts +++ b/frontend/src/api/models/GameSignAccountReorderIn.ts @@ -3,7 +3,7 @@ /* tslint:disable */ /* eslint-disable */ /** - * 游戏签到账号组排序请求 + * 游戏社区账号组排序请求 */ export type GameSignAccountReorderIn = { /** diff --git a/frontend/src/api/models/GameSignAccountUpdateIn.ts b/frontend/src/api/models/GameSignAccountUpdateIn.ts index 2d801a763..cb3a74ec3 100644 --- a/frontend/src/api/models/GameSignAccountUpdateIn.ts +++ b/frontend/src/api/models/GameSignAccountUpdateIn.ts @@ -4,7 +4,7 @@ /* eslint-disable */ import type { GameSignAccountGroupConfig } from './GameSignAccountGroupConfig'; /** - * 游戏签到账号组更新请求 + * 游戏社区账号组更新请求 */ export type GameSignAccountUpdateIn = { /** diff --git a/frontend/src/api/models/GameSignAccountsListOut.ts b/frontend/src/api/models/GameSignAccountsListOut.ts index cbb977bde..dafd9026c 100644 --- a/frontend/src/api/models/GameSignAccountsListOut.ts +++ b/frontend/src/api/models/GameSignAccountsListOut.ts @@ -2,8 +2,10 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { GameSignAccountDataOut } from './GameSignAccountDataOut'; +import type { GameSignAccountInstanceOut } from './GameSignAccountInstanceOut'; /** - * 游戏签到账号组列表响应 + * 游戏社区账号组列表响应 */ export type GameSignAccountsListOut = { /** @@ -21,6 +23,6 @@ export type GameSignAccountsListOut = { /** * 账号组列表 */ - data?: Record; + data?: Record | GameSignAccountDataOut)>; }; diff --git a/frontend/src/api/models/SklandLoginIn.ts b/frontend/src/api/models/SklandLoginIn.ts deleted file mode 100644 index 32794213a..000000000 --- a/frontend/src/api/models/SklandLoginIn.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -/** - * 森空岛一次性手机号密码登录请求。 - */ -export type SklandLoginIn = { - /** - * 账号组 UUID - */ - accountId: string; - /** - * 鹰角网络通行证手机号 - */ - phone: string; - /** - * 鹰角网络通行证密码 - */ - password: string; -}; - diff --git a/frontend/src/api/models/SklandQrCheckIn.ts b/frontend/src/api/models/SklandQrCheckIn.ts new file mode 100644 index 000000000..97e2b3675 --- /dev/null +++ b/frontend/src/api/models/SklandQrCheckIn.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type SklandQrCheckIn = { + ticket: string; + device?: string; +}; + diff --git a/frontend/src/api/models/SklandQrCheckOut.ts b/frontend/src/api/models/SklandQrCheckOut.ts new file mode 100644 index 000000000..e9cfdf157 --- /dev/null +++ b/frontend/src/api/models/SklandQrCheckOut.ts @@ -0,0 +1,23 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type SklandQrCheckOut = { + /** + * 状态码 + */ + code?: number; + /** + * Init/Scanned/Confirmed/Expired/Canceled/Error + */ + status?: string; + /** + * 操作消息 + */ + message?: string; + /** + * 确认后返回的短时 scanCode + */ + scan_code?: string; +}; + diff --git a/frontend/src/api/models/SklandQrCreateOut.ts b/frontend/src/api/models/SklandQrCreateOut.ts new file mode 100644 index 000000000..cd52fe646 --- /dev/null +++ b/frontend/src/api/models/SklandQrCreateOut.ts @@ -0,0 +1,31 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type SklandQrCreateOut = { + /** + * 状态码 + */ + code?: number; + /** + * 操作状态 + */ + status?: string; + /** + * 操作消息 + */ + message?: string; + /** + * 森空岛扫码 ticket/scanId + */ + ticket?: string; + /** + * 用于生成二维码的跳转链接 + */ + qr_url?: string; + /** + * 本地设备 ID + */ + device?: string; +}; + diff --git a/frontend/src/api/models/SklandQrSaveIn.ts b/frontend/src/api/models/SklandQrSaveIn.ts new file mode 100644 index 000000000..b872f687d --- /dev/null +++ b/frontend/src/api/models/SklandQrSaveIn.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type SklandQrSaveIn = { + /** + * MAS 账号组 UUID + */ + account_uid: string; + scan_code: string; +}; + diff --git a/frontend/src/api/models/ToolsConfig_GameSign.ts b/frontend/src/api/models/ToolsConfig_GameSign.ts index 45c8d258b..1cd46ae95 100644 --- a/frontend/src/api/models/ToolsConfig_GameSign.ts +++ b/frontend/src/api/models/ToolsConfig_GameSign.ts @@ -4,13 +4,17 @@ /* eslint-disable */ export type ToolsConfig_GameSign = { /** - * 是否启用游戏签到 + * 是否启用游戏社区 */ Enabled?: (boolean | null); /** * 签到后是否发送通知 */ NotifyEnabled?: (boolean | null); + /** + * 是否启用日常便笺 + */ + ActivityEnabled?: (boolean | null); /** * 签到窗口起点 HH:mm */ diff --git a/frontend/src/api/services/CommunityService.ts b/frontend/src/api/services/CommunityService.ts new file mode 100644 index 000000000..f19d9ea25 --- /dev/null +++ b/frontend/src/api/services/CommunityService.ts @@ -0,0 +1,31 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CommunityActivityOut } from '../models/CommunityActivityOut'; +import type { CommunityActivityQueryIn } from '../models/CommunityActivityQueryIn'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class CommunityService { + /** + * 查询游戏社区日常活跃度 + * 查询游戏社区日常活动,不占用签到流程锁。 + * @param requestBody + * @returns CommunityActivityOut Successful Response + * @throws ApiError + */ + public static queryCommunityActivityApiToolsCommunityActivityQueryPost( + requestBody: CommunityActivityQueryIn, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/tools/community/activity/query', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } +} diff --git a/frontend/src/api/services/GameSignService.ts b/frontend/src/api/services/GameSignService.ts index d7209b41f..f21047590 100644 --- a/frontend/src/api/services/GameSignService.ts +++ b/frontend/src/api/services/GameSignService.ts @@ -9,15 +9,14 @@ import type { GameSignAccountReorderIn } from '../models/GameSignAccountReorderI import type { GameSignAccountsListOut } from '../models/GameSignAccountsListOut'; import type { GameSignAccountUpdateIn } from '../models/GameSignAccountUpdateIn'; import type { OutBase } from '../models/OutBase'; -import type { SklandLoginIn } from '../models/SklandLoginIn'; import type { TaygedoLoginIn } from '../models/TaygedoLoginIn'; import type { CancelablePromise } from '../core/CancelablePromise'; import { OpenAPI } from '../core/OpenAPI'; import { request as __request } from '../core/request'; export class GameSignService { /** - * 获取所有游戏签到账号组 - * 获取所有游戏签到账号组 + * 获取所有游戏社区账号组 + * 获取所有游戏社区账号组 * @returns GameSignAccountsListOut Successful Response * @throws ApiError */ @@ -28,8 +27,8 @@ export class GameSignService { }); } /** - * 添加游戏签到账号组 - * 添加游戏签到账号组 + * 添加游戏社区账号组 + * 添加游戏社区账号组 * @returns GameSignAccountCreateOut Successful Response * @throws ApiError */ @@ -40,8 +39,8 @@ export class GameSignService { }); } /** - * 获取游戏签到账号组详情 - * 获取游戏签到账号组详情 + * 获取游戏社区账号组详情 + * 获取游戏社区账号组详情 * @param requestBody * @returns GameSignAccountCreateOut Successful Response * @throws ApiError @@ -60,8 +59,8 @@ export class GameSignService { }); } /** - * 更新游戏签到账号组配置 - * 更新游戏签到账号组配置 + * 更新游戏社区账号组配置 + * 更新游戏社区账号组配置 * @param requestBody * @returns OutBase Successful Response * @throws ApiError @@ -80,8 +79,8 @@ export class GameSignService { }); } /** - * 删除游戏签到账号组 - * 删除游戏签到账号组 + * 删除游戏社区账号组 + * 删除游戏社区账号组 * @param requestBody * @returns OutBase Successful Response * @throws ApiError @@ -100,8 +99,8 @@ export class GameSignService { }); } /** - * 调整游戏签到账号组顺序 - * 调整游戏签到账号组顺序 + * 调整游戏社区账号组顺序 + * 调整游戏社区账号组顺序 * @param requestBody * @returns OutBase Successful Response * @throws ApiError @@ -139,24 +138,4 @@ export class GameSignService { }, }); } - /** - * 森空岛手机号密码登录 - * 一次性使用手机号和密码换取并保存森空岛凭据,不保存密码。 - * @param requestBody - * @returns OutBase Successful Response - * @throws ApiError - */ - public static loginSklandApiToolsSignAccountSklandLoginPost( - requestBody: SklandLoginIn, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/api/tools/sign/account/skland/login', - body: requestBody, - mediaType: 'application/json', - errors: { - 422: `Validation Error`, - }, - }); - } } diff --git a/frontend/src/api/services/Service.ts b/frontend/src/api/services/Service.ts index 591c7dcfc..74fd16d94 100644 --- a/frontend/src/api/services/Service.ts +++ b/frontend/src/api/services/Service.ts @@ -7,6 +7,8 @@ import type { BetterGICustomGroupsOut } from '../models/BetterGICustomGroupsOut' import type { Body_batch_update_oknte_configs_api_scripts_oknte_configs_batch_update_post } from '../models/Body_batch_update_oknte_configs_api_scripts_oknte_configs_batch_update_post'; import type { Body_update_oknte_config_api_scripts_oknte_configs_update_post } from '../models/Body_update_oknte_config_api_scripts_oknte_configs_update_post'; import type { ComboBoxOut } from '../models/ComboBoxOut'; +import type { CommunityActivityOut } from '../models/CommunityActivityOut'; +import type { CommunityActivityQueryIn } from '../models/CommunityActivityQueryIn'; import type { DispatchIn } from '../models/DispatchIn'; import type { EmulatorCreateOut } from '../models/EmulatorCreateOut'; import type { EmulatorDeleteIn } from '../models/EmulatorDeleteIn'; @@ -87,7 +89,10 @@ import type { ScriptUploadIn } from '../models/ScriptUploadIn'; import type { ScriptUrlIn } from '../models/ScriptUrlIn'; import type { SettingGetOut } from '../models/SettingGetOut'; import type { SettingUpdateIn } from '../models/SettingUpdateIn'; -import type { SklandLoginIn } from '../models/SklandLoginIn'; +import type { SklandQrCheckIn } from '../models/SklandQrCheckIn'; +import type { SklandQrCheckOut } from '../models/SklandQrCheckOut'; +import type { SklandQrCreateOut } from '../models/SklandQrCreateOut'; +import type { SklandQrSaveIn } from '../models/SklandQrSaveIn'; import type { TaskCreateIn } from '../models/TaskCreateIn'; import type { TaskCreateOut } from '../models/TaskCreateOut'; import type { TaskRuntimeSnapshot } from '../models/TaskRuntimeSnapshot'; @@ -2406,8 +2411,28 @@ export class Service { }); } /** - * 获取所有游戏签到账号组 - * 获取所有游戏签到账号组 + * 查询游戏社区日常活跃度 + * 查询游戏社区日常活动,不占用签到流程锁。 + * @param requestBody + * @returns CommunityActivityOut Successful Response + * @throws ApiError + */ + public static queryCommunityActivityApiToolsCommunityActivityQueryPost( + requestBody: CommunityActivityQueryIn, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/tools/community/activity/query', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * 获取所有游戏社区账号组 + * 获取所有游戏社区账号组 * @returns GameSignAccountsListOut Successful Response * @throws ApiError */ @@ -2418,8 +2443,8 @@ export class Service { }); } /** - * 添加游戏签到账号组 - * 添加游戏签到账号组 + * 添加游戏社区账号组 + * 添加游戏社区账号组 * @returns GameSignAccountCreateOut Successful Response * @throws ApiError */ @@ -2430,8 +2455,8 @@ export class Service { }); } /** - * 获取游戏签到账号组详情 - * 获取游戏签到账号组详情 + * 获取游戏社区账号组详情 + * 获取游戏社区账号组详情 * @param requestBody * @returns GameSignAccountCreateOut Successful Response * @throws ApiError @@ -2450,8 +2475,8 @@ export class Service { }); } /** - * 更新游戏签到账号组配置 - * 更新游戏签到账号组配置 + * 更新游戏社区账号组配置 + * 更新游戏社区账号组配置 * @param requestBody * @returns OutBase Successful Response * @throws ApiError @@ -2470,8 +2495,8 @@ export class Service { }); } /** - * 删除游戏签到账号组 - * 删除游戏签到账号组 + * 删除游戏社区账号组 + * 删除游戏社区账号组 * @param requestBody * @returns OutBase Successful Response * @throws ApiError @@ -2490,8 +2515,8 @@ export class Service { }); } /** - * 调整游戏签到账号组顺序 - * 调整游戏签到账号组顺序 + * 调整游戏社区账号组顺序 + * 调整游戏社区账号组顺序 * @param requestBody * @returns OutBase Successful Response * @throws ApiError @@ -2529,26 +2554,6 @@ export class Service { }, }); } - /** - * 森空岛手机号密码登录 - * 一次性使用手机号和密码换取并保存森空岛凭据,不保存密码。 - * @param requestBody - * @returns OutBase Successful Response - * @throws ApiError - */ - public static loginSklandApiToolsSignAccountSklandLoginPost( - requestBody: SklandLoginIn, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/api/tools/sign/account/skland/login', - body: requestBody, - mediaType: 'application/json', - errors: { - 422: `Validation Error`, - }, - }); - } /** * 导出数据备份 * 导出数据、配置与历史记录。 @@ -2863,7 +2868,7 @@ export class Service { } /** * 轮询扫码状态 - * 轮询状态,确认后返回从 Passport 响应提取的 cookies。 + * 轮询状态,确认后返回 Passport App 或 Web 链路的完整 cookies。 * @param requestBody * @returns QrCheckOut Successful Response * @throws ApiError @@ -2900,4 +2905,54 @@ export class Service { }, }); } + /** + * 创建二维码 + * @returns SklandQrCreateOut Successful Response + * @throws ApiError + */ + public static qrCreateApiToolsSignSklandQrCreatePost(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/tools/sign/skland/qr/create', + }); + } + /** + * 轮询扫码状态 + * 确认后返回短时 scanCode,由前端随后提交保存。 + * @param requestBody + * @returns SklandQrCheckOut Successful Response + * @throws ApiError + */ + public static qrCheckApiToolsSignSklandQrCheckPost( + requestBody: SklandQrCheckIn, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/tools/sign/skland/qr/check', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * 保存森空岛 Token + * @param requestBody + * @returns OutBase Successful Response + * @throws ApiError + */ + public static qrSaveApiToolsSignSklandQrSavePost( + requestBody: SklandQrSaveIn, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/tools/sign/skland/qr/save', + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } } diff --git a/frontend/src/assets/community-notes/ATTRIBUTION.md b/frontend/src/assets/community-notes/ATTRIBUTION.md new file mode 100644 index 000000000..1df949d2d --- /dev/null +++ b/frontend/src/assets/community-notes/ATTRIBUTION.md @@ -0,0 +1,76 @@ +# Game community note assets + +The five game identity assets are local copies from the fixed reference +revisions below. They are used only in the daily-note cards. + +- `arknights.png`: `gxy12345/arknights-plugin` + (`3b6683cfe72a8379c8b3e4659c932cef572d7dae`), + `resources/common/icon/amiya.png`, MIT, Copyright (c) 2023 gxy12345. +- `zenless.png` (retained original stamina asset, no longer used as the game + identity): `ZZZure/ZZZ-Plugin` + (`a8a7037be8a5810624b6cf17a506512b65128ea4`), + `resources/note/images/IconStamina.png`, GNU AGPL-3.0. +- `endfield.jpg`, `genshin.jpg`, `star-rail.jpg`, and `zenless.jpg`: + `yoshino-xiao7/A-game_checkin` + (`f8611120b433ece71fbce160a0950f5690386559`), + `resources/reward/icons/game/{endfield,genshin,star-rail,zenless}.jpg`, + MIT as declared by `package.json`. + +The selected Endfield and Miyoushe-specific fixed revisions do not contain a +redistributable bitmap asset with a confirmed license, so the MIT game-icon +assets above are used for those fallback cards. Protocol and rendering +behavior were separately reviewed against the game-specific reference +projects recorded in the development report. The source project names, +revisions, paths, and license information are preserved here; no reference +repository is bundled into the application. + +The following card background files were supplied by the requester for this +change on 2026-09-04. Their original source and redistribution license were not +included with the files. Copyright remains with the respective rights holders; +redistribution permission must be confirmed before a public release. + +On 2026-09-07 the requester identified these backgrounds as public materials +from the respective game Wikis and selected the AUTO-MAS documentation site +for distribution. Specific Wiki page URLs and image license records have not +yet been provided; public accessibility alone does not establish a license. +The original PNGs and their provenance remain here for traceability, but are +excluded from the application build. WebP derivatives are maintained in +`AUTO-MAS-docs/public/community-notes/`, together with their source +notice and conversion manifest, and served at +`https://doc.auto-mas.top/community-notes/` after the docs are published. + +- `arknights-background.png`: SHA-256 + `22AA487DA66082F0B932D691EA8073238752477A684751F04363211F6B620AF7`. +- `endfield-background.png`: SHA-256 + `C2CF843682A82B20890BF5228D9A68E5965B4DAE3C36CD951B8988FA1737B01C`. +- `genshin-background.png`: SHA-256 + `322F83D8731C2744F1FDBFA4DE9AFF8AE973C76F22C724D7D83877C73B9C3B48`. +- `star-rail-background.png`: SHA-256 + `4D6766536FC768657047218BBE885C78BD2BADEF551F8E4B44B0E9B613B6C3A7`. +- `zenless-background.png`: SHA-256 + `3A7A6CDBB24307A77D02D6EF5A27DB1A9E379A71A39FC6BA35E1AA4EE6137658`. + +The following notice is copied from `arknights-plugin` and applies to +`arknights.png`. The pinned `A-game_checkin` revision declares MIT in +`package.json` but does not include a separate license file or copyright +notice; its repository, revision, and asset paths are preserved above. + +Copyright (c) 2023 gxy12345 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/frontend/src/assets/community-notes/arknights-background.png b/frontend/src/assets/community-notes/arknights-background.png new file mode 100644 index 000000000..4c936be37 Binary files /dev/null and b/frontend/src/assets/community-notes/arknights-background.png differ diff --git a/frontend/src/assets/community-notes/arknights.png b/frontend/src/assets/community-notes/arknights.png new file mode 100644 index 000000000..34da57d26 Binary files /dev/null and b/frontend/src/assets/community-notes/arknights.png differ diff --git a/frontend/src/assets/community-notes/endfield-background.png b/frontend/src/assets/community-notes/endfield-background.png new file mode 100644 index 000000000..bb4bea2df Binary files /dev/null and b/frontend/src/assets/community-notes/endfield-background.png differ diff --git a/frontend/src/assets/community-notes/endfield.jpg b/frontend/src/assets/community-notes/endfield.jpg new file mode 100644 index 000000000..5120fef8f Binary files /dev/null and b/frontend/src/assets/community-notes/endfield.jpg differ diff --git a/frontend/src/assets/community-notes/genshin-background.png b/frontend/src/assets/community-notes/genshin-background.png new file mode 100644 index 000000000..d61275222 Binary files /dev/null and b/frontend/src/assets/community-notes/genshin-background.png differ diff --git a/frontend/src/assets/community-notes/genshin.jpg b/frontend/src/assets/community-notes/genshin.jpg new file mode 100644 index 000000000..3718a7e25 Binary files /dev/null and b/frontend/src/assets/community-notes/genshin.jpg differ diff --git a/frontend/src/assets/community-notes/star-rail-background.png b/frontend/src/assets/community-notes/star-rail-background.png new file mode 100644 index 000000000..e09c7627d Binary files /dev/null and b/frontend/src/assets/community-notes/star-rail-background.png differ diff --git a/frontend/src/assets/community-notes/star-rail.jpg b/frontend/src/assets/community-notes/star-rail.jpg new file mode 100644 index 000000000..42b732749 Binary files /dev/null and b/frontend/src/assets/community-notes/star-rail.jpg differ diff --git a/frontend/src/assets/community-notes/zenless-background.png b/frontend/src/assets/community-notes/zenless-background.png new file mode 100644 index 000000000..8051026a7 Binary files /dev/null and b/frontend/src/assets/community-notes/zenless-background.png differ diff --git a/frontend/src/assets/community-notes/zenless.jpg b/frontend/src/assets/community-notes/zenless.jpg new file mode 100644 index 000000000..b8c0569b9 Binary files /dev/null and b/frontend/src/assets/community-notes/zenless.jpg differ diff --git a/frontend/src/assets/community-notes/zenless.png b/frontend/src/assets/community-notes/zenless.png new file mode 100644 index 000000000..fff8259f2 Binary files /dev/null and b/frontend/src/assets/community-notes/zenless.png differ diff --git a/frontend/src/components/AppLayout.vue b/frontend/src/components/AppLayout.vue index fc0955c05..cf3d54fd7 100644 --- a/frontend/src/components/AppLayout.vue +++ b/frontend/src/components/AppLayout.vue @@ -120,16 +120,42 @@ const bottomMenuItems = computed(() => [ { key: '/settings', label: t('comp.settings'), icon: icon(SettingOutlined) }, ]) +type MenuEntry = NonNullable[number] +type KeyedMenuEntry = Exclude & { key: string | number } + +const hasMenuKey = (item: MenuEntry): item is KeyedMenuEntry => + item !== null && 'key' in item && item.key !== undefined + +const flattenMenuItems = (items: readonly MenuEntry[]): MenuEntry[] => { + const flattened: MenuEntry[] = [] + for (const item of items) { + if (!item) continue + flattened.push(item) + if ('children' in item && item.children) { + flattened.push(...flattenMenuItems(item.children)) + } + } + return flattened +} + const allItems = computed(() => [ ...mainMenuItems.value, ...(isDevelopment.value ? devMenuItems.value : []), ...bottomMenuItems.value, ]) -// 选中项:根据当前路径前缀匹配 +const flatItems = computed(() => flattenMenuItems(allItems.value)) + +// 选中项:优先精确匹配,再按路径边界匹配当前菜单。 const selectedKeys = computed(() => { const path = route.path - const matched = allItems.value.find(i => path.startsWith(String(i.key))) + const matched = flatItems.value + .filter(hasMenuKey) + .filter(item => { + const key = String(item.key) + return path === key || path.startsWith(`${key}/`) + }) + .sort((left, right) => String(right.key).length - String(left.key).length)[0] return [matched?.key || '/home'] }) @@ -196,6 +222,49 @@ const onMenuClick: MenuProps['onClick'] = info => { text-align: left; } +.sider-content :deep(.ant-menu .ant-menu-submenu) { + width: calc(100% - 16px); + margin: 2px auto; + border-radius: 6px; +} + +.sider-content :deep(.ant-menu .ant-menu-submenu-title) { + height: 40px; + margin: 0; + padding: 5px 16px !important; + line-height: 30px; + border-radius: 6px; + display: flex; + align-items: center; + gap: 6px; + color: var(--ant-color-text); + transition: + background 0.16s ease, + color 0.16s ease; +} + +.sider-content :deep(.ant-menu .ant-menu-submenu-title .anticon) { + color: var(--ant-color-text-secondary); + font-size: 18px; +} + +.sider-content :deep(.ant-menu .ant-menu-submenu-title:hover) { + background: var(--ant-color-primary-bg); + color: var(--ant-color-text); +} + +.sider-content :deep(.ant-menu .ant-menu-submenu > .ant-menu) { + background: transparent !important; +} + +.sider-content :deep(.ant-menu .ant-menu-submenu .ant-menu-item) { + width: calc(100% - 8px); + margin: 2px 4px; + padding-left: 40px !important; + height: 36px; + line-height: 32px; +} + .sider-content :deep(.ant-menu .ant-menu-item .anticon) { color: var(--ant-color-text-secondary); font-size: 18px; diff --git a/frontend/src/composables/useGameSignAccountApi.ts b/frontend/src/composables/useGameSignAccountApi.ts index c3e222e13..ab3bf0b6c 100644 --- a/frontend/src/composables/useGameSignAccountApi.ts +++ b/frontend/src/composables/useGameSignAccountApi.ts @@ -84,28 +84,6 @@ export function useGameSignAccountApi() { } } - /** - * 使用森空岛手机号密码一次性换取并保存凭据。 - * 密码只在本次请求中存在,不写入前端状态或日志。 - */ - const loginSkland = async (accountId: string, phone: string, password: string): Promise => { - try { - const response = await Service.loginSklandApiToolsSignAccountSklandLoginPost({ - accountId, - phone, - password, - }) - if (Number(response.code) !== 200 || response.status !== 'success') { - throw new Error(response.message || '森空岛手机号密码登录失败') - } - message.success(response.message || '森空岛登录成功,Token 已保存') - } catch (error) { - logger.error('森空岛手机号密码登录失败') - message.error(error instanceof Error ? error.message : '森空岛手机号密码登录失败') - throw error - } - } - /** * 删除账号组 */ @@ -135,7 +113,6 @@ export function useGameSignAccountApi() { addAccount, updateAccount, loginTaygedo, - loginSkland, deleteAccount, } } diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 9fe3ce1c1..41df984d0 100644 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -70,7 +70,7 @@ export default { moduleName: 'Module name', emulators: 'Emulators', testRoute: 'Test route', - checkIns: 'Check-ins', + checkIns: 'Game community', port: 'Port', portNumber: 'Port number', warning: 'Warning', @@ -2061,7 +2061,45 @@ export default { }, }, gamesign: { - title: 'Check-ins', + title: 'Game Community', + nav: { + sign: 'Check-ins', + activity: 'Daily notes', + }, + activity: { + title: 'Daily notes', + queriedAt: 'Queried at {time}', + refresh: 'Refresh daily notes', + queryFailed: 'Could not query daily notes', + empty: 'No daily notes to show', + drag: 'Drag to reorder', + dailyProgress: 'Daily status', + tasks: 'Daily tasks', + noteTasks: 'Tasks and recurring', + weeklyTasks: 'Weekly and recurring', + resources: 'Available resources', + roleUid: 'UID {uid}', + noRole: 'No bound character', + status: { + success: 'Available', + empty: 'No character', + limited: 'Restricted', + unavailable: 'Unavailable', + failed: 'Failed', + unknown: 'Unknown', + }, + platform: { + skland: 'Skland', + miyoushe: 'Miyoushe', + }, + game: { + arknights: 'Arknights', + endfield: 'Arknights: Endfield', + genshin: 'Genshin Impact', + starrail: 'Honkai: Star Rail', + zenless: 'Zenless Zone Zero', + }, + }, statusInvalid: 'The check-in status response was invalid', resultInvalid: 'The check-in result response was invalid', signStatus: { signed: 'Done', risk: 'Blocked', failed: 'Failed', unsigned: 'Pending' }, @@ -2069,15 +2107,17 @@ export default { defaultUserName: 'User', newUserName: 'User {n}', section: { - settings: 'Check-in settings', + settings: 'Community settings', signAll: 'Check in everywhere', noticeTitle: 'What this does, and your privacy', toolDesc: - 'The check-in tool stores your community credentials and runs check-ins at startup, on schedule, or on demand.', + 'The game community tool stores your community credentials and runs check-ins at startup, on schedule, or on demand.', privacyNotice: - 'Signing in with a username and password stores neither. The credentials live only in memory for that one login request and are cleared as soon as it finishes or fails — they never reach the config, the logs, or a notification.', - enable: 'Enable the check-in tool', - enableDesc: 'Runs check-ins on the MAS schedule.', + 'When obtaining a token by QR code or password, MAS does not save the account, phone number, or password. Passwords exist only for the current request and are not written to config, logs, or notifications.', + enable: 'Enable community tools', + enableDesc: 'Runs community check-ins with the MAS task scheduler.', + activityEnable: 'Enable daily notes', + activityEnableDesc: 'Show daily game data; turn off to skip daily-data queries.', notify: 'Notify on completion', notifyDesc: 'Push the result through your configured notification channels', runOnStartup: 'Check in at startup', @@ -2091,7 +2131,7 @@ export default { add: 'Add a user', colName: 'Name', colEnabled: 'Enabled', - colTags: 'Check-in status by community', + colTags: 'Community status', colActions: 'Actions', drag: 'Drag to reorder', tooltipTitle: '{platform} — check-in details', @@ -2119,31 +2159,31 @@ export default { }, login: { taygedoTitle: 'Get a Taygedo token with a password', - sklandTitle: 'Get a Skland token with a password', disclaimerTitle: 'Password sign-in disclaimer', currentAccount: 'Account', taygedoAccount: 'Account or phone number', taygedoAccountPlaceholder: 'Enter the Taygedo account or phone number', password: 'Password', taygedoPasswordPlaceholder: 'Enter the Taygedo password', - sklandPhone: 'Phone number', - sklandPhonePlaceholder: 'Enter the Hypergryph passport phone number', - sklandPasswordPlaceholder: 'Enter the Hypergryph passport password', submit: 'Get and save the token', }, qr: { title: 'Miyoushe QR sign-in', + sklandTitle: 'Skland QR sign-in', alt: 'QR sign-in', hint: 'Open the Miyoushe app, tap the scan icon in the top left, and scan the code above', + sklandHint: 'Open the Skland app, use Scan, and scan the code above', + sklandWaiting: 'Scan the code with the Skland app', retry: 'Generate a new code', responseInvalid: 'The QR status response was invalid — refresh and try again', expired: 'The QR code expired — refresh and scan again', - noCookie: 'The scan was confirmed but no valid auth cookie came back — generate a new code', + noCookie: 'The scan was confirmed, but the login flow returned incomplete credentials', saving: 'Saving the credentials...', saveTokenFailed: 'Could not save the token', scannedButSaveFailed: 'Scan succeeded, but the token could not be saved', success: 'Signed in — the token is filled in for you', loginSuccess: 'Miyoushe QR sign-in succeeded', + sklandLoginSuccess: 'Skland QR sign-in succeeded', queryFailed: 'Could not query the status', scanned: 'Scanned — waiting for confirmation...', cancelled: 'Sign-in cancelled', @@ -2161,6 +2201,8 @@ export default { reorderFailed: 'Could not save the new order', reorderError: 'Could not save the new order: {error}', tokenSaved: 'Token saved', + miyousheDevicePairRequired: + 'The Miyoushe device ID and device fingerprint must both be filled in or both cleared', needTaygedoCredential: 'Enter the Taygedo account and password', needSklandCredential: 'Enter the Skland phone number and password', autoSignRunning: 'An automatic check-in is already running — try again shortly', @@ -2168,6 +2210,7 @@ export default { signPartialNotify: 'Check-in finished, but some notifications did not send', signDone: 'Check-in finished', signError: 'Check-in failed: {error}', + externalLinkFailed: 'Could not open the external link. Try again shortly.', }, }, history: { @@ -2497,6 +2540,7 @@ export default { input: 'Input', couldNotPickLaunch: 'Could not pick the launch exe', qqDirectMessageOver: 'QQ direct message over the OneBot HTTP API', + qqDirectMessageOverImage: 'Send a QQ direct-message image over the OneBot HTTP API', notifyGetRequest: 'Notify with a GET request', restartApp: 'Restart the app', couldNotReorder: 'Could not reorder', diff --git a/frontend/src/i18n/locales/ja-JP.ts b/frontend/src/i18n/locales/ja-JP.ts index 799a1ecd4..869350d46 100644 --- a/frontend/src/i18n/locales/ja-JP.ts +++ b/frontend/src/i18n/locales/ja-JP.ts @@ -72,7 +72,7 @@ export default { moduleName: 'モジュール名', emulators: 'エミュレータ', testRoute: 'テストルート', - checkIns: 'ログインボーナス', + checkIns: 'ゲームコミュニティ', port: 'ポート', portNumber: 'ポート番号', warning: '警告', @@ -1778,7 +1778,45 @@ export default { }, }, gamesign: { - title: 'ゲームログインボーナス', + title: 'ゲームコミュニティ', + nav: { + sign: 'チェックイン', + activity: 'デイリーノート', + }, + activity: { + title: 'デイリーノート', + queriedAt: '{time} に照会', + refresh: 'デイリーノートを更新', + queryFailed: 'デイリーノートを取得できませんでした', + empty: '表示できるデイリーノートはありません', + drag: 'ドラッグして並べ替え', + dailyProgress: 'デイリー状況', + tasks: 'デイリー任務', + noteTasks: '任務と定期項目', + weeklyTasks: '週間・定期項目', + resources: '利用可能なリソース', + roleUid: 'UID {uid}', + noRole: 'キャラクター未連携', + status: { + success: '取得済み', + empty: 'キャラクターなし', + limited: '制限中', + unavailable: '利用不可', + failed: '失敗', + unknown: '不明', + }, + platform: { + skland: '森空島', + miyoushe: 'HoYoLAB 中国版', + }, + game: { + arknights: 'アークナイツ', + endfield: 'アークナイツ:エンドフィールド', + genshin: '原神', + starrail: '崩壊:スターレイル', + zenless: 'ゼンレスゾーンゼロ', + }, + }, statusInvalid: 'ログインボーナスの状態レスポンスが不正です', resultInvalid: 'ログインボーナスの結果レスポンスが不正です', signStatus: { @@ -1791,15 +1829,17 @@ export default { defaultUserName: 'ユーザー', newUserName: 'ユーザー {n}', section: { - settings: 'ログインボーナス設定', + settings: 'コミュニティ設定', signAll: 'すべて受け取る', noticeTitle: '機能の説明とプライバシーについて', - toolDesc: 'このツールは各コミュニティの認証情報を管理し', + toolDesc: + 'ゲームコミュニティツールは各コミュニティの認証情報を管理し、起動時・スケジュール実行時・手動実行時にチェックインします。', privacyNotice: - 'アカウントとパスワードによるトークン取得では、どちらも保存されません。入力した情報はそのログイン要求のあいだメモリ上にのみ存在し、完了または失敗した時点で破棄されます。設定・ログ・通知のいずれにも書き込まれません。', - enable: 'ログインボーナス機能を有効にする', - enableDesc: - 'MAS のスケジュールに従って受け取ります。手動実行は 1 日 1 回の制限を受けません。', + 'QR またはパスワードでトークンを取得する際、アカウント・電話番号・パスワードは保存されません。パスワードは現在の要求中にのみ存在し、設定・ログ・通知には書き込まれません。', + enable: 'コミュニティツールを有効にする', + enableDesc: 'MAS のタスクスケジュールに従ってコミュニティのチェックインを実行します。', + activityEnable: 'デイリーノートを有効にする', + activityEnableDesc: 'ゲームごとのデイリー情報を表示します。オフにすると照会しません。', notify: '結果を通知する', notifyDesc: '設定済みの通知チャンネルに結果を送信します', runOnStartup: '起動時に受け取る', @@ -1813,7 +1853,7 @@ export default { add: 'ユーザーを追加', colName: 'ユーザー名', colEnabled: '有効', - colTags: 'コミュニティ別の受取状況', + colTags: '各コミュニティの状態', colActions: '操作', drag: 'ドラッグして並び替え', tooltipTitle: '{platform} — 受取の詳細', @@ -1841,32 +1881,31 @@ export default { }, login: { taygedoTitle: 'Taygedo のトークンをアカウントとパスワードで取得', - sklandTitle: 'Skland のトークンをアカウントとパスワードで取得', disclaimerTitle: 'アカウント・パスワード利用に関する注意', currentAccount: '対象アカウント', taygedoAccount: 'アカウントまたは電話番号', taygedoAccountPlaceholder: 'Taygedo のアカウントまたは電話番号を入力してください', password: 'パスワード', taygedoPasswordPlaceholder: 'Taygedo のパスワードを入力してください', - sklandPhone: '電話番号', - sklandPhonePlaceholder: 'Hypergryph 通行証の電話番号を入力してください', - sklandPasswordPlaceholder: 'Hypergryph 通行証のパスワードを入力してください', submit: 'トークンを取得して保存', }, qr: { title: '米游社 QR ログイン', + sklandTitle: '森空島 QR ログイン', alt: 'QR ログイン', hint: '米游社アプリを開き、左上のスキャンから上の QR コードを読み取ってください', + sklandHint: '森空島アプリを開き、スキャンから上の QR コードを読み取ってください', + sklandWaiting: '森空島アプリで QR コードを読み取ってください', retry: 'QR コードを再生成', responseInvalid: 'QR コードの状態レスポンスが不正です。更新してからお試しください', expired: 'QR コードの有効期限が切れました。更新してから読み取り直してください', - noCookie: - '読み取りは確認されましたが、有効な認証 Cookie を取得できませんでした。QR コードを再生成してください', + noCookie: '読み取りは確認されましたが、ログイン処理から完全な認証情報を取得できませんでした', saving: '認証情報を保存しています...', saveTokenFailed: 'トークンを保存できませんでした', scannedButSaveFailed: '読み取りには成功しましたが、トークンを保存できませんでした', success: 'ログインしました。トークンを自動入力しました', loginSuccess: '米游社 QR ログインに成功しました', + sklandLoginSuccess: '森空島 QR ログインに成功しました', queryFailed: '状態を取得できませんでした', scanned: '読み取り済みです。確認をお待ちください...', cancelled: 'ログインをキャンセルしました', @@ -1885,6 +1924,8 @@ export default { reorderFailed: '並び替えを保存できませんでした', reorderError: '並び替えを保存できませんでした:{error}', tokenSaved: 'トークンを保存しました', + miyousheDevicePairRequired: + '米游社の端末 ID と端末フィンガープリントは、両方を入力するか両方を空にしてください', needTaygedoCredential: 'Taygedo のアカウントとパスワードを入力してください', needSklandCredential: 'Skland の電話番号とパスワードを入力してください', autoSignRunning: '自動受け取りが実行中です。しばらくしてからお試しください', @@ -1892,6 +1933,7 @@ export default { signPartialNotify: '受け取りは完了しましたが、一部の通知を送信できませんでした', signDone: '受け取りが完了しました', signError: '受け取りに失敗しました: {error}', + externalLinkFailed: '外部リンクを開けませんでした。しばらくしてからお試しください', }, }, history: { @@ -2221,6 +2263,7 @@ export default { input: '入力', couldNotPickLaunch: '起動する exe を選択できませんでした', qqDirectMessageOver: 'OneBot HTTP API 経由で QQ のダイレクトメッセージを送信', + qqDirectMessageOverImage: 'OneBot HTTP API 経由で QQ のダイレクトメッセージ画像を送信', notifyGetRequest: 'GET リクエストで通知を送信', restartApp: 'アプリを再起動', couldNotReorder: '並び替えを保存できませんでした', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index ce3f1ebee..0d9501208 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -72,7 +72,7 @@ export default { moduleName: '模块名', emulators: '模拟器管理', testRoute: '测试路由', - checkIns: '游戏签到', + checkIns: '游戏社区', port: '端口', portNumber: '端口号', warning: '警告', @@ -1967,7 +1967,45 @@ export default { }, }, gamesign: { - title: '游戏签到', + title: '游戏社区', + nav: { + sign: '签到', + activity: '日常便笺', + }, + activity: { + title: '日常便笺', + queriedAt: '查询于 {time}', + refresh: '刷新日常便笺', + queryFailed: '日常便笺查询失败', + empty: '暂无可展示的日常便笺', + drag: '拖拽排序', + dailyProgress: '日常情况', + tasks: '每日任务', + noteTasks: '任务与周期', + weeklyTasks: '每周与周期事项', + resources: '可用资源', + roleUid: 'UID {uid}', + noRole: '未绑定角色', + status: { + success: '已获取', + empty: '暂无角色', + limited: '受限', + unavailable: '不可用', + failed: '失败', + unknown: '未知', + }, + platform: { + skland: '森空岛', + miyoushe: '米游社', + }, + game: { + arknights: '明日方舟', + endfield: '终末地', + genshin: '原神', + starrail: '星穹铁道', + zenless: '绝区零', + }, + }, statusInvalid: '签到状态响应无效', resultInvalid: '签到结果响应无效', signStatus: { signed: '已签', risk: '风控', failed: '失败', unsigned: '未签' }, @@ -1975,14 +2013,16 @@ export default { defaultUserName: '用户', newUserName: '用户 {n}', section: { - settings: '签到设置', + settings: '社区设置', signAll: '全部签到', noticeTitle: '功能说明与隐私声明', - toolDesc: '游戏签到社区工具用于管理各社区凭据,并按配置执行启动时、任务调度和手动签到。', + toolDesc: '游戏社区工具用于管理各社区凭据,并按配置执行启动时、任务调度和手动签到。', privacyNotice: - '账密获取 Token 不保存任何账号密码;本次登录使用的账号密码仅存在于当前登录请求的内存中,登录完成或失败后立即清理,不写入配置、日志或通知。', - enable: '启用签到工具', - enableDesc: '启用后按 MAS 任务调度执行签到', + '扫码或账密获取 Token 时,不保存账号、手机号或密码;密码仅存在于当前请求内存中,均不写入配置、日志或通知。', + enable: '启用社区工具', + enableDesc: '启用后会随 MAS 任务调度执行签到', + activityEnable: '启用日常便笺', + activityEnableDesc: '开启后显示各游戏的日常数据;关闭后不查询日常数据', notify: '结果通知', notifyDesc: '签到完成后通过已配置的通知渠道推送结果', runOnStartup: '启动时签到', @@ -1996,7 +2036,7 @@ export default { add: '添加用户', colName: '用户名', colEnabled: '启用', - colTags: '各社区签到情况', + colTags: '各社区状态', colActions: '操作', drag: '拖拽排序', tooltipTitle: '{platform} - 签到详情', @@ -2011,7 +2051,7 @@ export default { save: '保存', userName: '用户名称', miyoushe: '米游社', - miyoushePlaceholder: '浏览器 F12 → document.cookie 获取', + miyoushePlaceholder: '可粘贴已有米游社 Cookie,或使用下方扫码登录', qrLogin: '扫码获取 Token', kuro: '库街区', kuroPlaceholder: '粘贴已从库街区客户端获取的 Token', @@ -2023,31 +2063,31 @@ export default { }, login: { taygedoTitle: '塔吉多账密获取 Token', - sklandTitle: '森空岛账密获取 Token', disclaimerTitle: '账密获取 Token 免责声明', currentAccount: '当前账号', taygedoAccount: '账号或手机号', taygedoAccountPlaceholder: '请输入塔吉多账号或手机号', password: '密码', taygedoPasswordPlaceholder: '请输入塔吉多账号密码', - sklandPhone: '手机号', - sklandPhonePlaceholder: '请输入鹰角网络通行证手机号', - sklandPasswordPlaceholder: '请输入鹰角网络通行证密码', submit: '获取并保存 Token', }, qr: { title: '米游社扫码登录', + sklandTitle: '森空岛扫码登录', alt: '扫码登录', hint: '打开米游社 APP → 左上角扫码 → 扫描上方二维码', + sklandHint: '打开森空岛 APP → 扫一扫 → 扫描上方二维码', + sklandWaiting: '请使用森空岛 APP 扫描二维码', retry: '重新生成二维码', responseInvalid: '二维码状态响应无效,请刷新后重试', expired: '二维码已过期,请刷新后重新扫码', - noCookie: '扫码确认成功但未获取到有效认证 Cookie,请重新生成二维码', + noCookie: '扫码确认成功但登录链路未返回完整凭据,请稍后重试', saving: '正在保存登录凭据...', saveTokenFailed: '保存 Token 失败', scannedButSaveFailed: '扫码成功,但保存 Token 失败', success: '登录成功!Token 已自动填入', loginSuccess: '米游社扫码登录成功', + sklandLoginSuccess: '森空岛扫码登录成功', queryFailed: '查询状态失败', scanned: '已扫码,等待确认...', cancelled: '登录已取消', @@ -2065,6 +2105,7 @@ export default { reorderFailed: '排序保存失败', reorderError: '排序保存失败:{error}', tokenSaved: 'Token 已保存', + miyousheDevicePairRequired: '米游社设备 ID 和设备指纹必须同时填写或同时清空', needTaygedoCredential: '请填写塔吉多账号和密码', needSklandCredential: '请填写森空岛手机号和密码', autoSignRunning: '自动签到正在执行,请稍后再试', @@ -2072,6 +2113,7 @@ export default { signPartialNotify: '签到完成,但部分通知发送失败', signDone: '签到完成', signError: '签到失败: {error}', + externalLinkFailed: '打开外部链接失败,请稍后重试', }, }, history: { @@ -2395,6 +2437,7 @@ export default { input: '输入', couldNotPickLaunch: '选择启动 exe 失败', qqDirectMessageOver: '通过 OneBot HTTP API 发送 QQ 私聊消息', + qqDirectMessageOverImage: '通过 OneBot HTTP API 发送 QQ 私聊图片消息', notifyGetRequest: '通过GET请求发送通知', restartApp: '重启应用', couldNotReorder: '重新排序失败', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 608a6abd4..da89966bb 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -300,7 +300,22 @@ const routes = [ path: '/gamesign', name: 'GameSign', component: () => import('../views/gamesign/index.vue'), - meta: { title: '游戏签到' }, + meta: { title: '游戏社区' }, + }, + { + path: '/gamesign/sign', + name: 'CommunityToolSign', + redirect: '/gamesign', + meta: { title: '游戏社区' }, + }, + { + path: '/gamesign/activity', + name: 'CommunityToolActivity', + redirect: { + path: '/gamesign', + query: { tab: 'activity' }, + }, + meta: { title: '游戏社区' }, }, { path: '/tools', diff --git a/frontend/src/utils/webhookTemplates.ts b/frontend/src/utils/webhookTemplates.ts index e1d1b1517..b78526e75 100644 --- a/frontend/src/utils/webhookTemplates.ts +++ b/frontend/src/utils/webhookTemplates.ts @@ -33,7 +33,8 @@ export const WEBHOOK_TEMPLATES: WebhookTemplate[] = [ { name: '企业微信机器人', descriptionKey: 'misc.wecomGroupBot', - template: '{"msgtype": "text", "text": {"content": "{title}\\n{content}"}}', + template: + '{"msgtype": "markdown", "markdown": {"content": "**{title}**\\n{content}"}}', method: 'POST', example: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your_key', headers: { @@ -101,6 +102,17 @@ export const WEBHOOK_TEMPLATES: WebhookTemplate[] = [ 'Content-Type': 'application/json', }, }, + { + name: 'OneBot 私聊(图片)', + descriptionKey: 'misc.qqDirectMessageOverImage', + template: + '{"user_id": "YOUR_QQ_NUMBER", "message": [{"type": "image", "data": {"file": "base64://{image_base64}"}}]}', + method: 'POST', + example: 'http://服务器IP:端口/send_private_msg?access_token=YOUR_ACCESS_TOKEN', + headers: { + 'Content-Type': 'application/json', + }, + }, { name: '自定义JSON', descriptionKey: 'misc.customJsonPayload', @@ -125,6 +137,7 @@ export const WEBHOOK_TEMPLATES: WebhookTemplate[] = [ export const TEMPLATE_VARIABLES = [ { name: '{title}', description: '通知标题' }, { name: '{content}', description: '通知内容' }, + { name: '{image_base64}', description: '可选图片的 Base64 数据' }, { name: '{datetime}', description: '完整日期时间 (YYYY-MM-DD HH:MM:SS)' }, { name: '{date}', description: '日期 (YYYY-MM-DD)' }, { name: '{time}', description: '时间 (HH:MM:SS)' }, diff --git a/frontend/src/views/gamesign/CommunityActivityView.vue b/frontend/src/views/gamesign/CommunityActivityView.vue new file mode 100644 index 000000000..b8d714628 --- /dev/null +++ b/frontend/src/views/gamesign/CommunityActivityView.vue @@ -0,0 +1,241 @@ + + + + + diff --git a/frontend/src/views/gamesign/QrLoginModal.vue b/frontend/src/views/gamesign/QrLoginModal.vue index fbb156718..b49ca7b02 100644 --- a/frontend/src/views/gamesign/QrLoginModal.vue +++ b/frontend/src/views/gamesign/QrLoginModal.vue @@ -1,7 +1,7 @@ + + + + diff --git a/frontend/src/views/gamesign/components/CommunityActivityMetric.vue b/frontend/src/views/gamesign/components/CommunityActivityMetric.vue new file mode 100644 index 000000000..64c7ef014 --- /dev/null +++ b/frontend/src/views/gamesign/components/CommunityActivityMetric.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/frontend/src/views/gamesign/gameSignDisplay.test.ts b/frontend/src/views/gamesign/gameSignDisplay.test.ts index 094865be0..ab7d3376c 100644 --- a/frontend/src/views/gamesign/gameSignDisplay.test.ts +++ b/frontend/src/views/gamesign/gameSignDisplay.test.ts @@ -34,6 +34,7 @@ const group = (uid: string, games: GameItem[], alias = '别名'): AccountGroup = const account = (extra: Partial = {}): SignAccount => ({ uid: 'u1', MiyousheToken: '', + CloudGenshinToken: '', KuroToken: '', SklandToken: '', TaygedoToken: '', @@ -97,6 +98,7 @@ describe('解析塔吉多凭据', () => { describe('社区凭据判定', () => { it('各社区读各自的 Token 字段', () => { expect(hasPlatformToken(account({ MiyousheToken: 't' }), '米游社')).toBe(true) + expect(hasPlatformToken(account({ CloudGenshinToken: 't' }), '米游社')).toBe(true) expect(hasPlatformToken(account({ KuroToken: 't' }), '库街区')).toBe(true) expect(hasPlatformToken(account({ SklandToken: 't' }), '森空岛')).toBe(true) expect(hasPlatformToken(account({ MiyousheToken: 't' }), '库街区')).toBe(false) diff --git a/frontend/src/views/gamesign/gameSignDisplay.ts b/frontend/src/views/gamesign/gameSignDisplay.ts index c09f24a36..5f253965c 100644 --- a/frontend/src/views/gamesign/gameSignDisplay.ts +++ b/frontend/src/views/gamesign/gameSignDisplay.ts @@ -1,5 +1,5 @@ /** - * 游戏签到标签云的纯展示逻辑。 + * 游戏社区标签云的纯展示逻辑。 * * 这里全部是纯函数,不依赖 Vue 响应式和后端接口,方便单测覆盖。 * TabGameSign 的标签云(各社区已签/失败/风控状态)完全由这些函数推导, @@ -46,6 +46,7 @@ export interface PlatformTag { export interface SignAccount { uid: string MiyousheToken?: string + CloudGenshinToken?: string KuroToken?: string SklandToken?: string TaygedoToken?: string @@ -108,7 +109,7 @@ export const hasPlatformToken = ( ): boolean => { switch (platform) { case '米游社': - return !!account.MiyousheToken + return !!(account.MiyousheToken || account.CloudGenshinToken) case '库街区': return !!account.KuroToken case '森空岛': diff --git a/frontend/src/views/gamesign/index.vue b/frontend/src/views/gamesign/index.vue index c643b1617..2511c21e2 100644 --- a/frontend/src/views/gamesign/index.vue +++ b/frontend/src/views/gamesign/index.vue @@ -1,8 +1,9 @@