@sfmc-bds/qq-bridge 0.2.0-beta.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -2
- package/deploy/install-nginx-webhook.sh +34 -0
- package/deploy/nginx-webhook-location.conf +10 -0
- package/deploy/qq-bridge.service +23 -0
- package/deploy/qq-config-sync.ps1 +106 -0
- package/deploy/qq-tunnel.ps1 +15 -0
- package/deploy/sync-qq-config.mjs +229 -0
- package/dist/commands/account-format.js +41 -0
- package/dist/commands/account-format.js.map +7 -0
- package/dist/commands/content-format.js +114 -0
- package/dist/commands/content-format.js.map +7 -0
- package/dist/commands/db-api.js +21 -6
- package/dist/commands/db-api.js.map +2 -2
- package/dist/commands/handlers.js +392 -524
- package/dist/commands/handlers.js.map +2 -2
- package/dist/commands/index.js +5 -5
- package/dist/commands/index.js.map +3 -3
- package/dist/commands/interaction-router.js +38 -133
- package/dist/commands/interaction-router.js.map +2 -2
- package/dist/commands/join-settings-ui.js +1 -24
- package/dist/commands/join-settings-ui.js.map +2 -2
- package/dist/commands/menu-format.js +36 -33
- package/dist/commands/menu-format.js.map +2 -2
- package/dist/commands/menu-panel-sync.js +39 -13
- package/dist/commands/menu-panel-sync.js.map +2 -2
- package/dist/commands/message-text.js +91 -0
- package/dist/commands/message-text.js.map +7 -0
- package/dist/commands/pending.js +23 -16
- package/dist/commands/pending.js.map +2 -2
- package/dist/commands/permissions.js +17 -0
- package/dist/commands/permissions.js.map +7 -0
- package/dist/commands/registry.js +1 -1
- package/dist/commands/registry.js.map +2 -2
- package/dist/commands/render.js +7 -16
- package/dist/commands/render.js.map +2 -2
- package/dist/commands/reply-llbot.js +18 -7
- package/dist/commands/reply-llbot.js.map +2 -2
- package/dist/commands/reply-official.js +55 -39
- package/dist/commands/reply-official.js.map +3 -3
- package/dist/commands/router.js +116 -23
- package/dist/commands/router.js.map +2 -2
- package/dist/config.js +35 -27
- package/dist/config.js.map +2 -2
- package/dist/console.js +3 -3
- package/dist/console.js.map +2 -2
- package/dist/index.js +30 -16
- package/dist/index.js.map +2 -2
- package/dist/llbot-ws-api.js +2 -2
- package/dist/llbot-ws-api.js.map +2 -2
- package/dist/official/events.js +32 -7
- package/dist/official/events.js.map +2 -2
- package/dist/official/gateway.js +10 -1
- package/dist/official/gateway.js.map +2 -2
- package/dist/official/webhook.js +165 -0
- package/dist/official/webhook.js.map +7 -0
- package/dist/onebot.js +0 -4
- package/dist/onebot.js.map +2 -2
- package/package.json +4 -4
- package/dist/commands/join-settings-ui.test.js +0 -43
- package/dist/commands/join-settings-ui.test.js.map +0 -7
- package/dist/commands/menu-panel-sync.test.js +0 -27
- package/dist/commands/menu-panel-sync.test.js.map +0 -7
- package/dist/commands/render.test.js +0 -44
- package/dist/commands/render.test.js.map +0 -7
- package/dist/commands/router.test.js +0 -92
- package/dist/commands/router.test.js.map +0 -7
- package/dist/llbot-ws-api.test.js +0 -32
- package/dist/llbot-ws-api.test.js.map +0 -7
- package/dist/official/events.test.js +0 -123
- package/dist/official/events.test.js.map +0 -7
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
SFMC QQ ↔ MC 桥接,支持双后端:
|
|
4
4
|
|
|
5
|
-
- **official**(默认):QQ 开放平台 Gateway
|
|
5
|
+
- **official**(默认):QQ 开放平台 WebSocket Gateway 或 Webhook;开启「接收所有消息」后,群内指令无需 @,普通聊天仍需 @ 才转发游戏
|
|
6
6
|
- **llbot**:LLBot OneBot 11 reverse-WS(端口 3002)
|
|
7
7
|
|
|
8
8
|
消息统一 POST 到 db-server `/api/sfmc/messages`。MC→QQ 出站由 db-server 按同一 `qq_backend` 处理。
|
|
@@ -14,12 +14,44 @@ QQ 侧指令(`菜单` / `ping` / `whoami`)在桥内拦截,不依赖 MC;o
|
|
|
14
14
|
## 安装
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
|
-
|
|
17
|
+
pnpm add @sfmc-bds/qq-bridge
|
|
18
18
|
node node_modules/@sfmc-bds/qq-bridge/dist/index.js
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
通常由 `@sfmc-bds/sfmc` / `@sfmc-bds/cli` 统一拉起。
|
|
22
22
|
|
|
23
|
+
## 官方机器人 Webhook 云端部署
|
|
24
|
+
|
|
25
|
+
Webhook 模式只改变 QQ→桥的事件接收。MC→QQ 仍由游戏机上的 db-server 调用 QQ OpenAPI。云端仅运行 `qq-bridge`,不需要复制游戏世界或数据库。
|
|
26
|
+
|
|
27
|
+
游戏机 `configs/qq_config.json` 中设置 `qq_backend: "official"`、`qq_enabled: true` 和 `official.transport: "webhook"`。官方凭据、目标群、管理员及回调参数集中放在 `official` 对象;LLBot 参数集中放在 `llbot` 对象。CLI 看到 Webhook 模式后跳过游戏机上的 QQ 桥进程,保留 db-server 与 BDS。
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"qq_enabled": true,
|
|
32
|
+
"qq_backend": "official",
|
|
33
|
+
"official": {
|
|
34
|
+
"transport": "webhook",
|
|
35
|
+
"app_id": "填入 AppID",
|
|
36
|
+
"app_secret": "填入 AppSecret",
|
|
37
|
+
"group_openid": "填入目标群 OpenID",
|
|
38
|
+
"admin_openids": [],
|
|
39
|
+
"webhook": { "port": 3005, "path": "/qqbot/webhook" }
|
|
40
|
+
},
|
|
41
|
+
"llbot": { "enabled": false }
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`deploy/qq-config-sync.ps1` 在游戏机登录后持续检查配置。仅当 `official.transport` 为 `webhook` 时,它经 SSH 标准输入同步官方凭据、目标群、管理员、频道和公开服务器信息;云端保留自己的 Webhook 监听、隧道端口与面板 ID。切回 `websocket` 或禁用 QQ 时,同步任务停止云端桥;切换接收方式后还需重启游戏机上的 SFMC CLI,并在 QQ 开放平台修改消息接收方式。同步失败时云端保留上一版配置。
|
|
46
|
+
|
|
47
|
+
当前阿里云部署使用 SSH 别名 `aliyun`、云端同步程序 `/opt/sfmc-qq-bridge/deploy/sync-qq-config.mjs`,游戏机任务名为 `SFMC QQ Config Sync`。同步任务登录后启动,每 30 秒检查一次;游戏机重启后需先登录该用户。日志在运行目录的 `qq-config-sync.log`,只记录状态。回调端口与路径在此部署中固定为 `3005` 和 `/qqbot/webhook`;修改这两个值须同步调整 Nginx 和 QQ 开放平台回调地址。
|
|
48
|
+
|
|
49
|
+
Webhook 进程仅监听云机 `127.0.0.1:3005`。用同机 HTTPS 反向代理把 `https://你的域名/qqbot/webhook` 转给它,然后在 QQ 开放平台「消息接收方式」选择 HTTP 回调,填写同一 URL。回调先完成平台 `op:13` 地址验证,正式事件再校验签名;反向代理须保留请求体原始字节与 `X-Bot-Appid`、`X-Signature-Timestamp`、`X-Signature-Ed25519` 请求头。
|
|
50
|
+
|
|
51
|
+
云端桥需要访问游戏机 db-server 的多个本地 API。db-server 只监听游戏机 `127.0.0.1:3001`,不要开放其端口。游戏机 SFMC CLI 的 `tunnel` 服务主动建立 SSH 反向隧道,将云机 `127.0.0.1:13001` 转发到游戏机 `127.0.0.1:3001`;云端配置 `db_host: "127.0.0.1"`、`db_port: 13001`。在 `qq_config.json` 的 `official.tunnel` 配置 `{ "enabled": true, "host": "aliyun", "remotePort": 13001 }`,其中 `host` 是本机 SSH 配置中的别名。SFMC 启动 db-server 后启动隧道,异常退出时重试,并通过 `status`、`start tunnel`、`stop tunnel`、`restart tunnel` 和 `logs tunnel` 管理。远端只监听回环地址;不要同时运行旧的 `deploy/qq-tunnel.ps1`,否则两者会争抢云端端口。配置同步任务仍负责同步云端机器人配置,与隧道的生命周期分开。
|
|
52
|
+
|
|
53
|
+
回调成功接收后会尽快确认,事件在单进程内按序处理并限制待处理数量。当前待处理队列位于内存中,进程在确认后立即崩溃时,尚未处理的事件可能丢失;如需跨进程故障恢复,应再增加持久化队列。
|
|
54
|
+
|
|
23
55
|
## 依赖
|
|
24
56
|
|
|
25
57
|
- `@sfmc-bds/sdk`
|
|
@@ -30,3 +62,38 @@ node node_modules/@sfmc-bds/qq-bridge/dist/index.js
|
|
|
30
62
|
|
|
31
63
|
<https://github.com/DogeLakeDev/ScriptsForMinecraftServer/tree/main/packages/qq-bridge>
|
|
32
64
|
|
|
65
|
+
## 玩家服务交互(第一期)
|
|
66
|
+
|
|
67
|
+
首页分为「服务器」「我的账号」「入服」「帮助」,管理权限核验成功才显示「管理」。两端保留原有文字命令和斜杠别名,子菜单均可返回首页。官方机器人开启「接收所有消息」后,群内可直接发送指令;未开启时仍需 @机器人。LLBot 菜单编号只对打开菜单的本人有效,60 秒过期,错误编号不会消费菜单。
|
|
68
|
+
|
|
69
|
+
- `查服` / `状态` / `/status`:玩家可见的运行、在线与世界摘要;主机与进程信息位于管理员「自检」。
|
|
70
|
+
- `版本` / `/version`:优先显示 SFMC CLI 捕获的本次 BDS 启动日志版本。须部署新版 CLI 并重启 BDS 才能产生记录;外部启动、停止运行或启动记录无法核验时,不冒充当前版本。
|
|
71
|
+
- `ip` / `地址`:只显示 `qq_config.json` 的公开连接配置,不使用数据库地址或主机名。
|
|
72
|
+
- `绑定`:获取验证码后,在游戏内执行 `/c:bind`,随后发送纯数字验证码。
|
|
73
|
+
- 解绑等需确认的操作:先显示对象与影响,60 秒内本人确认;取消、新命令或超时后失效。确认时重新核验权限,重复确认不重复提交。
|
|
74
|
+
- 管理员在「管理 → 事件推送」中可切换事件总开关、BDS 启停与异常退出、玩家进出和死亡通知,并设置 5–600 秒的聚合间隔;关闭总开关需本人确认。设置写入 `configs/qq_config.json` 并用于后续推送。
|
|
75
|
+
- 在线名单自动连续分段。官方富文本发送失败时,降级文本保留可直接发送的完整命令。
|
|
76
|
+
|
|
77
|
+
公开信息配置示例(域名仅为占位,请填写自己的公开地址):
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"public_server": {
|
|
82
|
+
"address": "play.example.com",
|
|
83
|
+
"port": 19132,
|
|
84
|
+
"version": ""
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`version` 是无法获取实际版本时的备用入服说明,回复会明确标注来源;留空时提示联系管理员。修改后重启 QQ 桥接服务。权限仍由 db-server 最终校验,群管理员是否具有管理权限取决于现有 `treat_group_admins_as_admins` 设置;查询失败时隐藏受限入口。
|
|
90
|
+
|
|
91
|
+
本期不改变入服规则。申请提交、审核通过和 BDS 写入白名单分别提示,不将审核通过视作已生效。下一阶段再完善自助入服进度和玩家数据服务。
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
### 本次验证记录(2026-09-23)
|
|
95
|
+
|
|
96
|
+
- QQ 桥接、db-server、CLI 与 SDK 类型检查通过;前三个包构建通过,SDK 配置入口与类型产物已更新。
|
|
97
|
+
- 基于构建产物的 73 项本地模拟检查通过:菜单排版与权限、数字会话、确认取消/过期/重复/角色变化、错误拦截、版本与公开地址、120 人名单、UTF-8 分段、官方 Markdown 失败降级、LLBot HTTP 连续发送、旧按钮回调与 BDS 版本记录核验。
|
|
98
|
+
- HTTP/QQ 发送均使用本地模拟服务或替代实现;没有向真实群发送测试消息。临时验证脚本已清理。
|
|
99
|
+
- 尚未部署到运行目录,未重启实际 BDS,真实官方 QQ 与 LLBot 客户端联调待完成。公开地址需要填写 `public_server`;实际版本需要新版 CLI 捕获一次 BDS 启动日志。
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -eu
|
|
3
|
+
|
|
4
|
+
site_file="${1:?请提供 Nginx 站点配置路径}"
|
|
5
|
+
server_name="${2:?请提供 HTTPS 站点域名}"
|
|
6
|
+
snippet=/etc/nginx/snippets/sfmc-qq-webhook.conf
|
|
7
|
+
backup="${site_file}.bak-$(date +%Y%m%d%H%M%S)"
|
|
8
|
+
|
|
9
|
+
test -f "$site_file"
|
|
10
|
+
install -m 0644 "$(dirname "$0")/nginx-webhook-location.conf" "$snippet"
|
|
11
|
+
cp -p "$site_file" "$backup"
|
|
12
|
+
|
|
13
|
+
python3 - "$site_file" "$server_name" <<'PY'
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
path = Path(sys.argv[1])
|
|
18
|
+
server_name = sys.argv[2]
|
|
19
|
+
text = path.read_text()
|
|
20
|
+
include_line = " include /etc/nginx/snippets/sfmc-qq-webhook.conf;\n"
|
|
21
|
+
if include_line not in text:
|
|
22
|
+
marker = f"server_name {server_name};\n"
|
|
23
|
+
if marker not in text:
|
|
24
|
+
raise SystemExit(f"未找到站点 {server_name}")
|
|
25
|
+
text = text.replace(marker, marker + include_line, 1)
|
|
26
|
+
path.write_text(text)
|
|
27
|
+
PY
|
|
28
|
+
|
|
29
|
+
if ! nginx -t; then
|
|
30
|
+
cp -p "$backup" "$site_file"
|
|
31
|
+
exit 1
|
|
32
|
+
fi
|
|
33
|
+
systemctl reload nginx
|
|
34
|
+
printf 'Nginx 回调路径已安装;站点备份:%s\n' "$backup"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
location = /qqbot/webhook {
|
|
2
|
+
client_max_body_size 64k;
|
|
3
|
+
proxy_pass http://127.0.0.1:3005;
|
|
4
|
+
proxy_http_version 1.1;
|
|
5
|
+
proxy_set_header Host $host;
|
|
6
|
+
proxy_set_header X-Bot-Appid $http_x_bot_appid;
|
|
7
|
+
proxy_set_header X-Signature-Timestamp $http_x_signature_timestamp;
|
|
8
|
+
proxy_set_header X-Signature-Ed25519 $http_x_signature_ed25519;
|
|
9
|
+
proxy_read_timeout 10s;
|
|
10
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=SFMC official QQ bridge (Webhook)
|
|
3
|
+
After=network-online.target
|
|
4
|
+
Wants=network-online.target
|
|
5
|
+
|
|
6
|
+
[Service]
|
|
7
|
+
Type=simple
|
|
8
|
+
User=qqbridge
|
|
9
|
+
Group=qqbridge
|
|
10
|
+
UMask=0027
|
|
11
|
+
WorkingDirectory=/opt/sfmc-qq-bridge
|
|
12
|
+
Environment=SFMC_ROOT=/var/lib/sfmc-qq-bridge
|
|
13
|
+
ExecStart=/usr/bin/node /opt/sfmc-qq-bridge/node_modules/@sfmc-bds/qq-bridge/dist/index.js
|
|
14
|
+
Restart=always
|
|
15
|
+
RestartSec=3
|
|
16
|
+
NoNewPrivileges=true
|
|
17
|
+
PrivateTmp=true
|
|
18
|
+
ProtectSystem=strict
|
|
19
|
+
ProtectHome=true
|
|
20
|
+
ReadWritePaths=/var/lib/sfmc-qq-bridge
|
|
21
|
+
|
|
22
|
+
[Install]
|
|
23
|
+
WantedBy=multi-user.target
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# 游戏机 qq_config.json 是唯一人工编辑入口;仅 official.transport=webhook 时同步云端桥配置。
|
|
2
|
+
param(
|
|
3
|
+
[string]$Root = $PSScriptRoot,
|
|
4
|
+
[switch]$Once
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
$ErrorActionPreference = 'Stop'
|
|
8
|
+
$OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
|
9
|
+
$configPath = Join-Path $Root 'configs\qq_config.json'
|
|
10
|
+
$logPath = Join-Path $Root 'qq-config-sync.log'
|
|
11
|
+
$mutex = New-Object System.Threading.Mutex($false, 'Global\SFMCQQConfigSync')
|
|
12
|
+
if (-not $mutex.WaitOne(0)) { exit 0 }
|
|
13
|
+
|
|
14
|
+
function Write-SyncLog([string]$message) {
|
|
15
|
+
if ((Test-Path -LiteralPath $logPath) -and (Get-Item -LiteralPath $logPath).Length -gt 1048576) {
|
|
16
|
+
Clear-Content -LiteralPath $logPath
|
|
17
|
+
}
|
|
18
|
+
"$(Get-Date -Format o) $message" | Add-Content -LiteralPath $logPath -Encoding UTF8
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function Read-StableConfig {
|
|
22
|
+
$first = [IO.File]::ReadAllText($configPath, [Text.Encoding]::UTF8)
|
|
23
|
+
Start-Sleep -Seconds 1
|
|
24
|
+
$second = [IO.File]::ReadAllText($configPath, [Text.Encoding]::UTF8)
|
|
25
|
+
if ($first -ne $second) { throw '配置仍在写入' }
|
|
26
|
+
try { return $second | ConvertFrom-Json } catch { throw 'qq_config.json 不是有效 JSON' }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function New-SyncPayload($config) {
|
|
30
|
+
if ($null -eq $config.qq_backend -or $null -eq $config.official -or $null -eq $config.official.transport) {
|
|
31
|
+
throw '缺少显式的 qq_backend 或 official.transport'
|
|
32
|
+
}
|
|
33
|
+
$backend = [string]$config.qq_backend
|
|
34
|
+
$transport = [string]$config.official.transport
|
|
35
|
+
if ($backend -notin @('official', 'llbot') -or $transport -notin @('websocket', 'webhook')) {
|
|
36
|
+
throw 'qq_backend 或 official.transport 无效'
|
|
37
|
+
}
|
|
38
|
+
if ($backend -ne 'official' -or $config.qq_enabled -eq $false -or $transport -ne 'webhook') {
|
|
39
|
+
return [ordered]@{ mode = 'websocket' }
|
|
40
|
+
}
|
|
41
|
+
if ([string]::IsNullOrWhiteSpace($config.official.app_id) -or
|
|
42
|
+
[string]::IsNullOrWhiteSpace($config.official.app_secret)) {
|
|
43
|
+
throw 'official.app_id 或 official.app_secret 未配置'
|
|
44
|
+
}
|
|
45
|
+
if (($null -ne $config.official.webhook.port -and [int]$config.official.webhook.port -ne 3005) -or
|
|
46
|
+
($null -ne $config.official.webhook.path -and $config.official.webhook.path -ne '/qqbot/webhook')) {
|
|
47
|
+
throw '当前阿里云反向代理固定使用 3005 和 /qqbot/webhook'
|
|
48
|
+
}
|
|
49
|
+
$publicServer = $config.public_server
|
|
50
|
+
$publicPort = if ($null -ne $publicServer -and $null -ne $publicServer.port) { [int]$publicServer.port } else { 19132 }
|
|
51
|
+
$shared = [ordered]@{
|
|
52
|
+
official = [ordered]@{
|
|
53
|
+
app_id = [string]$config.official.app_id
|
|
54
|
+
app_secret = [string]$config.official.app_secret
|
|
55
|
+
sandbox = ($config.official.sandbox -eq $true)
|
|
56
|
+
group_openid = [string]$config.official.group_openid
|
|
57
|
+
admin_openids = @($config.official.admin_openids | Where-Object { $null -ne $_ })
|
|
58
|
+
sync_menu_panel = ($config.official.sync_menu_panel -ne $false)
|
|
59
|
+
}
|
|
60
|
+
public_server = [ordered]@{
|
|
61
|
+
address = [string]$publicServer.address
|
|
62
|
+
port = $publicPort
|
|
63
|
+
version = [string]$publicServer.version
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return [ordered]@{ mode = 'webhook'; shared = $shared }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
$lastSuccess = ''
|
|
71
|
+
$hadError = $false
|
|
72
|
+
while ($true) {
|
|
73
|
+
try {
|
|
74
|
+
$payload = New-SyncPayload (Read-StableConfig)
|
|
75
|
+
$json = ConvertTo-Json -InputObject $payload -Depth 8 -Compress
|
|
76
|
+
$bytes = [Text.Encoding]::UTF8.GetBytes($json)
|
|
77
|
+
$sha = [Security.Cryptography.SHA256]::Create()
|
|
78
|
+
try { $fingerprint = [Convert]::ToBase64String($sha.ComputeHash($bytes)) } finally { $sha.Dispose() }
|
|
79
|
+
if ($fingerprint -ne $lastSuccess) {
|
|
80
|
+
$previousErrorPreference = $ErrorActionPreference
|
|
81
|
+
$ErrorActionPreference = 'Continue'
|
|
82
|
+
try {
|
|
83
|
+
$json | & 'C:\Windows\System32\OpenSSH\ssh.exe' -T `
|
|
84
|
+
-o BatchMode=yes -o ConnectTimeout=10 aliyun `
|
|
85
|
+
'flock -w 30 /run/lock/sfmc-qq-config-sync.lock node /opt/sfmc-qq-bridge/deploy/sync-qq-config.mjs' 2>&1 | Out-Null
|
|
86
|
+
$result = $LASTEXITCODE
|
|
87
|
+
} finally {
|
|
88
|
+
$ErrorActionPreference = $previousErrorPreference
|
|
89
|
+
}
|
|
90
|
+
if ($result -ne 0) { throw "阿里云同步失败(SSH 退出码 $result)" }
|
|
91
|
+
$lastSuccess = $fingerprint
|
|
92
|
+
Write-SyncLog "云端同步成功(mode=$($payload.mode))"
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
# 异常可能包含解析位置,日志只记录固定原因,避免泄露配置内容。
|
|
96
|
+
$hadError = $true
|
|
97
|
+
Write-SyncLog '本轮未同步;请检查 QQ 配置、SSH 连接和阿里云服务状态'
|
|
98
|
+
}
|
|
99
|
+
if ($Once) { break }
|
|
100
|
+
Start-Sleep -Seconds 30
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
$mutex.ReleaseMutex()
|
|
104
|
+
$mutex.Dispose()
|
|
105
|
+
}
|
|
106
|
+
if ($Once -and $hadError) { exit 1 }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# 游戏机主动连接阿里云,只在云机回环地址暴露 db-server 转发端口。
|
|
2
|
+
$logPath = Join-Path $PSScriptRoot 'qq-tunnel.log'
|
|
3
|
+
|
|
4
|
+
while ($true) {
|
|
5
|
+
if ((Test-Path -LiteralPath $logPath) -and (Get-Item -LiteralPath $logPath).Length -gt 1048576) {
|
|
6
|
+
Clear-Content -LiteralPath $logPath
|
|
7
|
+
}
|
|
8
|
+
"$(Get-Date -Format o) 尝试建立 QQ 反向隧道" | Add-Content -LiteralPath $logPath
|
|
9
|
+
& 'C:\Windows\System32\OpenSSH\ssh.exe' -N -T `
|
|
10
|
+
-o BatchMode=yes -o ConnectTimeout=10 -o ExitOnForwardFailure=yes `
|
|
11
|
+
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 `
|
|
12
|
+
-R 127.0.0.1:13001:127.0.0.1:3001 aliyun 2>> $logPath
|
|
13
|
+
"$(Get-Date -Format o) SSH 退出,代码 $LASTEXITCODE" | Add-Content -LiteralPath $logPath
|
|
14
|
+
Start-Sleep -Seconds 5
|
|
15
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/** 游戏机通过 SSH 标准输入提交共享 QQ 配置;只在本机原子更新云端桥配置。 */
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { createPrivateKey, createPublicKey, randomBytes, verify } from "node:crypto";
|
|
4
|
+
import { chownSync, chmodSync, readFileSync, renameSync, statSync, writeFileSync, rmSync } from "node:fs";
|
|
5
|
+
import { request } from "node:http";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
|
|
8
|
+
const CONFIG_PATH = "/var/lib/sfmc-qq-bridge/configs/qq_config.json";
|
|
9
|
+
const SERVICE = "sfmc-qq-bridge.service";
|
|
10
|
+
const PKCS8_ED25519_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
11
|
+
|
|
12
|
+
function service(action) {
|
|
13
|
+
execFileSync("systemctl", [action, SERVICE], { stdio: "ignore", timeout: 15_000 });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readConfig() {
|
|
17
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function privateKey(secret) {
|
|
21
|
+
const source = Buffer.from(secret, "utf8");
|
|
22
|
+
const seed = Buffer.alloc(32);
|
|
23
|
+
for (let i = 0; i < seed.length; i++) seed[i] = source[i % source.length];
|
|
24
|
+
return createPrivateKey({ key: Buffer.concat([PKCS8_ED25519_PREFIX, seed]), format: "der", type: "pkcs8" });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function checkChallenge(config) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const eventTs = String(Math.floor(Date.now() / 1000));
|
|
30
|
+
const plainToken = randomBytes(12).toString("hex");
|
|
31
|
+
const body = JSON.stringify({ op: 13, d: { event_ts: eventTs, plain_token: plainToken } });
|
|
32
|
+
const req = request(
|
|
33
|
+
{
|
|
34
|
+
hostname: "127.0.0.1",
|
|
35
|
+
port: 3005,
|
|
36
|
+
path: "/qqbot/webhook",
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
"Content-Length": Buffer.byteLength(body),
|
|
41
|
+
"X-Bot-Appid": config.official.app_id,
|
|
42
|
+
},
|
|
43
|
+
timeout: 1500,
|
|
44
|
+
},
|
|
45
|
+
(res) => {
|
|
46
|
+
let answer = "";
|
|
47
|
+
res.setEncoding("utf8");
|
|
48
|
+
res.on("data", (chunk) => (answer += chunk));
|
|
49
|
+
res.on("end", () => {
|
|
50
|
+
try {
|
|
51
|
+
const value = JSON.parse(answer);
|
|
52
|
+
const signature = String(value.signature ?? "");
|
|
53
|
+
const valid =
|
|
54
|
+
res.statusCode === 200 &&
|
|
55
|
+
value.plain_token === plainToken &&
|
|
56
|
+
/^[0-9a-f]{128}$/i.test(signature) &&
|
|
57
|
+
verify(
|
|
58
|
+
null,
|
|
59
|
+
Buffer.from(eventTs + plainToken),
|
|
60
|
+
createPublicKey(privateKey(config.official.app_secret)),
|
|
61
|
+
Buffer.from(signature, "hex")
|
|
62
|
+
);
|
|
63
|
+
if (valid) resolve();
|
|
64
|
+
else reject(new Error("回调校验未通过"));
|
|
65
|
+
} catch {
|
|
66
|
+
reject(new Error("回调响应无效"));
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
req.on("timeout", () => req.destroy(new Error("回调超时")));
|
|
72
|
+
req.on("error", reject);
|
|
73
|
+
req.end(body);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function waitUntilHealthy(config) {
|
|
78
|
+
let lastError;
|
|
79
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
80
|
+
try {
|
|
81
|
+
service("is-active");
|
|
82
|
+
await checkChallenge(config);
|
|
83
|
+
return;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
lastError = error;
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
throw lastError ?? new Error("服务未就绪");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function writeAtomic(value, previousStat) {
|
|
93
|
+
const temporary = join(dirname(CONFIG_PATH), `.qq_config.sync-${process.pid}.tmp`);
|
|
94
|
+
try {
|
|
95
|
+
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
96
|
+
chownSync(temporary, previousStat.uid, previousStat.gid);
|
|
97
|
+
chmodSync(temporary, 0o640);
|
|
98
|
+
renameSync(temporary, CONFIG_PATH);
|
|
99
|
+
} finally {
|
|
100
|
+
rmSync(temporary, { force: true });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function expectString(value, name, required = false) {
|
|
105
|
+
if (typeof value !== "string" || (required && !value.trim())) throw new Error(`${name} 无效`);
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sharedFields(payload) {
|
|
110
|
+
const source = payload.shared;
|
|
111
|
+
if (!source || typeof source !== "object" || Array.isArray(source)) throw new Error("共享配置无效");
|
|
112
|
+
const official = source.official;
|
|
113
|
+
if (!official || typeof official !== "object" || Array.isArray(official)) throw new Error("official 无效");
|
|
114
|
+
if (!Array.isArray(official.admin_openids) || !official.admin_openids.every((id) => typeof id === "string")) {
|
|
115
|
+
throw new Error("official.admin_openids 无效");
|
|
116
|
+
}
|
|
117
|
+
if (typeof official.sandbox !== "boolean" || typeof official.sync_menu_panel !== "boolean") {
|
|
118
|
+
throw new Error("QQ 开关配置无效");
|
|
119
|
+
}
|
|
120
|
+
const publicServer = source.public_server;
|
|
121
|
+
if (publicServer && (typeof publicServer !== "object" || Array.isArray(publicServer))) {
|
|
122
|
+
throw new Error("public_server 无效");
|
|
123
|
+
}
|
|
124
|
+
const publicAddress = expectString(publicServer?.address ?? "", "public_server.address");
|
|
125
|
+
const publicVersion = expectString(publicServer?.version ?? "", "public_server.version");
|
|
126
|
+
const publicPort = publicServer?.port ?? 19132;
|
|
127
|
+
if (!Number.isInteger(publicPort) || publicPort < 1 || publicPort > 65535) {
|
|
128
|
+
throw new Error("public_server.port 无效");
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
official: {
|
|
132
|
+
app_id: expectString(official.app_id, "official.app_id", true),
|
|
133
|
+
app_secret: expectString(official.app_secret, "official.app_secret", true),
|
|
134
|
+
sandbox: official.sandbox,
|
|
135
|
+
group_openid: expectString(official.group_openid, "official.group_openid"),
|
|
136
|
+
admin_openids: official.admin_openids,
|
|
137
|
+
sync_menu_panel: official.sync_menu_panel,
|
|
138
|
+
},
|
|
139
|
+
public_server: { address: publicAddress, port: publicPort, version: publicVersion },
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function main() {
|
|
144
|
+
if (process.getuid?.() !== 0) throw new Error("同步程序必须由云端 root 执行");
|
|
145
|
+
if (process.argv[2] === "--health") {
|
|
146
|
+
await waitUntilHealthy(readConfig());
|
|
147
|
+
process.stdout.write("Webhook 回调校验通过\n");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
let input = "";
|
|
151
|
+
for await (const part of process.stdin) {
|
|
152
|
+
input += part;
|
|
153
|
+
if (input.length > 65_536) throw new Error("同步请求过大");
|
|
154
|
+
}
|
|
155
|
+
const payload = JSON.parse(input);
|
|
156
|
+
if (payload?.mode === "websocket") {
|
|
157
|
+
service("stop");
|
|
158
|
+
process.stdout.write("云端 Webhook 已停止\n");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (payload?.mode !== "webhook") throw new Error("接收方式无效");
|
|
162
|
+
|
|
163
|
+
const shared = sharedFields(payload);
|
|
164
|
+
const current = readConfig();
|
|
165
|
+
const next = {
|
|
166
|
+
...current,
|
|
167
|
+
public_server: shared.public_server,
|
|
168
|
+
qq_enabled: true,
|
|
169
|
+
qq_backend: "official",
|
|
170
|
+
official: {
|
|
171
|
+
...current.official,
|
|
172
|
+
...shared.official,
|
|
173
|
+
transport: "webhook",
|
|
174
|
+
webhook: { port: 3005, path: "/qqbot/webhook" },
|
|
175
|
+
group_panel_id:
|
|
176
|
+
current.official?.group_openid === shared.official.group_openid ? current.official?.group_panel_id ?? "" : "",
|
|
177
|
+
},
|
|
178
|
+
db_host: "127.0.0.1",
|
|
179
|
+
db_port: 13001,
|
|
180
|
+
};
|
|
181
|
+
delete next.bridge_channel_id;
|
|
182
|
+
delete next.mctoqq_prefix;
|
|
183
|
+
if (JSON.stringify(next) === JSON.stringify(current)) {
|
|
184
|
+
try {
|
|
185
|
+
service("is-active");
|
|
186
|
+
await checkChallenge(current);
|
|
187
|
+
} catch {
|
|
188
|
+
service("start");
|
|
189
|
+
await waitUntilHealthy(current);
|
|
190
|
+
}
|
|
191
|
+
process.stdout.write("云端配置未变化,服务正常\n");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 停止写回面板 ID 的进程后重读,避免覆盖同步期间刚生成的 ID。
|
|
196
|
+
service("stop");
|
|
197
|
+
let stoppedConfig;
|
|
198
|
+
let previousStat;
|
|
199
|
+
try {
|
|
200
|
+
stoppedConfig = readConfig();
|
|
201
|
+
next.official.group_panel_id =
|
|
202
|
+
stoppedConfig.official?.group_openid === shared.official.group_openid
|
|
203
|
+
? stoppedConfig.official?.group_panel_id ?? ""
|
|
204
|
+
: "";
|
|
205
|
+
previousStat = statSync(CONFIG_PATH);
|
|
206
|
+
const backupPath = `${CONFIG_PATH}.bak-sync`;
|
|
207
|
+
writeFileSync(backupPath, readFileSync(CONFIG_PATH), { mode: 0o600 });
|
|
208
|
+
chmodSync(backupPath, 0o600);
|
|
209
|
+
writeAtomic(next, previousStat);
|
|
210
|
+
service("restart");
|
|
211
|
+
await waitUntilHealthy(next);
|
|
212
|
+
process.stdout.write("云端 QQ 配置已更新,Webhook 校验通过\n");
|
|
213
|
+
} catch {
|
|
214
|
+
try {
|
|
215
|
+
if (stoppedConfig && previousStat) writeAtomic(stoppedConfig, previousStat);
|
|
216
|
+
service("restart");
|
|
217
|
+
if (stoppedConfig) await waitUntilHealthy(stoppedConfig);
|
|
218
|
+
} catch {
|
|
219
|
+
throw new Error("云端配置应用失败,回滚未完成,请检查服务");
|
|
220
|
+
}
|
|
221
|
+
throw new Error("云端配置应用失败,已恢复上一版");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
main().catch((error) => {
|
|
226
|
+
// 错误消息只包含字段名和状态,不输出收到的配置或凭据。
|
|
227
|
+
process.stderr.write(`QQ 配置同步失败:${error.message}\n`);
|
|
228
|
+
process.exitCode = 1;
|
|
229
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
function cell(value) {
|
|
2
|
+
return value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ").trim() || "\u2014";
|
|
3
|
+
}
|
|
4
|
+
function balanceText(profile) {
|
|
5
|
+
if (profile.balance == null) return "\u6682\u65F6\u8BFB\u4E0D\u5230";
|
|
6
|
+
const unit = profile.unit?.trim() || "\u8282\u64CD";
|
|
7
|
+
return `${profile.balance} ${unit}`;
|
|
8
|
+
}
|
|
9
|
+
function formatAccountCard(opts) {
|
|
10
|
+
if (!opts.bound) {
|
|
11
|
+
const text2 = ["\u6211\u7684\u8D26\u53F7", "", "\u7ED1\u5B9A\u72B6\u6001\uFF1A\u672A\u7ED1\u5B9A"].join("\n");
|
|
12
|
+
return { text: text2, markdown: ["## \u6211\u7684\u8D26\u53F7", "", "\u7ED1\u5B9A\u72B6\u6001\uFF1A\u672A\u7ED1\u5B9A"].join("\n") };
|
|
13
|
+
}
|
|
14
|
+
const name = opts.playerName?.trim() || "\u672A\u547D\u540D\u89D2\u8272";
|
|
15
|
+
const head = ["\u7ED1\u5B9A\u72B6\u6001\uFF1A\u5DF2\u7ED1\u5B9A", `\u6E38\u620F\u89D2\u8272\uFF1A${name}`];
|
|
16
|
+
if (!opts.profile) {
|
|
17
|
+
const text2 = ["\u6211\u7684\u8D26\u53F7", "", ...head].join("\n");
|
|
18
|
+
return { text: text2, markdown: ["## \u6211\u7684\u8D26\u53F7", "", ...head].join("\n") };
|
|
19
|
+
}
|
|
20
|
+
const rows = [
|
|
21
|
+
["\u4F59\u989D", balanceText(opts.profile)],
|
|
22
|
+
["\u4ECA\u65E5\u5728\u7EBF", opts.profile.today_text || "\u6682\u65F6\u8BFB\u4E0D\u5230"],
|
|
23
|
+
["\u672C\u6708\u5728\u7EBF", opts.profile.month_text || "\u6682\u65F6\u8BFB\u4E0D\u5230"],
|
|
24
|
+
["\u7D2F\u8BA1\u5728\u7EBF", opts.profile.total_text || "\u6682\u65F6\u8BFB\u4E0D\u5230"]
|
|
25
|
+
];
|
|
26
|
+
const text = ["\u6211\u7684\u8D26\u53F7", "", ...head, "", ...rows.map(([label, value]) => `${label}\uFF1A${value}`)].join("\n");
|
|
27
|
+
const markdown = [
|
|
28
|
+
"## \u6211\u7684\u8D26\u53F7",
|
|
29
|
+
"",
|
|
30
|
+
...head,
|
|
31
|
+
"",
|
|
32
|
+
"| \u9879\u76EE | \u6570\u503C |",
|
|
33
|
+
"| --- | --- |",
|
|
34
|
+
...rows.map(([label, value]) => `| ${cell(label ?? "")} | ${cell(value ?? "")} |`)
|
|
35
|
+
].join("\n");
|
|
36
|
+
return { text, markdown };
|
|
37
|
+
}
|
|
38
|
+
export {
|
|
39
|
+
formatAccountCard
|
|
40
|
+
};
|
|
41
|
+
//# sourceMappingURL=account-format.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/commands/account-format.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * account-format.ts \u2014 QQ\u300C\u6211\u7684\u8D26\u53F7\u300D\u6B63\u6587\u3002\n *\n * \u5DF2\u7ED1\u5B9A\u65F6\u7528 Markdown \u8868\u683C\u5217\u51FA\u4F59\u989D\u548C\u5728\u7EBF\u65F6\u957F\uFF1B\u7EAF\u6587\u672C\u4FDD\u7559\u540C\u6837\u7684\u5B57\u6BB5\uFF0C\u4F9B Markdown \u53D1\u9001\u5931\u8D25\u65F6\u964D\u7EA7\u3002\n */\n\nexport type AccountProfileView = {\n unit?: string;\n balance?: number | null;\n today_text?: string;\n month_text?: string;\n total_text?: string;\n};\n\nfunction cell(value: string): string {\n return value.replace(/\\|/g, \"\\\\|\").replace(/\\r?\\n/g, \" \").trim() || \"\u2014\";\n}\n\nfunction balanceText(profile: AccountProfileView): string {\n if (profile.balance == null) return \"\u6682\u65F6\u8BFB\u4E0D\u5230\";\n const unit = profile.unit?.trim() || \"\u8282\u64CD\";\n return `${profile.balance} ${unit}`;\n}\n\n/** \u8D26\u53F7\u9875\u7684\u7EAF\u6587\u672C\u548C Markdown\u3002\u672A\u7ED1\u5B9A\u6216\u65E7\u63A5\u53E3\u6CA1\u6709 profile \u65F6\u4E0D\u7F16\u9020\u6570\u5B57\u3002 */\nexport function formatAccountCard(opts: {\n bound: boolean;\n playerName?: string;\n profile?: AccountProfileView | null;\n}): { text: string; markdown: string } {\n if (!opts.bound) {\n const text = [\"\u6211\u7684\u8D26\u53F7\", \"\", \"\u7ED1\u5B9A\u72B6\u6001\uFF1A\u672A\u7ED1\u5B9A\"].join(\"\\n\");\n return { text, markdown: [\"## \u6211\u7684\u8D26\u53F7\", \"\", \"\u7ED1\u5B9A\u72B6\u6001\uFF1A\u672A\u7ED1\u5B9A\"].join(\"\\n\") };\n }\n const name = opts.playerName?.trim() || \"\u672A\u547D\u540D\u89D2\u8272\";\n const head = [\"\u7ED1\u5B9A\u72B6\u6001\uFF1A\u5DF2\u7ED1\u5B9A\", `\u6E38\u620F\u89D2\u8272\uFF1A${name}`];\n if (!opts.profile) {\n const text = [\"\u6211\u7684\u8D26\u53F7\", \"\", ...head].join(\"\\n\");\n return { text, markdown: [\"## \u6211\u7684\u8D26\u53F7\", \"\", ...head].join(\"\\n\") };\n }\n const rows = [\n [\"\u4F59\u989D\", balanceText(opts.profile)],\n [\"\u4ECA\u65E5\u5728\u7EBF\", opts.profile.today_text || \"\u6682\u65F6\u8BFB\u4E0D\u5230\"],\n [\"\u672C\u6708\u5728\u7EBF\", opts.profile.month_text || \"\u6682\u65F6\u8BFB\u4E0D\u5230\"],\n [\"\u7D2F\u8BA1\u5728\u7EBF\", opts.profile.total_text || \"\u6682\u65F6\u8BFB\u4E0D\u5230\"],\n ];\n const text = [\"\u6211\u7684\u8D26\u53F7\", \"\", ...head, \"\", ...rows.map(([label, value]) => `${label}\uFF1A${value}`)].join(\"\\n\");\n const markdown = [\n \"## \u6211\u7684\u8D26\u53F7\",\n \"\",\n ...head,\n \"\",\n \"| \u9879\u76EE | \u6570\u503C |\",\n \"| --- | --- |\",\n ...rows.map(([label, value]) => `| ${cell(label ?? \"\")} | ${cell(value ?? \"\")} |`),\n ].join(\"\\n\");\n return { text, markdown };\n}\n"],
|
|
5
|
+
"mappings": "AAcA,SAAS,KAAK,OAAuB;AACnC,SAAO,MAAM,QAAQ,OAAO,KAAK,EAAE,QAAQ,UAAU,GAAG,EAAE,KAAK,KAAK;AACtE;AAEA,SAAS,YAAY,SAAqC;AACxD,MAAI,QAAQ,WAAW,KAAM,QAAO;AACpC,QAAM,OAAO,QAAQ,MAAM,KAAK,KAAK;AACrC,SAAO,GAAG,QAAQ,OAAO,IAAI,IAAI;AACnC;AAGO,SAAS,kBAAkB,MAIK;AACrC,MAAI,CAAC,KAAK,OAAO;AACf,UAAMA,QAAO,CAAC,4BAAQ,IAAI,kDAAU,EAAE,KAAK,IAAI;AAC/C,WAAO,EAAE,MAAAA,OAAM,UAAU,CAAC,+BAAW,IAAI,kDAAU,EAAE,KAAK,IAAI,EAAE;AAAA,EAClE;AACA,QAAM,OAAO,KAAK,YAAY,KAAK,KAAK;AACxC,QAAM,OAAO,CAAC,oDAAY,iCAAQ,IAAI,EAAE;AACxC,MAAI,CAAC,KAAK,SAAS;AACjB,UAAMA,QAAO,CAAC,4BAAQ,IAAI,GAAG,IAAI,EAAE,KAAK,IAAI;AAC5C,WAAO,EAAE,MAAAA,OAAM,UAAU,CAAC,+BAAW,IAAI,GAAG,IAAI,EAAE,KAAK,IAAI,EAAE;AAAA,EAC/D;AACA,QAAM,OAAO;AAAA,IACX,CAAC,gBAAM,YAAY,KAAK,OAAO,CAAC;AAAA,IAChC,CAAC,4BAAQ,KAAK,QAAQ,cAAc,gCAAO;AAAA,IAC3C,CAAC,4BAAQ,KAAK,QAAQ,cAAc,gCAAO;AAAA,IAC3C,CAAC,4BAAQ,KAAK,QAAQ,cAAc,gCAAO;AAAA,EAC7C;AACA,QAAM,OAAO,CAAC,4BAAQ,IAAI,GAAG,MAAM,IAAI,GAAG,KAAK,IAAI,CAAC,CAAC,OAAO,KAAK,MAAM,GAAG,KAAK,SAAI,KAAK,EAAE,CAAC,EAAE,KAAK,IAAI;AACtG,QAAM,WAAW;AAAA,IACf;AAAA,IACA;AAAA,IACA,GAAG;AAAA,IACH;AAAA,IACA;AAAA,IACA;AAAA,IACA,GAAG,KAAK,IAAI,CAAC,CAAC,OAAO,KAAK,MAAM,KAAK,KAAK,SAAS,EAAE,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC,IAAI;AAAA,EACnF,EAAE,KAAK,IAAI;AACX,SAAO,EAAE,MAAM,SAAS;AAC1B;",
|
|
6
|
+
"names": ["text"]
|
|
7
|
+
}
|