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;
+}
|