From f28217ec273862278f4c9dd7a5135948af081de5 Mon Sep 17 00:00:00 2001 From: qsc Date: Wed, 2 Sep 2026 23:53:40 +0800 Subject: [PATCH] =?UTF-8?q?=E6=AD=A3=E4=BB=B7=E4=BA=8B=E4=BB=B6=E6=8E=A8?= =?UTF-8?q?=E9=80=81=EF=BC=8C=E6=B2=99=E7=9B=98=E5=8A=9F=E8=83=BD=E9=87=8D?= =?UTF-8?q?=E5=81=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 14 + README.md | 129 ++++---- _conf_schema.json | 126 +------- core/aijx3_data.py | 128 -------- core/async_task.py | 189 ------------ core/event_push.py | 468 ++++++++++++++++++++++++++++++ core/jx3api_data.py | 63 +++- core/jx3box_data.py | 35 --- core/message.py | 44 ++- main.py | 68 ++--- metadata.yaml | 4 +- requirements.txt | 1 - templates/pages/helps.html | 7 +- templates/pages/shapan.html | 67 +++++ templates/styles/pages/shapan.css | 85 ++++++ 15 files changed, 801 insertions(+), 627 deletions(-) delete mode 100644 core/aijx3_data.py delete mode 100644 core/async_task.py create mode 100644 core/event_push.py create mode 100644 templates/pages/shapan.html create mode 100644 templates/styles/pages/shapan.css diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fd80cf..0eb8a7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ ## 更新记录 +### version: 3.3.1: + +沙盘功能改用 JX3API `/sand/records` 据点数据接口,并使用随插件分发的沙盘底图、阵营领地图层和据点图标在本地渲染完整图片。 + +移除仅用于旧剑侠茶馆沙盘图片接口的 `AIJX3Service`,沙盘命令现在支持缺省使用插件配置的默认服务器。 + +### version: 3.3.0: + +移除开服、新闻、刷马、赤兔四类定时轮询推送及 APScheduler 依赖。 + +新增 JX3API WebSocket 实时事件通道,支持心跳保活、断线重连和按事件编号分发。 + +新增按 AstrBot 会话保存的事件订阅表,总开关与每个事件开关独立存储;免费事件 2001 至 2006 可直接订阅。 + ### version: 3.2.3: API区服接口功能调整,做对应调整 diff --git a/README.md b/README.md index b957683..9eba90f 100644 --- a/README.md +++ b/README.md @@ -8,28 +8,27 @@ 基于 AstrBot 的剑网三综合数据查询插件

-`astrbot_plugin_jx3` 通过 JX3API、剑侠茶馆、JX3BOX 等数据源查询《剑网3》游戏数据,并根据功能将结果发送为纯文本、图片、图文消息或两轮交互消息。插件同时提供本地避雷记录和开服、新闻、刷马、赤兔后台推送能力。 +`astrbot_plugin_jx3` 通过 JX3API、JX3BOX 等数据源查询《剑网3》游戏数据,并根据功能将结果发送为纯文本、图片、图文消息或两轮交互消息。插件同时提供本地避雷记录和基于 JX3API WebSocket 的实时事件推送。 ## 功能特点 -- 108 个中文触发词,覆盖活动、名剑、排行、交易、阵营、角色、奇遇、副本、家园、社区等场景。 +- 中文触发词覆盖活动、名剑、排行、交易、阵营、角色、奇遇、副本、家园、社区等场景。 - 支持纯文本、远程图片、HTML/Jinja2 渲染图片和图文消息链。 - 支持 `宏`、`资历` 两类 30 秒两轮交互。 - 支持本地 SQLite 避雷记录的增删改查。 -- 支持开服、新闻、刷马、赤兔四类定时轮询与会话推送。 +- 支持按 AstrBot 会话分别开启总开关和具体 JX3API 实时事件订阅。 - 复用 `aiohttp.ClientSession`,统一处理 GET、POST、JSON、图片和分页请求。 - JX3BOX 的 Node、Next2、CMS 请求统一封装,资历与交易行基础数据支持本地快照缓存和过期兜底。 -- 内置 46 个页面片段,通过公共布局与样式在本地组装为完整 HTML,并附带通用、门派/心法和奇遇图标资源。 +- 内置 47 个页面片段,通过公共布局与样式在本地组装为完整 HTML,并附带通用、沙盘、门派/心法和奇遇图标资源。 ## 数据来源 | 数据源 | 代码入口 | 主要用途 | | --- | --- | --- | -| [JX3API](https://www.jx3api.com/) | `core/jx3api_data.py` | 绝大多数游戏查询、官方资讯、排行、角色和推送数据 | -| [剑侠茶馆](https://www.jianxiachaguan.cn/) | `core/aijx3_data.py` | 阵营沙盘图片 | -| [JX3BOX](https://www.jx3box.com/) | `core/jx3box_data.py` | 奇遇攻略、配装、宏、资历、交易行及刷马/赤兔推送消息 | -| 本地 SQLite | `core/sqlite.py`、`core/bilei_data.py` | 心法别名、避雷记录、推送状态和基础数据缓存 | +| [JX3API](https://www.jx3api.com/) | `core/jx3api_data.py`、`core/event_push.py` | 游戏查询、沙盘据点数据及 WebSocket 实时事件 | +| [JX3BOX](https://www.jx3box.com/) | `core/jx3box_data.py` | 奇遇攻略、配装、宏、资历及交易行 | +| 本地 SQLite | `core/sqlite.py`、`core/bilei_data.py` | 心法别名、避雷记录、事件订阅和基础数据缓存 | 外部数据源的可用性、数据时效和字段结构均不由本插件控制。接口变更、网络异常、凭据权限不足或上游限流都可能导致查询失败。 @@ -71,7 +70,6 @@ pip install -r data/plugins/astrbot_plugin_jx3/requirements.txt | `aiohttp` | 异步 HTTP 请求与连接复用 | | `aiofiles` | 异步读取 HTML 模板 | | `aiosqlite` | 异步访问本地 SQLite 数据库 | -| `apscheduler` | 后台轮询与消息推送调度 | | `matplotlib` | 当前依赖清单保留的绘图依赖;v3.2.1 业务代码未直接导入 | ## 插件配置 @@ -82,29 +80,13 @@ pip install -r data/plugins/astrbot_plugin_jx3/requirements.txt | --- | --- | --- | --- | | `prefix.enable` | `bool` | `true` | 是否启用指令前缀检查 | | `prefix.text` | `string` | `剑三` | 指令前缀内容 | -| `server` | `string` | `梦江南` | 后台推送使用的服务器;当前查询指令中仅 `烟花` 显式使用该值补齐空服务器 | +| `server` | `string` | `梦江南` | 查询指令未输入服务器时使用的默认服务器 | | `jx3api_token` | `string` | 空 | JX3API Token | | `jx3api_ticket` | `string` | 空 | 部分名剑和心法接口需要的推栏 Ticket | -| `kfts` | `object` | 关闭、60 秒 | 开服监控配置 | -| `xwts` | `object` | 关闭、280 秒 | 新闻资讯推送配置 | -| `smts` | `object` | 关闭、60 秒 | 刷马消息推送配置 | -| `ctts` | `object` | 关闭、60 秒 | 赤兔消息推送配置 | +| `jx3api_wss` | `string` | `wss://socket.nicemoe.cn` | JX3API 事件通道地址 | +| `jx3api_wss_token` | `string` | 空 | 事件版令牌;免费事件无需填写 | -四个推送对象都包含以下字段: - -```json -{ - "enable": false, - "time": 60, - "umos": ["QQ:GroupMessage:123456"] -} -``` - -- `enable`:是否在插件初始化时创建该任务。 -- `time`:轮询周期,单位为秒。 -- `umos`:接收推送的 AstrBot 会话唯一 ID 列表,可通过 AstrBot 的 `/std` 等方式获取。 - -推送配置在插件初始化时读取。修改配置后应重新加载插件;`开服推送` 等查询指令只显示任务状态,不会动态开启或关闭任务。 +会话订阅不再写入插件配置,而是通过 `事件推送` 指令保存到本地 SQLite。修改 WebSocket 地址或事件版令牌后需要重新加载插件。 ### Token 与 Ticket @@ -212,7 +194,7 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通 | 指令 | 说明与输出 | 凭据 | | --- | --- | --- | | `帮战 服务器` | 帮战记录;图片 | 无 | -| `沙盘 [服务器]` | 从剑侠茶馆获取沙盘图片 URL 并直接发送 | 无 | +| `沙盘 [服务器]` | 查询 JX3API 据点归属并使用本地图层渲染阵营沙盘;服务器可省略 | Token | | `诛恶 服务器` | 诛恶事件,固定最多 20 条;图片 | Token | ### 角色名片与奇遇 @@ -297,16 +279,18 @@ JX3API Token 可从 [JX3API](https://www.jx3api.com/) 获取。推栏 Ticket 通 避雷功能没有内置权限校验。只要能够触发插件指令,当前实现就允许新增、修改和删除记录;公开群聊部署时应在 AstrBot 或消息平台层配置访问控制。 -### 推送任务状态 +### 实时事件推送 -| 指令 | 对应配置 | 作用 | -| --- | --- | --- | -| `开服推送` | `kfts` | 查看开服监控任务状态 | -| `新闻推送` | `xwts` | 查看新闻推送任务状态 | -| `刷马推送` | `smts` | 查看刷马推送任务状态 | -| `赤兔推送` | `ctts` | 查看赤兔推送任务状态 | +| 指令 | 作用 | +| --- | --- | +| `事件推送`、`事件推送 状态` | 查看当前会话的总开关和已订阅事件 | +| `事件推送 开启` | 开启当前会话的事件推送总开关 | +| `事件推送 关闭` | 关闭总开关;保留各事件的订阅选择 | +| `事件推送 2001 开启` | 为当前会话订阅事件 `2001` | +| `事件推送 2001 关闭` | 为当前会话取消订阅事件 `2001` | +| `事件推送 列表` | 查看支持的全部事件编号 | -状态信息包含任务键、是否启用、轮询周期、上次状态和推送对象。任务只在 `enable=true` 且 `umos` 非空时加入调度器;检测到新旧状态不同时,插件向所有目标会话发送消息并持久化新状态。 +总开关和具体事件开关必须同时开启,消息才会发送到当前会话。免费事件为:`2001` 开服状态、`2002` 官方新闻、`2003` 版本更新、`2004` 八卦速报、`2005` 关隘首领、`2006` 云从预告。其他事件需要独立的事件版令牌。 ## 业务流程 @@ -316,19 +300,15 @@ flowchart LR B --> C["command_map 查找处理器"] C --> D["MessageBuilder 参数适配"] D --> E1["JX3APIService"] - D --> E2["AIJX3Service"] - D --> E3["JX3BOXService"] - D --> E4["BiLeidata / AsyncTask"] + D --> E2["JX3BOXService"] + D --> E3["BiLeidata / EventPushService"] E1 --> F["APIClient"] E2 --> F - E3 --> F F --> G["外部 HTTP 数据源"] E3 --> H["plugin_data.db / local_data.db"] - E4 --> H E1 --> I["标准返回对象"] E2 --> I E3 --> I - E4 --> I I --> J1["纯文本"] I --> J2["HTML 模板渲染图片"] I --> J3["远程图片或图文消息链"] @@ -338,18 +318,18 @@ flowchart LR `main.py` 中的 `Jx3ApiPlugin` 是插件入口。构造阶段完成以下工作: -1. 读取前缀、默认服务器、Token、Ticket 和推送配置。 +1. 读取前缀、默认服务器、Token、Ticket 和 WebSocket 配置。 2. 计算插件目录、AstrBot 数据目录、模板目录和两个 SQLite 文件路径。 -3. 把 `templates/img`、`templates/sect`、`templates/serendipity` 中的图片读取为 base64 Data URL。 -4. 创建本地数据库、随包数据库、三个数据服务、避雷服务、推送调度器和消息构建器。 +3. 把 `templates/img`、`templates/img/sand`、`templates/sect`、`templates/serendipity` 中的图片读取为 base64 Data URL。 +4. 创建本地数据库、随包数据库、两个数据服务、避雷服务、事件推送服务和消息构建器。 异步初始化阶段会连接数据库并创建以下本地表: - `bilei`:避雷记录。 -- `tuishong`:四类推送的最新状态,固定使用 `id=1` 的单行记录。 +- `event_push_subscriptions`:按 AstrBot 会话保存事件总开关和每个事件编号的订阅开关。 - `achievement_cache`:JSON 基础数据缓存及更新时间。 -随后连接随包的 `plugin_data.db`、启动已配置的后台任务,最后建立指令映射。插件停用时会关闭调度器、三个 HTTP Session 和两个 SQLite 连接。 +随后连接随包的 `plugin_data.db`、启动 JX3API WebSocket 事件通道,最后建立指令映射。插件停用时会关闭事件通道、两个 HTTP Session 和两个 SQLite 连接。 ### 2. 指令分发 @@ -365,11 +345,10 @@ flowchart LR ### 3. 数据服务 -三个数据服务按上游来源拆分: +两个数据服务按上游来源拆分: -- `JX3APIService`:以 `https://www.jx3api.com` 为根地址,通过 GET 请求实现主体功能。 -- `AIJX3Service`:向剑侠茶馆发送 POST 请求,目前只负责沙盘图片。 -- `JX3BOXService`:通过统一请求入口访问 JX3BOX 的 Node、CMS 和 Next2 服务,负责攻略、配装、宏、资历、交易行及刷马/赤兔推送消息。 +- `JX3APIService`:以 `https://www.jx3api.com` 为根地址,通过 GET 请求实现主体功能和沙盘据点查询。 +- `JX3BOXService`:通过统一请求入口访问 JX3BOX 的 Node、CMS 和 Next2 服务,负责攻略、配装、宏、资历及交易行。 服务方法通常返回统一结构: @@ -426,7 +405,9 @@ AstrBot 的渲染接口接收完整 HTML 字符串,因此插件不会依赖渲 - `styles/tokens.css`:颜色、间距、圆角、字体和各页面内容宽度。 - `styles/base.css`:固定图片画布的页面背景、外框和基础排版。 - `styles/components.css`:统一维护数据表格、列状态、排行、统计卡片、可配置列数网格、技能/奇穴卡片、奇遇卡片、器物详情、标签和空数据等跨页面组件。 -- `styles/pages/*.css`:可选,仅保留成本计算、成就、副本记录等无法合理复用的复杂页面布局。当前 46 个页面中只有 13 个需要专属 CSS。 +- `styles/pages/*.css`:可选,仅保留成本计算、成就、副本记录、沙盘等无法合理复用的复杂页面布局。当前 47 个页面中只有 14 个需要专属 CSS。 + +沙盘坐标集中在 `templates/pages/shapan.html`。搜索 `data-castle="据点名"` 后,同一段内第一个 `` 的 `left/top` 控制领地图层,第二个 `` 控制据点图标,`` 控制竖排据点名;坐标原点是 1339 × 916 的 `background.png` 左上角。阵营防线的位置在 `templates/styles/pages/shapan.css` 的 `.sand-map__frontline` 中调整。修改后重新加载插件并发送 `沙盘 梦江南` 即可使用真实数据快速验证。 表格列数直接由模板中的 ``、`` 数量决定,不需要为四列、五列等情况分别创建样式。卡片网格通过 `--grid-columns` 配置列数,例如: @@ -447,23 +428,17 @@ AstrBot 的渲染接口接收完整 HTML 字符串,因此插件不会依赖渲 | 文件 | 生命周期 | 内容 | | --- | --- | --- | | `data/plugin_data.db` | 随插件分发,只读基础数据为主 | `kungfu` 心法名称、别名和 JX3BOX 配装 ID | -| AstrBot 插件数据目录下的 `local_data.db` | 运行时创建和维护 | 避雷记录、推送状态、资历与交易行基础数据缓存 | +| AstrBot 插件数据目录下的 `local_data.db` | 运行时创建和维护 | 避雷记录、事件订阅、资历与交易行基础数据缓存 | `achievement_cache` 同时被资历基础数据和交易行物品分组复用。每个接口快照以一条 JSON 记录保存,当前使用 `achievement_menus`、`achievement_points` 和 `trade_item_groups` 三个键。缓存有效期为 30 天;缓存过期后优先全量刷新,上游请求失败时继续使用可解析的旧缓存兜底。资历菜单与点数的刷新接口分别为 JX3BOX Node 的 `/api/node/achievement/menus` 和 `/api/node/achievement/points`。 -### 7. 后台推送 +### 7. 实时事件推送 -`core/async_task.py` 使用 `AsyncIOScheduler` 和 `IntervalTrigger`。每类任务保存: +`core/event_push.py` 与 JX3API WebSocket 保持长连接,每 30 秒发送协议心跳。连接中断后按 1、2、4、8 秒递增重试,最大间隔为 30 秒;插件卸载时会取消心跳和重连任务并关闭连接。 -- 是否启用; -- 轮询周期; -- 目标会话列表; -- 本地旧状态; -- 最近请求得到的新状态。 +每条消息按 `action` 识别事件,正文优先读取 `detail`,同时兼容 `data`。分发前查询 `event_push_subscriptions`:只有当前会话的 `enabled=1` 且对应 `action_编号=1` 时才发送。会话 ID 直接取当前消息的 `unified_msg_origin`,无需手动填写。 -调度任务取得业务数据后读取其中的 `status`。状态发生变化时,向所有 `umos` 发送 `data` 文本,并将新状态写回 `tuishong` 表。插件卸载时会移除全部任务并以非等待方式关闭调度器。 - -开服与新闻任务使用 `JX3APIService`;刷马与赤兔任务使用 `JX3BOXService.machangxiaoxi()` 请求 Next2 马场消息接口,分别传入 `horse/foreshow` 和 `chitu-horse/share_msg`,并把最新消息 ID 作为状态值避免重复推送。 +表中使用 `action_1001` 这类列名保存各事件开关,并在新增事件编号时自动为旧数据库补列。升级时旧的 `tuishong` 轮询状态表会被删除。 ## 目录结构 @@ -480,11 +455,10 @@ astrbot_plugin_jx3/ │ └── plugin_data.db # 随包心法/别名基础数据 ├── core/ │ ├── jx3api_data.py # JX3API 业务服务 -│ ├── aijx3_data.py # 剑侠茶馆业务服务 │ ├── jx3box_data.py # JX3BOX 业务服务与缓存逻辑 │ ├── message.py # 文本、图片、消息链和两轮会话构建 │ ├── request.py # aiohttp 请求封装 -│ ├── async_task.py # APScheduler 后台推送 +│ ├── event_push.py # WebSocket 事件通道与会话订阅 │ ├── bilei_data.py # 避雷数据增删改查 │ ├── sqlite.py # aiosqlite 通用封装 │ ├── fun_basic.py # 图标、时间和货币格式化工具 @@ -493,7 +467,7 @@ astrbot_plugin_jx3/ ├── layouts/ │ └── base.html # 唯一的完整 HTML 文档骨架 ├── pages/ - │ └── *.html # 46 个页面内容与 Jinja2 数据绑定 + │ └── *.html # 47 个页面内容与 Jinja2 数据绑定 ├── styles/ │ ├── tokens.css # 设计变量与页面宽度 │ ├── base.css # 全局背景、外框和排版 @@ -508,7 +482,7 @@ astrbot_plugin_jx3/ 新增查询功能时,建议按当前分层复用现有能力: -1. 根据数据来源选择 `JX3APIService`、`AIJX3Service` 或 `JX3BOXService`。 +1. 根据数据来源选择 `JX3APIService` 或 `JX3BOXService`。 2. 在业务服务中新增异步方法,复用 `APIClient`,完成请求、空数据检查、字段整理和标准返回对象构建。 3. 需要图片输出时新增或复用 `templates/pages/*.html`,优先组合 `data-table`、`card-grid`、`ability-card`、`object-card` 等公共组件,并通过 `template-components` 元数据声明所需组件;只有无法复用的复杂布局才添加同名的 `templates/styles/pages/*.css`,不要在业务层拼装最终图片消息。 4. 在 `MessageBuilder` 中增加薄包装方法,选择文本、HTML 图片、远程图片、消息链或会话处理器。 @@ -524,24 +498,21 @@ python -m compileall -q . git diff --check ``` -项目级 `.gitignore` 会忽略整个 `tests/`,本地自行维护的测试不会随插件分发。语法检查和差异检查仍不能替代带真实数据的 AstrBot 消息、HTML 渲染、后台推送和外部接口联调;发布前应在具备有效凭据的实际环境中覆盖成功、空数据、超时及上游异常路径。 +项目级 `.gitignore` 会忽略整个 `tests/`,本地自行维护的测试不会随插件分发。语法检查和差异检查仍不能替代带真实数据的 AstrBot 消息、HTML 渲染、实时事件和外部接口联调;发布前应在具备有效凭据的实际环境中覆盖成功、空数据、断线重连及上游异常路径。 ## 当前版本状态 -以下内容是对 v3.2.1 当前源码的静态核对结果,部署和二次开发前应注意: +以下内容是对 v3.3.1 当前源码的静态核对结果,部署和二次开发前应注意: -1. `command_map` 实际注册 108 个触发词;`templates/pages/helps.html` 标注 105 条,并遗漏 `功能`、`小药`、`骗子`、`开服推送`。帮助图中的 `开服监控` 不是当前有效触发词。 -2. 默认服务器配置用于刷马和赤兔后台任务;开服监控仍固定查询 `梦江南`,新闻任务不使用服务器参数。普通查询中只有 `烟花` 通过 `serverdefault()` 显式补齐默认服务器,其他可选服务器参数会原样传为空字符串。 -3. `JX3BOXService` 通过统一的 `_base_request()` 分发 Node、Next2 和 CMS 请求;资历菜单与点数缓存过期后分别从 `/api/node/achievement/menus` 和 `/api/node/achievement/points` 刷新。 -4. 刷马和赤兔后台任务已改用 JX3BOX Next2 马场消息接口;`machangxiaoxi()` 当前直接读取结果列表第一项,上游返回空列表时会由后台任务记录数据结构异常而不发送消息。 -5. `main.py` 仍计算 `data/jx3api_config.json` 路径,但仓库没有该文件,当前三个服务也不从该路径读取接口配置;接口地址直接维护在服务代码中。 -6. `APIClient` 当前默认 `ssl_verify=False`,即外部 HTTPS 请求不校验证书。对传输安全有要求的部署应先评估并调整该设置。 -7. JX3API 服务初始化时会把 Token 和 Ticket 写入 debug 日志。不要公开调试日志,建议二次开发时移除敏感值输出。 +1. `烟花` 和 `沙盘` 会通过 `serverdefault()` 补齐默认服务器,其他可选服务器参数仍会原样传为空字符串。 +2. `JX3BOXService` 通过统一的 `_base_request()` 分发 Node、Next2 和 CMS 请求;资历菜单与点数缓存过期后分别从 `/api/node/achievement/menus` 和 `/api/node/achievement/points` 刷新。 +3. `APIClient` 当前默认 `ssl_verify=False`,即外部 HTTPS 请求不校验证书。对传输安全有要求的部署应先评估并调整该设置。 +4. JX3API 服务初始化时会把 Token 和 Ticket 写入 debug 日志。不要公开调试日志,建议二次开发时移除敏感值输出。 ## 注意事项 - Token、Ticket 和会话唯一 ID 都可能属于敏感信息,不要提交到仓库、粘贴到 Issue 或输出到公开日志。 -- 不要将推送间隔设置得过短,以免触发上游限流或给目标会话造成刷屏。 +- 高流量事件应按需订阅,避免给目标会话造成刷屏。 - 奇遇、排行、掉落、贴吧等数据来自第三方聚合接口,不保证实时、完整或永久可用。 - 部分返回正文会直接交给 AstrBot HTML 渲染器;上游格式变化可能导致截图布局异常。 - 本地避雷数据位于 AstrBot 插件数据目录,升级或迁移前应备份 `local_data.db`。 diff --git a/_conf_schema.json b/_conf_schema.json index 37c8763..61df8ac 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -6,7 +6,7 @@ "enable": { "description": "是否启用", "type": "bool", - "default": true, + "default": false, "hint": "是否启用指令前缀检查功能" }, "text": { @@ -33,118 +33,18 @@ "description": "推栏标识", "type": "string", "default": "", - "hint": "通过抓包推栏APP账号登录信息获取推栏标识" + "hint": "通过抓包推栏APP账号登录信息获取推栏标识。" }, - "kfts": { - "description": "开服监控配置", - "type": "object", - "items": { - "enable": { - "description": "开服监控功能开关", - "type": "bool", - "default": false, - "hint": "是否启用剑网三开服监控功能。" - }, - "time": { - "description": "开服监控循环时间", - "type": "int", - "default": 60, - "hint": "请求服务器状态的循环时间,单位秒。" - }, - "umos": { - "description": "开服监控推送列表", - "type": "list", - "hint": "可以填写多个会话唯一ID。", - "items": { - "type": "string", - "description": "会话唯一ID,可通过/std获取" - }, - "default": [] - } - } + "jx3api_wss": { + "description": "事件通道地址", + "type": "string", + "default": "wss://socket.nicemoe.cn", + "hint": "事件推送的WebSocket地址。" }, - "xwts": { - "description": "新闻资讯推送", - "type": "object", - "items": { - "enable": { - "description": "新闻资讯推送功能开关", - "type": "bool", - "default": false, - "hint": "是否启用剑网三开新闻资讯推送功能。" - }, - "time": { - "description": "获取最新资讯循环时间", - "type": "int", - "default": 280, - "hint": "请求最新资讯的循环时间,单位秒。" - }, - "umos": { - "description": "推送列表", - "type": "list", - "hint": "可以填写多个会话唯一ID。", - "items": { - "type": "string", - "description": "会话唯一ID,可通过/std获取" - }, - "default": [] - } - } - }, - "smts": { - "description": "刷马消息推送", - "type": "object", - "items": { - "enable": { - "description": "刷马消息推送功能开关", - "type": "bool", - "default": false, - "hint": "是否启用剑网三开刷马消息推送功能。" - }, - "time": { - "description": "获取最新消息循环时间", - "type": "int", - "default": 60, - "hint": "请求最新消息的循环时间,单位秒。" - }, - "umos": { - "description": "推送列表", - "type": "list", - "hint": "可以填写多个会话唯一ID。", - "items": { - "type": "string", - "description": "会话唯一ID,可通过/std获取" - }, - "default": [] - } - } - }, - "ctts": { - "description": "赤兔消息推送", - "type": "object", - "items": { - "enable": { - "description": "赤兔消息推送功能开关", - "type": "bool", - "default": false, - "hint": "是否启用剑网三开赤兔消息推送功能。" - }, - "time": { - "description": "获取最新消息循环时间", - "type": "int", - "default": 60, - "hint": "请求最新消息的循环时间,单位秒。" - }, - "umos": { - "description": "推送列表", - "type": "list", - "hint": "可以填写多个会话唯一ID。", - "items": { - "type": "string", - "description": "会话唯一ID,可通过/std获取" - }, - "default": [] - } - } + "jx3api_wss_token": { + "description": "事件推送令牌", + "type": "string", + "default": "", + "hint": "用于付费事件推送,不填写只推送免费事件。" } -} \ No newline at end of file +} diff --git a/core/aijx3_data.py b/core/aijx3_data.py deleted file mode 100644 index 4a8970f..0000000 --- a/core/aijx3_data.py +++ /dev/null @@ -1,128 +0,0 @@ -import json -import html -import re -from datetime import datetime, timedelta -from typing import Dict, Any, Optional, List, Union -from inspect import isawaitable -from typing import Any, Awaitable, Callable, Dict, Optional - -from astrbot.api import logger -from astrbot.api import AstrBotConfig -import astrbot.api.message_components as Comp - -from .request import APIClient -from .sqlite import AsyncSQLiteDB -from .fun_basic import load_template,gold_to_parts,week_to_num,compare_date_str,format_time,format_remaining - - -class AIJX3Service: - def __init__(self, config: AstrBotConfig, sqlite: AsyncSQLiteDB, cache_sqlite: Optional[AsyncSQLiteDB] = None): - # 实例化 API Client - self._api: APIClient = APIClient() - # 引用插件配置文件 - self._config = config - # 引用sqlite - self._sql_db = sqlite - self._cache_db = cache_sqlite or sqlite - - - - async def close(self): - """释放底层 APIClient 资源""" - if self._api: - await self._api.close() - - - def _init_return_data(self) -> Dict[str, Any]: - """初始化标准的返回数据结构""" - return { - "code": 0, - "msg": "功能函数未执行", - "data": {}, - "temp": "", - "icons": {} - } - - - async def _base_request( - self, - api_path: str, - params: Optional[Dict[str, Any]] = None, - out: Optional[str] = "data" - ) -> Optional[Any]: - """ - 基础请求封装,处理配置获取和API调用。 - """ - try: - if not self._api: - logger.error("API client is not initialized") - return None - - base_url = "https://www.jianxiachaguan.cn" - api_url = base_url + api_path - data = await self._api.post(api_url, data=params, out_key=out) - - if not data: - logger.warning(f"获取接口信息失败或返回空数据: {api_url}") - - return data - - except Exception as e: - logger.error(f"基础请求调用出错 ({api_path}): {e}") - return None - - - async def _request_api( - self, - path: str, - params: Dict[str, Any], - processor: Optional[ - Callable[[Any, Dict[str, Any]], Any | Awaitable[Any]] - ] = None, - template: Optional[str] = None, - ) -> Dict[str, Any]: - """通用接口请求与模板处理。""" - return_data = self._init_return_data() - - data = await self._base_request(path, params) - if data is None: - return_data["msg"] = "获取接口信息失败" - return return_data - - try: - await processor(data, return_data) - except Exception as e: - logger.exception(f"数据处理时出错: {e}") - return_data["msg"] = "处理接口返回信息时出错" - return return_data - - # template 为空时不加载模板 - if template: - try: - return_data["temp"] = await load_template(template) - except FileNotFoundError as e: - logger.error(f"加载模板失败: {e}") - return_data["msg"] = "系统错误:模板文件不存在" - return return_data - - return_data["code"] = 200 - return return_data - - - async def shapan(self, server: str ) -> Dict[str, Any]: - """区服沙盘""" - async def processor(data: Any, return_data: Dict[str, Any]) -> None: - pic_url = data.get("picUrl") - if pic_url: - return_data["data"] = pic_url - else: - return_data["msg"] = "接口未返回图片URL" - return return_data - - return await self._request_api( - path="/api2/aijx3-jxcg/game/get-sand-table-img", - params={"serverName": server}, - processor=processor, - template="" - ) - diff --git a/core/async_task.py b/core/async_task.py deleted file mode 100644 index 22df63e..0000000 --- a/core/async_task.py +++ /dev/null @@ -1,189 +0,0 @@ -# pyright: reportArgumentType=false -import asyncio - -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from apscheduler.triggers.interval import IntervalTrigger - -from astrbot.api.event import MessageChain -from astrbot.api.star import Context -from astrbot.api import logger -from astrbot.api import AstrBotConfig - - -from .jx3api_data import JX3APIService -from .jx3box_data import JX3BOXService -from .sqlite import AsyncSQLiteDB - -class AsyncTask: - """ - 基于 APScheduler 的后台异步监控任务管理类 - """ - - def __init__(self, context: Context, config: AstrBotConfig, jx3api: JX3APIService, jx3box: JX3BOXService, sqlite: AsyncSQLiteDB): - self.context = context - self.conf = config - self.jx3api = jx3api - self.jx3box = jx3box - self.sql = sqlite - - self.server = self.conf.get("server", "梦江南") - - self.scheduler = AsyncIOScheduler() - self.tasks = {} - - logger.info(f"初始化推送功能成功") - - - """===================== 本地读写 =====================""" - - async def set_local_data(self, key: str, value): - try: - allowed = {"kfts", "xwts", "smts", "ctts"} - if key not in allowed: - raise ValueError("非法字段") - - await self.sql.update( - "tuishong", - { - key: value, - }, - "id=?", - (1,) - ) - except Exception as e: - logger.error(f"数据写入失败:{e}") - - - async def get_local_data(self, key: str, default: int=0): - try: - allowed = {"kfts", "xwts", "smts", "ctts"} - if key not in allowed: - return default - - data = await self.sql.select_one( - "tuishong", - "id=?", - (1,) - ) - if data: - return data.get(key, default) - else: - return default - except Exception as e: - logger.error(f"数据读取失败:{e}") - - - """===================== 通用后台任务 =====================""" - - async def _job_common(self, fetch_func, task_key: str, namefun: str): - state = self.tasks[task_key] - - try: - data = await fetch_func() - - if not isinstance(data, dict): - raise ValueError("fetch_func 返回数据不是 dict") - - state["state_new"] = data.get("status") - - if state["state_old"] != state["state_new"]: - message_chain = MessageChain().message(data.get("data")) - - for umo in state["umos"]: - await self.context.send_message(umo, message_chain) - - await self.set_local_data(task_key, state["state_new"]) - state["state_old"] = state["state_new"] - - except asyncio.CancelledError: - # 调度器 shutdown 时的正常路径 - raise - - except (KeyError, TypeError, ValueError) as e: - logger.error(f"{namefun} 数据结构异常: {e}") - - except Exception as e: - logger.exception(f"{namefun} 后台任务执行异常") - - """===================== 初始化任务 =====================""" - - async def init_tasks(self): - settings = [ - ("kfts", "开服监控", lambda: self.jx3api.kaifu("梦江南")), - ("xwts", "新闻资讯", lambda: self.jx3api.xinwen(1)), - ("smts", "刷马消息", lambda: self.jx3box.machangxiaoxi(self.server,"horse","foreshow")), - ("ctts", "赤兔消息", lambda: self.jx3box.machangxiaoxi(self.server,"chitu-horse","share_msg")), - ] - - for key, name, fetch in settings: - conf = self.conf.get(key, {}) - - state_old = await self.get_local_data(key, default=False) - self.tasks[key] = { - "enable": conf.get("enable", True), - "interval": conf.get("time", 60), - "umos": conf.get("umos", []), - "state_old": state_old, - "state_new": state_old - } - - if self.tasks[key]["enable"]: - if self.tasks[key]["umos"]: - self._add_scheduler(key, name, fetch) - else: - logger.warning(f"{name} 推送对象为空,任务未启动") - - if not self.scheduler.running: - self.scheduler.start() - logger.info("后台监控调度器已启动") - - """===================== 调度操作 =====================""" - - def _add_scheduler(self, key, namefun, fetch_func): - if self.scheduler.get_job(key): - self.scheduler.remove_job(key) - - interval = self.tasks[key]["interval"] - self.scheduler.add_job( - func=self._job_common, - trigger=IntervalTrigger(seconds=interval), - id=key, - args=[fetch_func, key, namefun] - ) - - logger.info(f"{namefun}后台任务启动成功,周期:{interval}s") - - def stop_all_tasks(self): - """ - 停止并移除所有任务 - """ - try: - self.scheduler.remove_all_jobs() - for key in self.tasks: - self.tasks[key]["enable"] = False - logger.info("已停止全部后台任务") - except Exception as e: - logger.error(f"停止全部后台任务失败:{e}") - - async def destroy(self): - try: - self.stop_all_tasks() - if self.scheduler.running: - self.scheduler.shutdown(wait=False) - self.tasks.clear() - logger.info("后台调度器已销毁") - except Exception as e: - logger.error(f"销毁调度器失败:{e}") - - async def get_task_info(self, key: str) -> str: - try: - t = self.tasks[key] - return ( - f"功能:{key}\n" - f"启用:{t['enable']}\n" - f"周期:{t['interval']} 秒\n" - f"旧状态:{t['state_old']}\n" - f"推送对象:{t['umos']}" - ) - except Exception as e: - return f"读取后台配置失败:{e}" diff --git a/core/event_push.py b/core/event_push.py new file mode 100644 index 0000000..8d82c53 --- /dev/null +++ b/core/event_push.py @@ -0,0 +1,468 @@ +# pyright: reportOptionalMemberAccess=false +import asyncio +import contextlib +import json +from datetime import datetime +from typing import Any, Optional +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +import aiohttp +from aiohttp import ClientSession, ClientTimeout, WSMsgType + +from astrbot.api import AstrBotConfig, logger +from astrbot.api.event import MessageChain +from astrbot.api.star import Context + +from .sqlite import AsyncSQLiteDB + + +DEFAULT_WSS_URL = "wss://socket.nicemoe.cn" +FREE_EVENT_ACTIONS = frozenset({2001, 2002, 2003, 2004, 2005, 2006}) +EVENT_NAMES = { + 1001: "奇遇触发", + 1002: "马驹刷新", + 1003: "马驹捕获", + 1005: "扶摇开启", + 1006: "扶摇点名", + 1008: "的卢每日", + 1009: "的卢刷新", + 1010: "的卢捕获", + 1011: "的卢拍卖", + 1012: "副本掉落 / 私货", + 1013: "阵营拍卖", + 1014: "诛恶事件", + 1015: "追魂点名", + 1017: "阵营祭祀", + 1018: "关隘首领", + 1101: "领地宣战·开始", + 1102: "领地宣战·结束", + 1103: "帮会宣战·开始", + 1104: "帮会宣战·结束", + 1105: "帮会约战·完胜", + 1111: "抢占粮仓", + 1112: "大旗重置", + 1113: "大旗被夺", + 1114: "据点占领", + 1115: "据点占领(无帮会)", + 1116: "小攻防贡献(非开战)", + 1117: "小攻防贡献", + 1118: "大攻防贡献", + 1119: "战利品竞拍", + 1120: "小攻防分红", + 1121: "大攻防分红", + 1122: "大攻防分红(含指挥)", + 1201: "微博更新", + 2001: "开服状态", + 2002: "官方新闻", + 2003: "版本更新", + 2004: "八卦速报", + 2005: "关隘首领", + 2006: "云从预告", +} +EVENT_ACTIONS = tuple(EVENT_NAMES) + + +class EventPushService: + """JX3API WebSocket 事件接收、会话订阅与消息分发。""" + + def __init__( + self, + context: Context, + config: AstrBotConfig, + sqlite: AsyncSQLiteDB, + ): + self.context = context + self.config = config + self.sql = sqlite + self.url = str(config.get("jx3api_wss", "") or DEFAULT_WSS_URL).strip() + self.token = str(config.get("jx3api_wss_token", "") or "").strip() + self._runner: Optional[asyncio.Task] = None + self._session: Optional[ClientSession] = None + self._websocket: Optional[aiohttp.ClientWebSocketResponse] = None + self._stopping = asyncio.Event() + + async def initialize(self): + await self._init_subscription_table() + if self._runner and not self._runner.done(): + return + self._stopping.clear() + self._runner = asyncio.create_task( + self._connection_loop(), + name="jx3api-event-push", + ) + + async def _init_subscription_table(self): + columns = ",\n".join( + f"action_{action} INTEGER NOT NULL DEFAULT 0" + for action in EVENT_ACTIONS + ) + await self.sql.execute( + f""" + CREATE TABLE IF NOT EXISTS event_push_subscriptions ( + session_id TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 0, + {columns}, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + # 以后增加事件编号时,旧数据库也能自动补齐新列。 + table_info = await self.sql.fetch_all( + "PRAGMA table_info(event_push_subscriptions)" + ) + existing_columns = {row["name"] for row in table_info} + for action in EVENT_ACTIONS: + column = self._action_column(action) + if column not in existing_columns: + await self.sql.execute( + f"ALTER TABLE event_push_subscriptions " + f"ADD COLUMN {column} INTEGER NOT NULL DEFAULT 0" + ) + + # 旧轮询推送状态不再使用,按迁移要求清理。 + await self.sql.execute("DROP TABLE IF EXISTS tuishong") + + async def stop(self): + self._stopping.set() + if self._websocket and not self._websocket.closed: + await self._websocket.close(code=1000, message=b"plugin stop") + if self._runner and not self._runner.done(): + self._runner.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._runner + self._runner = None + self._websocket = None + if self._session and not self._session.closed: + await self._session.close() + self._session = None + + async def _connection_loop(self): + retry_count = 0 + timeout = ClientTimeout(total=None, sock_connect=15) + self._session = ClientSession(timeout=timeout) + + try: + while not self._stopping.is_set(): + heartbeat_task = None + try: + connection_url = self._connection_url() + async with self._session.ws_connect( + connection_url, + heartbeat=45, + autoping=True, + ) as websocket: + self._websocket = websocket + retry_count = 0 + logger.info("JX3API 事件通道连接成功") + heartbeat_task = asyncio.create_task( + self._heartbeat_loop(websocket), + name="jx3api-event-heartbeat", + ) + + async for message in websocket: + if message.type == WSMsgType.TEXT: + await self._handle_message(message.data) + elif message.type in { + WSMsgType.CLOSE, + WSMsgType.CLOSED, + WSMsgType.ERROR, + }: + break + except asyncio.CancelledError: + raise + except Exception as exc: + if not self._stopping.is_set(): + error_text = str(exc) + if self.token: + error_text = error_text.replace(self.token, "***") + logger.warning( + f"JX3API 事件通道连接异常:" + f"{type(exc).__name__}: {error_text}" + ) + finally: + self._websocket = None + if heartbeat_task: + heartbeat_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await heartbeat_task + + if self._stopping.is_set(): + break + + delay = min(2 ** retry_count, 30) + retry_count += 1 + logger.info(f"JX3API 事件通道将在 {delay} 秒后重连") + try: + await asyncio.wait_for(self._stopping.wait(), timeout=delay) + except asyncio.TimeoutError: + pass + finally: + if self._session and not self._session.closed: + await self._session.close() + self._session = None + + def _connection_url(self) -> str: + if not self.token: + return self.url + + parts = urlsplit(self.url) + query = dict(parse_qsl(parts.query, keep_blank_values=True)) + # JX3API 官方 SDK 当前使用的事件令牌参数名即为 toekn。 + query["toekn"] = self.token + return urlunsplit( + (parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment) + ) + + async def _heartbeat_loop(self, websocket: aiohttp.ClientWebSocketResponse): + while not self._stopping.is_set() and not websocket.closed: + await asyncio.sleep(30) + if not websocket.closed: + await websocket.send_json({"action": -1}) + + async def _handle_message(self, payload: str): + try: + message = json.loads(payload) + except (TypeError, json.JSONDecodeError): + logger.debug("忽略 JX3API 事件通道的非 JSON 消息") + return + + if not isinstance(message, dict): + return + + try: + action = int(message.get("action")) + except (TypeError, ValueError): + return + + if action not in EVENT_NAMES: + logger.debug(f"忽略未知 JX3API 事件:{action}") + return + + status = str(message.get("status", "success")).lower() + if status != "success": + logger.warning(f"JX3API 事件状态异常:action={action}, status={status}") + return + + detail = message.get("detail") + if detail is None: + detail = message.get("data") + if not isinstance(detail, dict): + logger.warning(f"JX3API 事件正文结构异常:action={action}") + return + + recipients = await self._enabled_sessions(action) + if not recipients: + return + + text = self._format_event(action, detail) + results = await asyncio.gather( + *(self._send_message(session_id, text) for session_id in recipients), + return_exceptions=True, + ) + for session_id, result in zip(recipients, results): + if isinstance(result, Exception): + logger.error( + f"JX3API 事件推送失败:action={action}, " + f"session={session_id}, error={result}" + ) + + async def _send_message(self, session_id: str, text: str): + message_chain = MessageChain().message(text) + await self.context.send_message(session_id, message_chain) + + async def _enabled_sessions(self, action: int) -> list[str]: + column = self._action_column(action) + rows = await self.sql.fetch_all( + f"SELECT session_id FROM event_push_subscriptions " + f"WHERE enabled=1 AND {column}=1" + ) + return [str(row["session_id"]) for row in rows] + + async def configure( + self, + session_id: str, + first: str = "", + second: str = "", + ) -> str: + first = first.strip().lower() + second = second.strip().lower() + await self._ensure_session(session_id) + + if first in {"列表", "list"}: + return self._event_list_text() + if not first or first in {"状态", "查看", "status"}: + return await self._subscription_status(session_id) + + enable_words = {"开启", "启用", "开", "on"} + disable_words = {"关闭", "禁用", "关", "off"} + + if not second and first in enable_words | disable_words: + enabled = first in enable_words + await self.sql.update( + "event_push_subscriptions", + {"enabled": int(enabled), "updated_at": self._now_text()}, + "session_id=?", + (session_id,), + ) + state = "已开启" if enabled else "已关闭" + return f"当前会话的事件推送总开关{state}。" + + action_text = first + switch_text = second + if first in enable_words | disable_words and second.isdigit(): + action_text, switch_text = second, first + + try: + action = int(action_text) + except ValueError: + return self._usage_text() + + if action not in EVENT_NAMES: + return f"不支持事件 {action}。\n" + self._usage_text() + if switch_text not in enable_words | disable_words: + return self._usage_text() + + enabled = switch_text in enable_words + column = self._action_column(action) + await self.sql.update( + "event_push_subscriptions", + {column: int(enabled), "updated_at": self._now_text()}, + "session_id=?", + (session_id,), + ) + state = "已订阅" if enabled else "已取消订阅" + result = f"{state}事件 {action}({EVENT_NAMES[action]})。" + if enabled and action not in FREE_EVENT_ACTIONS and not self.token: + result += "\n该事件需要事件版令牌,当前配置未填写令牌。" + return result + + async def _ensure_session(self, session_id: str): + await self.sql.execute( + "INSERT OR IGNORE INTO event_push_subscriptions (session_id) VALUES (?)", + (session_id,), + ) + + async def _subscription_status(self, session_id: str) -> str: + row = await self.sql.select_one( + "event_push_subscriptions", + "session_id=?", + (session_id,), + ) + if not row: + return self._usage_text() + + subscriptions = [ + f"{action} {EVENT_NAMES[action]}" + for action in EVENT_ACTIONS + if row.get(self._action_column(action)) == 1 + ] + switch = "开启" if row.get("enabled") == 1 else "关闭" + selected = "、".join(subscriptions) if subscriptions else "无" + return ( + f"事件推送总开关:{switch}\n" + f"已订阅事件:{selected}\n\n" + f"{self._usage_text()}" + ) + + @staticmethod + def _action_column(action: int) -> str: + if action not in EVENT_NAMES: + raise ValueError(f"不支持事件:{action}") + return f"action_{action}" + + @staticmethod + def _now_text() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + @staticmethod + def _format_timestamp(value: Any) -> str: + try: + timestamp = float(value) + if timestamp > 10_000_000_000: + timestamp /= 1000 + return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") + except (TypeError, ValueError, OSError, OverflowError): + return str(value or "未知") + + def _format_event(self, action: int, detail: dict[str, Any]) -> str: + if action == 2001: + raw_status = detail.get("status") + server_status = {0: "维护", 1: "开服", "0": "维护", "1": "开服"}.get( + raw_status, + str(raw_status or "未知"), + ) + return ( + "【开服状态】\n" + f"大区:{detail.get('zone', '未知')}\n" + f"服务器:{detail.get('server', '未知')}\n" + f"状态:{server_status}\n" + f"时间:{self._format_timestamp(detail.get('time'))}" + ) + if action == 2002: + return ( + "【官方新闻】\n" + f"类型:{detail.get('type', '未知')}\n" + f"标题:{detail.get('title', '未知')}\n" + f"日期:{detail.get('date', '未知')}\n" + f"链接:{detail.get('url', '无')}" + ) + if action == 2003: + return ( + "【版本更新】\n" + f"当前版本:{detail.get('now_version', '未知')}\n" + f"最新版本:{detail.get('new_version', '未知')}\n" + f"更新包数量:{detail.get('package_num', '未知')}\n" + f"更新大小:{detail.get('package_size', '未知')}" + ) + if action == 2004: + return ( + "【八卦速报】\n" + f"分类:{detail.get('tags', '未知')}\n" + f"区服:{detail.get('zone', '未知')} / {detail.get('server', '未知')}\n" + f"来源:{detail.get('tieba', '未知')}\n" + f"标题:{detail.get('title', '未知')}\n" + f"日期:{detail.get('date', '未知')}\n" + f"链接:{detail.get('url', '无')}" + ) + if action == 2005: + return ( + "【关隘首领】\n" + f"服务器:{detail.get('server', '未知')}\n" + f"关卡:{detail.get('stage', '未知')}\n" + f"开始时间:{self._format_timestamp(detail.get('start'))}" + ) + if action == 2006: + return ( + "【云从预告】\n" + f"事件:{detail.get('name', '未知')}\n" + f"地点:{detail.get('site', '未知')}\n" + f"说明:{detail.get('desc', '无')}\n" + f"时间:{self._format_timestamp(detail.get('time'))}" + ) + + detail_text = json.dumps(detail, ensure_ascii=False, indent=2) + return f"【事件推送 · {action} {EVENT_NAMES[action]}】\n{detail_text}" + + @staticmethod + def _usage_text() -> str: + return ( + "用法:\n" + "事件推送 开启/关闭\n" + "事件推送 事件编号 开启/关闭\n" + "事件推送 状态\n" + "事件推送 列表" + ) + + @staticmethod + def _event_list_text() -> str: + free = "\n".join( + f"{action}:{EVENT_NAMES[action]}" + for action in sorted(FREE_EVENT_ACTIONS) + ) + paid = "\n".join( + f"{action}:{EVENT_NAMES[action]}" + for action in EVENT_ACTIONS + if action not in FREE_EVENT_ACTIONS + ) + return f"免费事件:\n{free}\n\n事件版事件:\n{paid}" diff --git a/core/jx3api_data.py b/core/jx3api_data.py index 659be95..42a1ddc 100644 --- a/core/jx3api_data.py +++ b/core/jx3api_data.py @@ -684,6 +684,61 @@ class JX3APIService: ) + async def shapan(self, server: str) -> Dict[str, Any]: + """阵营沙盘。""" + async def processor(data: Any, return_data: Dict[str, Any]) -> None: + if not isinstance(data, dict): + raise ValueError("沙盘接口返回的数据结构无效") + + records = data.get("data") + if not isinstance(records, list) or not records: + raise ValueError("沙盘接口未返回据点数据") + + items = [] + for record in records: + if not isinstance(record, dict): + continue + + camp_id = record.get("campId") + if camp_id not in (1, 2): + continue + + castle_name = str(record.get("castleName") or "").strip() + if not castle_name: + continue + + items.append( + { + "castle_name": castle_name, + "camp_key": "浩" if camp_id == 1 else "恶", + "camp_name": str(record.get("campName") or ""), + "tong_name": str(record.get("tongName") or ""), + "master_name": str(record.get("masterName") or ""), + "ride_piece": int(record.get("ridePiece") or 0), + "defend_count": int(record.get("defendCount") or 0), + } + ) + + if not items: + raise ValueError("沙盘接口未返回有效据点") + + update_time = format_time(data.get("update")) + return_data["data"] = { + "zone": str(data.get("zone") or ""), + "server": str(data.get("server") or server), + "reset": int(data.get("reset") or 0), + "update_time": update_time, + "items": items, + } + + return await self._request_api( + path="/sand/records", + params={"server": server, "token": self.token}, + processor=processor, + template="shapan.html", + ) + + async def zhueevent(self,server: str,limit: str) -> Dict[str, Any]: """诛恶事件""" async def processor(data: Any, return_data: Dict[str, Any]) -> None: @@ -1414,9 +1469,7 @@ class JX3APIService: """维护公告""" async def processor(data: Any, return_data: Dict[str, Any]) -> None: result = data[0] - return_data["status"] = result.get('id') - - result_msg = "维护推送\n" + result_msg = "维护公告\n" # 仅展示前1条,避免消息过长 for i, item in enumerate(data[:limit], 1): result_msg += f"{i}. 【{item.get('type', '无类型')}】\n" @@ -1438,9 +1491,7 @@ class JX3APIService: """新闻资讯""" async def processor(data: Any, return_data: Dict[str, Any]) -> None: result = data[0] - return_data["status"] = int(result.get('catid')) - - result_msg = "新闻资讯推送\n" + result_msg = "新闻资讯\n" # 仅展示前1条,避免消息过长 for i, item in enumerate(data[:limit], 1): result_msg += f"{i}. 【{item.get('type', '无类型')}】\n" diff --git a/core/jx3box_data.py b/core/jx3box_data.py index 827e5e7..c2bb3a3 100644 --- a/core/jx3box_data.py +++ b/core/jx3box_data.py @@ -115,41 +115,6 @@ class JX3BOXService: return None - async def machangxiaoxi(self, srever: str, type: str, subtype: str) -> Dict[str, Any]: - """马场消息 """ - return_data = self._init_return_data() - - data = await self._base_request( - "next2", - "/api/game/reporter/horse", - "GET", - params={ - "pageIndex":1, - "pageSize":1, - "server":srever, - "type":type, - "subtype":subtype, - }, - ) - - if data == None: - return None - else: - data_list = data["list"][0] - return_data["status"] = data_list["id"] - return_data["data"] = ( - f"区服:{srever}\n" - f"{data_list.get('content')}\n" - f"时间:{data_list.get('created_at')}\n" - ) - return_data["code"] = 200 - - return return_data - - - - - async def qiyugonglue(self, name: str) -> Dict[str, Any]: """奇遇攻略""" return_data = self._init_return_data() diff --git a/core/message.py b/core/message.py index a0fa64f..ff65309 100644 --- a/core/message.py +++ b/core/message.py @@ -7,9 +7,8 @@ from astrbot.core.utils.session_waiter import ( ) from .jx3api_data import JX3APIService -from .aijx3_data import AIJX3Service from .jx3box_data import JX3BOXService -from .async_task import AsyncTask +from .event_push import EventPushService from .bilei_data import BiLeidata @@ -41,18 +40,16 @@ class MessageBuilder: def __init__(self, server: str, jx3api: JX3APIService, - aijx3: AIJX3Service, jx3box: JX3BOXService, bilei: BiLeidata, - jx3at: AsyncTask, + event_push: EventPushService, icons: dict[str, dict[str, str]] ): self.server = server self.jx3api = jx3api - self.aijx3 = aijx3 self.jx3box = jx3box self.bilei = bilei - self.jx3at = jx3at + self.event_push = event_push self.icons = icons @@ -453,7 +450,8 @@ class MessageBuilder: async def shapan(self, event: AstrMessageEvent,server: str = ""): """ 沙盘 服务器""" - return await self.image_msg(event, lambda: self.aijx3.shapan(server)) + server = self.serverdefault(server) + return await self.T2I_image_msg(event, lambda: self.jx3api.shapan(server)) async def zhueevent(self, event: AstrMessageEvent, server: str): """ 诛恶事件 服务器""" @@ -698,22 +696,16 @@ class MessageBuilder: return await self.plain_msg(event, lambda: self.bilei.delete(id)) - async def kaifhujiank(self, event: AstrMessageEvent): - """ 开服监控""" - return_msg = await self.jx3at.get_task_info("kfts") - await event.send(event.plain_result(return_msg)) - - async def xinwenzhixun(self, event: AstrMessageEvent): - """ 新闻推送""" - return_msg = await self.jx3at.get_task_info("xwts") - await event.send(event.plain_result(return_msg)) - - async def shuamamsg(self, event: AstrMessageEvent): - """ 刷马推送""" - return_msg = await self.jx3at.get_task_info("smts") - await event.send(event.plain_result(return_msg)) - - async def chitusg(self, event: AstrMessageEvent): - """ 赤兔推送""" - return_msg = await self.jx3at.get_task_info("ctts") - await event.send(event.plain_result(return_msg)) + async def shijian_tuisong( + self, + event: AstrMessageEvent, + first: str = "", + second: str = "", + ): + """管理当前会话的实时事件订阅。""" + return_msg = await self.event_push.configure( + event.unified_msg_origin, + first, + second, + ) + await event.send(event.plain_result(return_msg)) diff --git a/main.py b/main.py index 7209b2e..ce61cab 100644 --- a/main.py +++ b/main.py @@ -9,17 +9,16 @@ from astrbot.api import AstrBotConfig from .core.sqlite import AsyncSQLiteDB from .core.jx3api_data import JX3APIService -from .core.aijx3_data import AIJX3Service from .core.jx3box_data import JX3BOXService -from .core.async_task import AsyncTask +from .core.event_push import EventPushService from .core.bilei_data import BiLeidata from .core.message import MessageBuilder from .core.fun_basic import load_as_base64 @register("astrbot_plugin_jx3", "fxdyz", - "聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和后台推送。", - "3.2.3", + "聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和实时事件推送。", + "3.3.1", "https://github.com/qsc20001102/astrbot_plugin_jx3" ) class Jx3ApiPlugin(Star): @@ -58,18 +57,17 @@ class Jx3ApiPlugin(Star): try: # 数据库初始化 await self.init_bilei_data() - await self.init_tuishong_data() await self.init_achievement_cache_data() # 连接插件数据 await self.plugin_sql_db.connect() - # 开启后台推送 - await self.jx3at.init_tasks() + # 开启实时事件通道 + await self.event_push.initialize() except Exception as e: - if self.jx3at is not None: - await self.jx3at.destroy() + if self.event_push is not None: + await self.event_push.stop() logger.exception("功能模块初始化失败") raise @@ -82,15 +80,12 @@ class Jx3ApiPlugin(Star): async def terminate(self): """可选择实现异步的插件销毁方法,当插件被卸载/停用时会调用。""" - if self.jx3at: - await self.jx3at.destroy() + if self.event_push: + await self.event_push.stop() if self.jx3api: await self.jx3api.close() - if self.aijx3: - await self.aijx3.close() - if self.jx3box: await self.jx3box.close() @@ -115,18 +110,18 @@ class Jx3ApiPlugin(Star): self.local_data_path = self.local_data_dir / "local_data.db" # SQLite插件路径 self.plugin_data_path = self.plugin_data_dir /"plugin_data.db" - # API配置文件路径 - self.api_data_path = self.plugin_data_dir / "jx3api_config.json" + # 图片文件路径 self.plugin_temp_img = self.plugin_temp_dir / "img" + self.plugin_temp_sand = self.plugin_temp_img / "sand" self.plugin_temp_sect = self.plugin_temp_dir / "sect" self.plugin_temp_serendipity = self.plugin_temp_dir / "serendipity" # 数据路径打印 logger.debug(f"本地数据路径: {self.local_data_path}") logger.debug(f"插件数据路径: {self.plugin_data_path}") - logger.debug(f"API配置文件路径: {self.api_data_path}") logger.debug(f"图片文件路径: {self.plugin_temp_img}") + logger.debug(f"沙盘图片文件路径: {self.plugin_temp_sand}") logger.debug(f"图片文件路径: {self.plugin_temp_sect}") logger.debug(f"图片文件路径: {self.plugin_temp_serendipity}") @@ -134,10 +129,12 @@ class Jx3ApiPlugin(Star): def load_local_base64(self): """加载图片文件的base64编码""" img = load_as_base64(str(self.plugin_temp_img)) + sand = load_as_base64(str(self.plugin_temp_sand)) sect = load_as_base64(str(self.plugin_temp_sect)) serendipity = load_as_base64(str(self.plugin_temp_serendipity)) self.icons = { "img": img, + "sand": sand, "sect": sect, "serendipity": serendipity } @@ -152,16 +149,20 @@ class Jx3ApiPlugin(Star): # 剑网三功能实例化 self.bilei = BiLeidata(self.local_sql_db) self.jx3api = JX3APIService(self.conf, self.plugin_sql_db, self.local_sql_db) - self.aijx3 = AIJX3Service(self.conf, self.plugin_sql_db, self.local_sql_db) self.jx3box = JX3BOXService(self.conf, self.plugin_sql_db, self.local_sql_db) - self.jx3at = AsyncTask( + self.event_push = EventPushService( cast(Context, self.context), self.conf, - self.jx3api, - self.jx3box, self.local_sql_db, ) - self.jx3cmd = MessageBuilder(self.server, self.jx3api, self.aijx3, self.jx3box, self.bilei, self.jx3at, self.icons) + self.jx3cmd = MessageBuilder( + self.server, + self.jx3api, + self.jx3box, + self.bilei, + self.event_push, + self.icons, + ) async def init_bilei_data(self): @@ -180,24 +181,6 @@ class Jx3ApiPlugin(Star): """) - async def init_tuishong_data(self): - """初始化推送数据表""" - # 创建tuishong表 - await self.local_sql_db.execute(""" - CREATE TABLE IF NOT EXISTS tuishong ( - id INTEGER PRIMARY KEY CHECK (id = 1), - kfts INTEGER DEFAULT 1, - xwts INTEGER DEFAULT 0, - smts INTEGER DEFAULT 0, - ctts INTEGER DEFAULT 0 - ) - """) - await self.local_sql_db.execute(""" - INSERT OR IGNORE INTO tuishong (id) - VALUES (1) - """) - - async def init_achievement_cache_data(self): """初始化资历基础数据缓存表""" await self.local_sql_db.execute(""" @@ -313,10 +296,7 @@ class Jx3ApiPlugin(Star): "资历": self. jx3cmd.zili, "交易行": self. jx3cmd.jiaoyihang, - "开服推送": self. jx3cmd.kaifhujiank, - "新闻推送": self. jx3cmd.xinwenzhixun, - "刷马推送": self. jx3cmd.shuamamsg, - "赤兔推送": self. jx3cmd.chitusg, + "事件推送": self.jx3cmd.shijian_tuisong, "避雷添加": self.jx3cmd.bilei_add, "避雷查看": self.jx3cmd.bilei_all, "避雷查询": self.jx3cmd.bilei_select, diff --git a/metadata.yaml b/metadata.yaml index 52ad823..d3128c4 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -1,7 +1,7 @@ name: astrbot_plugin_jx3 display_name: 剑网三游戏数据查询工具 -desc: 聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和后台推送。 -version: 3.2.3 +desc: 聚合剑网三游戏数据,提供查询、图片渲染、本地避雷和实时事件推送。 +version: 3.3.1 author: 飞翔大野猪 repo: https://github.com/qsc20001102/astrbot_plugin_jx3 astrbot_version: ">=4.11.0" diff --git a/requirements.txt b/requirements.txt index 9c229b8..f80a625 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,3 @@ matplotlib aiofiles aiosqlite aiohttp -apscheduler diff --git a/templates/pages/helps.html b/templates/pages/helps.html index 0cb1468..57fff0d 100644 --- a/templates/pages/helps.html +++ b/templates/pages/helps.html @@ -197,10 +197,9 @@
推送功能
-
开服监控
开服监控
-
新闻推送
新闻推送
-
刷马推送
刷马推送
-
赤兔推送
赤兔推送
+
事件总开关
事件推送 开启/关闭
+
订阅事件
事件推送 编号 开启/关闭
+
订阅状态
事件推送 [状态/列表]
diff --git a/templates/pages/shapan.html b/templates/pages/shapan.html new file mode 100644 index 0000000..9a1970e --- /dev/null +++ b/templates/pages/shapan.html @@ -0,0 +1,67 @@ +{# template-title: 阵营沙盘 #} +{# +坐标调整说明: +1. 搜索 data-castle="据点名" 定位据点。 +2. 第一个 img 的 left/top 是领地图层坐标。 +3. 第二个 img 的 left/top 是据点图标坐标。 +4. span 的 left/top 是竖排据点名坐标。 +坐标均以 1339 × 916 的 background.png 左上角为 (0, 0)。 +#} + +{% set castle_map = {} %} +{% for item in items %} + {% set _ = castle_map.update({item.castle_name: item.camp_key}) %} +{% endfor %} + +{% macro camp(name) %}{{ castle_map.get(name, '') }}{% endmacro %} +{% macro image(name) %}{{ icons.sand.get(name, '') }}{% endmacro %} + +
+ + +
+ + + 恶人谷 +
+ + {% if camp('凛风堡') %}
凛风堡
{% endif %} + {% if camp('神池岭') %}
神池岭
{% endif %} + {% if camp('烈日岗') %}
烈日岗
{% endif %} + {% if camp('扶风郡') %}
扶风郡
{% endif %} + {% if camp('世外坡') %}
世外坡
{% endif %} + {% if camp('飞沙关') %}
飞沙关
{% endif %} + {% if camp('龙门镇') %}
龙门镇
{% endif %} + {% if camp('凤鸣堡') %}
凤鸣堡
{% endif %} + {% if camp('惊虬谷') %}
惊虬谷
{% endif %} + {% if camp('日月崖') %}
日月崖
{% endif %} + {% if camp('卧龙坡') %}
卧龙坡
{% endif %} + {% if camp('啖杏林') %}
啖杏林
{% endif %} + {% if camp('枫湖寨') %}
枫湖寨
{% endif %} + {% if camp('不空关') %}
不空关
{% endif %} + {% if camp('激流坞') %}
激流坞
{% endif %} + {% if camp('澜沧城') %}
澜沧城
{% endif %} + {% if camp('霜戈堡') %}
霜戈堡
{% endif %} + {% if camp('金门关') %}
金门关
{% endif %} + {% if camp('青云坞') %}
青云坞
{% endif %} + {% if camp('逐鹿坪') %}
逐鹿坪
{% endif %} + {% if camp('盘龙坞') %}
盘龙坞
{% endif %} + {% if camp('千岩关') %}
千岩关
{% endif %} + {% if camp('大理山城') %}
大理山城
{% endif %} + {% if camp('秋雨堡') %}
秋雨堡
{% endif %} + {% if camp('红莲岗') %}
红莲岗
{% endif %} + {% if camp('武王城') %}
武王城
{% endif %} + +
+ + + 浩气盟 +
+ + +
diff --git a/templates/styles/pages/shapan.css b/templates/styles/pages/shapan.css new file mode 100644 index 0000000..255666e --- /dev/null +++ b/templates/styles/pages/shapan.css @@ -0,0 +1,85 @@ +html, +body.jx3-template--shapan { + width: fit-content; + height: 954px; + min-width: 0; + overflow: hidden; + background: #fff !important; +} + +body.jx3-template--shapan { + padding: 22px 15px 16px 55px; +} + +.jx3-template--shapan .jx3-frame { + width: 1339px; + min-height: 0; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; +} + +.jx3-template--shapan .jx3-frame::before, +.jx3-template--shapan .jx3-frame::after { + display: none; +} + +.sand-map { + position: relative; + width: 1339px; + height: 916px; + overflow: hidden; +} + +.sand-map img { + display: block; + max-width: none; + user-select: none; +} + +.sand-map__background { + position: absolute; + inset: 0; + z-index: 0; + width: 1339px; + height: 916px; +} + +.sand-layer, +.sand-map__neutral-labels img { + position: absolute; +} + +.sand-layer--territory { + z-index: 1; +} + +.sand-map__frontline { + position: absolute; + left: 119px; + top: 198px; + z-index: 2; + width: 727px; + height: 491px; +} + +.sand-map__neutral-labels img { + z-index: 3; +} + +.sand-layer--icon { + z-index: 4; +} + +.sand-castle__name { + position: absolute; + z-index: 5; + display: block; + width: 1em; + color: #fffcd0; + font-family: "Microsoft YaHei", "PingFang SC", sans-serif; + font-size: 12px; + line-height: 18px; + text-shadow: 1px 1px 1px #000; +}