正价事件推送,沙盘功能重做
This commit is contained in:
@@ -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区服接口功能调整,做对应调整
|
||||
|
||||
@@ -8,28 +8,27 @@
|
||||
基于 AstrBot 的剑网三综合数据查询插件
|
||||
</p>
|
||||
|
||||
`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="据点名"` 后,同一段内第一个 `<img>` 的 `left/top` 控制领地图层,第二个 `<img>` 控制据点图标,`<span>` 控制竖排据点名;坐标原点是 1339 × 916 的 `background.png` 左上角。阵营防线的位置在 `templates/styles/pages/shapan.css` 的 `.sand-map__frontline` 中调整。修改后重新加载插件并发送 `沙盘 梦江南` 即可使用真实数据快速验证。
|
||||
|
||||
表格列数直接由模板中的 `<th>`、`<td>` 数量决定,不需要为四列、五列等情况分别创建样式。卡片网格通过 `--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`。
|
||||
|
||||
+13
-113
@@ -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": "用于付费事件推送,不填写只推送免费事件。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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=""
|
||||
)
|
||||
|
||||
@@ -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}"
|
||||
@@ -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}"
|
||||
+57
-6
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
+18
-26
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-2
@@ -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"
|
||||
|
||||
@@ -2,4 +2,3 @@ matplotlib
|
||||
aiofiles
|
||||
aiosqlite
|
||||
aiohttp
|
||||
apscheduler
|
||||
|
||||
@@ -197,10 +197,9 @@
|
||||
<section class="section span-3 section--success push-section">
|
||||
<div class="section-title">推送功能</div>
|
||||
<div class="section-body">
|
||||
<div class="feature"><div class="feature-name">开服监控</div><div class="commands"><span class="command">开服监控</span></div></div>
|
||||
<div class="feature"><div class="feature-name">新闻推送</div><div class="commands"><span class="command">新闻推送</span></div></div>
|
||||
<div class="feature"><div class="feature-name">刷马推送</div><div class="commands"><span class="command">刷马推送</span></div></div>
|
||||
<div class="feature"><div class="feature-name">赤兔推送</div><div class="commands"><span class="command">赤兔推送</span></div></div>
|
||||
<div class="feature"><div class="feature-name">事件总开关</div><div class="commands"><span class="command">事件推送 开启/关闭</span></div></div>
|
||||
<div class="feature"><div class="feature-name">订阅事件</div><div class="commands"><span class="command">事件推送 编号 开启/关闭</span></div></div>
|
||||
<div class="feature"><div class="feature-name">订阅状态</div><div class="commands"><span class="command">事件推送 [状态/列表]</span></div></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
<main
|
||||
class="sand-map"
|
||||
aria-label="{{ zone }} {{ server }}阵营沙盘,更新时间{{ update_time }}"
|
||||
data-server="{{ server }}"
|
||||
data-update-time="{{ update_time }}"
|
||||
>
|
||||
<img class="sand-map__background" src="{{ image('background') }}" alt="">
|
||||
|
||||
<div class="sand-castle" data-castle="恶人谷">
|
||||
<img class="sand-layer sand-layer--territory" style="left:222px;top:64px" src="{{ image('恶_恶人谷') }}" alt="">
|
||||
<img class="sand-layer sand-layer--icon" style="left:300px;top:103px" src="{{ image('图标_恶_恶人谷') }}" alt="">
|
||||
<span class="sand-castle__name" style="left:380px;top:103px">恶人谷</span>
|
||||
</div>
|
||||
|
||||
{% if camp('凛风堡') %}<div class="sand-castle" data-castle="凛风堡"><img class="sand-layer sand-layer--territory" style="left:299px;top:117px" src="{{ image(camp('凛风堡') ~ '_凛风堡') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:435px;top:186px" src="{{ image('图标_' ~ camp('凛风堡') ~ '_凛风堡') }}" alt=""><span class="sand-castle__name" style="left:485px;top:176px">凛风堡</span></div>{% endif %}
|
||||
{% if camp('神池岭') %}<div class="sand-castle" data-castle="神池岭"><img class="sand-layer sand-layer--territory" style="left:260px;top:259px" src="{{ image(camp('神池岭') ~ '_神池岭') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:280px;top:288px" src="{{ image('图标_' ~ camp('神池岭') ~ '_02') }}" alt=""><span class="sand-castle__name" style="left:320px;top:278px">神池岭</span></div>{% endif %}
|
||||
{% if camp('烈日岗') %}<div class="sand-castle" data-castle="烈日岗"><img class="sand-layer sand-layer--territory" style="left:160px;top:321px" src="{{ image(camp('烈日岗') ~ '_烈日岗') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:239px;top:402px" src="{{ image('图标_' ~ camp('烈日岗') ~ '_01') }}" alt=""><span class="sand-castle__name" style="left:285px;top:392px">烈日岗</span></div>{% endif %}
|
||||
{% if camp('扶风郡') %}<div class="sand-castle" data-castle="扶风郡"><img class="sand-layer sand-layer--territory" style="left:446px;top:241px" src="{{ image(camp('扶风郡') ~ '_扶风郡') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:477px;top:276px" src="{{ image('图标_' ~ camp('扶风郡') ~ '_01') }}" alt=""><span class="sand-castle__name" style="left:527px;top:266px">扶风郡</span></div>{% endif %}
|
||||
{% if camp('世外坡') %}<div class="sand-castle" data-castle="世外坡"><img class="sand-layer sand-layer--territory" style="left:384px;top:286px" src="{{ image(camp('世外坡') ~ '_世外坡') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:451px;top:361px" src="{{ image('图标_' ~ camp('世外坡') ~ '_05') }}" alt=""><span class="sand-castle__name" style="left:491px;top:351px">世外坡</span></div>{% endif %}
|
||||
{% if camp('飞沙关') %}<div class="sand-castle" data-castle="飞沙关"><img class="sand-layer sand-layer--territory" style="left:513px;top:187px" src="{{ image(camp('飞沙关') ~ '_飞沙关') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:521px;top:196px" src="{{ image('图标_' ~ camp('飞沙关') ~ '_01') }}" alt=""><span class="sand-castle__name" style="left:561px;top:186px">飞沙关</span></div>{% endif %}
|
||||
{% if camp('龙门镇') %}<div class="sand-castle" data-castle="龙门镇"><img class="sand-layer sand-layer--territory" style="left:546px;top:171px" src="{{ image(camp('龙门镇') ~ '_龙门镇') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:601px;top:216px" src="{{ image('图标_' ~ camp('龙门镇') ~ '_02') }}" alt=""><span class="sand-castle__name" style="left:641px;top:206px">龙门镇</span></div>{% endif %}
|
||||
{% if camp('凤鸣堡') %}<div class="sand-castle" data-castle="凤鸣堡"><img class="sand-layer sand-layer--territory" style="left:174px;top:547px" src="{{ image(camp('凤鸣堡') ~ '_凤鸣堡') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:213px;top:576px" src="{{ image('图标_' ~ camp('凤鸣堡') ~ '_04') }}" alt=""><span class="sand-castle__name" style="left:253px;top:566px">凤鸣堡</span></div>{% endif %}
|
||||
{% if camp('惊虬谷') %}<div class="sand-castle" data-castle="惊虬谷"><img class="sand-layer sand-layer--territory" style="left:161px;top:438px" src="{{ image(camp('惊虬谷') ~ '_惊虬谷') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:283px;top:498px" src="{{ image('图标_' ~ camp('惊虬谷') ~ '_01') }}" alt=""><span class="sand-castle__name" style="left:323px;top:488px">惊虬谷</span></div>{% endif %}
|
||||
{% if camp('日月崖') %}<div class="sand-castle" data-castle="日月崖"><img class="sand-layer sand-layer--territory" style="left:406px;top:408px" src="{{ image(camp('日月崖') ~ '_日月崖') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:483px;top:482px" src="{{ image('图标_' ~ camp('日月崖') ~ '_02') }}" alt=""><span class="sand-castle__name" style="left:523px;top:472px">日月崖</span></div>{% endif %}
|
||||
{% if camp('卧龙坡') %}<div class="sand-castle" data-castle="卧龙坡"><img class="sand-layer sand-layer--territory" style="left:377px;top:529px" src="{{ image(camp('卧龙坡') ~ '_卧龙坡') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:471px;top:578px" src="{{ image('图标_' ~ camp('卧龙坡') ~ '_03') }}" alt=""><span class="sand-castle__name" style="left:511px;top:568px">卧龙坡</span></div>{% endif %}
|
||||
{% if camp('啖杏林') %}<div class="sand-castle" data-castle="啖杏林"><img class="sand-layer sand-layer--territory" style="left:653px;top:205px" src="{{ image(camp('啖杏林') ~ '_啖杏林') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:667px;top:226px" src="{{ image('图标_' ~ camp('啖杏林') ~ '_01') }}" alt=""><span class="sand-castle__name" style="left:687px;top:266px">啖杏林</span></div>{% endif %}
|
||||
{% if camp('枫湖寨') %}<div class="sand-castle" data-castle="枫湖寨"><img class="sand-layer sand-layer--territory" style="left:650px;top:254px" src="{{ image(camp('枫湖寨') ~ '_枫湖寨') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:729px;top:294px" src="{{ image('图标_' ~ camp('枫湖寨') ~ '_03') }}" alt=""><span class="sand-castle__name" style="left:759px;top:284px">枫湖寨</span></div>{% endif %}
|
||||
{% if camp('不空关') %}<div class="sand-castle" data-castle="不空关"><img class="sand-layer sand-layer--territory" style="left:543px;top:422px" src="{{ image(camp('不空关') ~ '_不空关') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:631px;top:446px" src="{{ image('图标_' ~ camp('不空关') ~ '_04') }}" alt=""><span class="sand-castle__name" style="left:671px;top:436px">不空关</span></div>{% endif %}
|
||||
{% if camp('激流坞') %}<div class="sand-castle" data-castle="激流坞"><img class="sand-layer sand-layer--territory" style="left:583px;top:485px" src="{{ image(camp('激流坞') ~ '_激流坞') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:609px;top:520px" src="{{ image('图标_' ~ camp('激流坞') ~ '_01') }}" alt=""><span class="sand-castle__name" style="left:649px;top:510px">激流坞</span></div>{% endif %}
|
||||
{% if camp('澜沧城') %}<div class="sand-castle" data-castle="澜沧城"><img class="sand-layer sand-layer--territory" style="left:274px;top:680px" src="{{ image(camp('澜沧城') ~ '_澜沧城') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:321px;top:772px" src="{{ image('图标_' ~ camp('澜沧城') ~ '_03') }}" alt=""><span class="sand-castle__name" style="left:361px;top:762px">澜沧城</span></div>{% endif %}
|
||||
{% if camp('霜戈堡') %}<div class="sand-castle" data-castle="霜戈堡"><img class="sand-layer sand-layer--territory" style="left:201px;top:656px" src="{{ image(camp('霜戈堡') ~ '_霜戈堡') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:253px;top:694px" src="{{ image('图标_' ~ camp('霜戈堡') ~ '_01') }}" alt=""><span class="sand-castle__name" style="left:293px;top:684px">霜戈堡</span></div>{% endif %}
|
||||
{% if camp('金门关') %}<div class="sand-castle" data-castle="金门关"><img class="sand-layer sand-layer--territory" style="left:788px;top:177px" src="{{ image(camp('金门关') ~ '_金门关') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:835px;top:266px" src="{{ image('图标_' ~ camp('金门关') ~ '_04') }}" alt=""><span class="sand-castle__name" style="left:875px;top:256px">金门关</span></div>{% endif %}
|
||||
{% if camp('青云坞') %}<div class="sand-castle" data-castle="青云坞"><img class="sand-layer sand-layer--territory" style="left:884px;top:198px" src="{{ image(camp('青云坞') ~ '_青云坞') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:919px;top:232px" src="{{ image('图标_' ~ camp('青云坞') ~ '_01') }}" alt=""><span class="sand-castle__name" style="left:959px;top:222px">青云坞</span></div>{% endif %}
|
||||
{% if camp('逐鹿坪') %}<div class="sand-castle" data-castle="逐鹿坪"><img class="sand-layer sand-layer--territory" style="left:724px;top:445px" src="{{ image(camp('逐鹿坪') ~ '_逐鹿坪') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:773px;top:488px" src="{{ image('图标_' ~ camp('逐鹿坪') ~ '_03') }}" alt=""><span class="sand-castle__name" style="left:813px;top:478px">逐鹿坪</span></div>{% endif %}
|
||||
{% if camp('盘龙坞') %}<div class="sand-castle" data-castle="盘龙坞"><img class="sand-layer sand-layer--territory" style="left:653px;top:530px" src="{{ image(camp('盘龙坞') ~ '_盘龙坞') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:729px;top:590px" src="{{ image('图标_' ~ camp('盘龙坞') ~ '_05') }}" alt=""><span class="sand-castle__name" style="left:769px;top:580px">盘龙坞</span></div>{% endif %}
|
||||
{% if camp('千岩关') %}<div class="sand-castle" data-castle="千岩关"><img class="sand-layer sand-layer--territory" style="left:456px;top:635px" src="{{ image(camp('千岩关') ~ '_千岩关') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:541px;top:788px" src="{{ image('图标_' ~ camp('千岩关') ~ '_01') }}" alt=""><span class="sand-castle__name" style="left:581px;top:778px">千岩关</span></div>{% endif %}
|
||||
{% if camp('大理山城') %}<div class="sand-castle" data-castle="大理山城"><img class="sand-layer sand-layer--territory" style="left:455px;top:660px" src="{{ image(camp('大理山城') ~ '_大理山城') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:487px;top:676px" src="{{ image('图标_' ~ camp('大理山城') ~ '_04') }}" alt=""><span class="sand-castle__name" style="left:527px;top:666px">大理山城</span></div>{% endif %}
|
||||
{% if camp('秋雨堡') %}<div class="sand-castle" data-castle="秋雨堡"><img class="sand-layer sand-layer--territory" style="left:774px;top:372px" src="{{ image(camp('秋雨堡') ~ '_秋雨堡') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:825px;top:424px" src="{{ image('图标_' ~ camp('秋雨堡') ~ '_01') }}" alt=""><span class="sand-castle__name" style="left:865px;top:414px">秋雨堡</span></div>{% endif %}
|
||||
{% if camp('红莲岗') %}<div class="sand-castle" data-castle="红莲岗"><img class="sand-layer sand-layer--territory" style="left:758px;top:313px" src="{{ image(camp('红莲岗') ~ '_红莲岗') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:849px;top:338px" src="{{ image('图标_' ~ camp('红莲岗') ~ '_02') }}" alt=""><span class="sand-castle__name" style="left:889px;top:328px">红莲岗</span></div>{% endif %}
|
||||
{% if camp('武王城') %}<div class="sand-castle" data-castle="武王城"><img class="sand-layer sand-layer--territory" style="left:713px;top:484px" src="{{ image(camp('武王城') ~ '_武王城') }}" alt=""><img class="sand-layer sand-layer--icon" style="left:857px;top:614px" src="{{ image('图标_' ~ camp('武王城') ~ '_武王城') }}" alt=""><span class="sand-castle__name" style="left:917px;top:604px">武王城</span></div>{% endif %}
|
||||
|
||||
<div class="sand-castle" data-castle="浩气盟">
|
||||
<img class="sand-layer sand-layer--territory" style="left:834px;top:577px" src="{{ image('浩_浩气盟') }}" alt="">
|
||||
<img class="sand-layer sand-layer--icon" style="left:913px;top:676px" src="{{ image('图标_浩_浩气盟') }}" alt="">
|
||||
<span class="sand-castle__name" style="left:983px;top:686px">浩气盟</span>
|
||||
</div>
|
||||
|
||||
<img class="sand-map__frontline" src="{{ image('阵营防线') }}" alt="">
|
||||
</main>
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user