正价事件推送,沙盘功能重做

This commit is contained in:
qsc
2026-09-02 23:53:40 +08:00
parent 328b09c24b
commit f28217ec27
15 changed files with 801 additions and 627 deletions
+14
View File
@@ -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区服接口功能调整,做对应调整
+50 -79
View File
@@ -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`
+12 -112
View File
@@ -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": "用于付费事件推送,不填写只推送免费事件。"
}
}
-128
View File
@@ -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=""
)
-189
View File
@@ -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}"
+468
View File
@@ -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
View File
@@ -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"
-35
View File
@@ -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()
+17 -25
View File
@@ -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")
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))
+24 -44
View File
@@ -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
View File
@@ -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"
-1
View File
@@ -2,4 +2,3 @@ matplotlib
aiofiles
aiosqlite
aiohttp
apscheduler
+3 -4
View File
@@ -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>
+67
View File
@@ -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>
+85
View File
@@ -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;
}