feat: 接入NapCat源码构建镜像
This commit is contained in:
parent
2a6f418a62
commit
85199ebfe5
2
API.md
2
API.md
@ -377,6 +377,8 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
|
||||
|
||||
该接口只返回脱敏后的运行态证据,供 Admin 排查镜像、locale、shm、配置 hash、漂移状态、风险模式和 watchdog 巡检告警状态;不会返回 WebUI token、reverse WS token、QQ 登录密码、SSH 私钥或运行态密码环境。账号列表只挂载 `napcat.profileStatus`、`napcat.runtimeProfile` 等摘要字段,不触发登录、重建或修复动作。watchdog 不执行登录恢复:遇到 QQ 登录态离线只记录离线原因并通知 `super`,登录恢复统一由 Admin 手动「更新登录」触发;session behavior profile 只做冷启动、housekeeping、presence 和自动能力分阶段降载,不实现账号级每小时/每日累计发送预算。
|
||||
|
||||
NapCat Chinese Desktop Runtime v3 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v3` digest。
|
||||
|
||||
`napcat_login_event` 实体和表仅作为历史 schema 兼容保留;watchdog 不再写入 quick/password 恢复事件,也不再依赖该表判断是否恢复登录。
|
||||
|
||||
外发消息不直接抢发:后端会按 `QQBOT_SEND_GLOBAL_INTERVAL_MS`、`QQBOT_SEND_TARGET_INTERVAL_MS` 和 `QQBOT_SEND_JITTER_MS` 预约发送窗口,默认全局 2500ms、同会话 8000ms、抖动 0-800ms;如果等待超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS`,本次发送会在下发前被拒绝。在线命令和自动回复规则会叠加运行时保底冷却,默认命令 5000ms、规则 30000ms;复读机默认连续 4 次相同普通文本才触发,同一会话默认 10 分钟内只复读一次,并限制普通文本长度,减少自动行为被风控识别的概率。
|
||||
|
||||
@ -77,7 +77,7 @@ QQBot 插件定时任务由 manifest 的 `tasks` 声明,平台持久化到 `qq
|
||||
|
||||
Admin 环境总览面板使用 `ENV_DASHBOARD_*` 只读配置聚合 local-dev、NAS 线上、腾讯云和 r4se 状态。`ENV_DASHBOARD_ADMIN_LOCAL_URL` / `ENV_DASHBOARD_ADMIN_PUBLIC_URL` 只用于展示 Admin 本机与线上入口证据。HTTP 快照提供当前拓扑,后端 local/MQTT 事件总线通过 SSE 推送增量事件给 Admin;前端不直连 MQTT,也不轮询刷新。Jenkins、K8s、Tencent Cloud、Caddy、WireGuard、Mihomo/OpenClash 未配置时会显示 `unwired` 证据,不能渲染成健康假象;第一版不暴露重启、部署、迁移、容器重建、插件启停或代理切换等写操作。
|
||||
|
||||
NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布和账号闭环按 `docs/superpowers/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md` 的 Task 10 执行。当前实现覆盖运行态/协议/会话行为/历史登录事件兼容表/风险模式表,真实物理设备风格 hostname/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。
|
||||
NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布和账号闭环按 `docs/superpowers/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md` 的 Task 10 执行。当前实现覆盖运行态/协议/会话行为/历史登录事件兼容表/风险模式表,真实物理设备风格 hostname/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v3 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact;镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v3` digest。
|
||||
|
||||
## 启动
|
||||
|
||||
@ -165,7 +165,7 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow
|
||||
- QQBot 插件平台统一使用 `plugin.json` manifest 描述插件 key、版本、操作、事件、权限、运行预算和包入口;CLI 负责 create/validate/pack/install-local,后端只暴露受控 SDK 能力并通过插件维度记录安装、配置、账号绑定和运行事件。
|
||||
- Bilibili Card 是事件型内置插件:`bilibili-card.message` 只在账号绑定后监听 QQ/NapCat `share/json/xml/lightapp` 卡片或文本里的 Bilibili 链接,`b23.tv` 短链通过平台 `resolveRedirect` 受控 host 能力解析,视频信息从 Bilibili `x/web-interface/view` 获取后回复纯文本摘要。
|
||||
- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表拆开展示 OneBot、容器、WebUI 和 QQ 登录态,心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态;`qqLoginMessage` 只展示 QQ 登录态消息,WebUI 配置或请求错误留在 `lastError`。
|
||||
- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。
|
||||
- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。`desktop-cn-v3` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch。
|
||||
- NapCat 账号新增/编辑支持可选 QQ 登录密码:Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。
|
||||
- NapCat 容器为已知 `selfId` 创建/重建时会一次性注入 `ACCOUNT` 等必要 env;容器重启(崩溃/重启策略/宿主重启)可复用持久化会话,但硬踢 `登录已失效` 仍需人工登录。Admin「更新登录」不通过 Docker 重建、重启或补 env 刷新登录态:只要源容器在线,就保持同一容器并通过 NapCat WebUI `SetQuickLogin -> PasswordLogin -> RefreshQRcode/GetQQLoginQrcode` 推进原弹窗流程;只有 Docker 容器离线或缺失时,容器准备阶段才创建/重建并一次性注入 env。快速登录失败后,如果账号保存了登录密码,后端使用解密密码计算 MD5 调 `/api/QQLogin/PasswordLogin`,不会把密码写入运行态 env,也没有成功后的 env 清理步骤;密码登录按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果。准备中的扫码会话会续期,重复调用更新登录会复用同一 pending `sessionId`,不会再次启动 quick/password/二维码准备。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS` 后会自动恢复为普通状态检测。验证码和新设备验证保持同一会话 pending:腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`;状态轮询遇到验证码文案但缺少 URL 时会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。Admin SSE 步骤顺序按实际路径为 `quick-login-*` -> `password-login-*` / `password-login-captcha` / 新设备验证 -> `qrcode/waiting-scan` -> `login-success|login-failed`;SSE 事件缓存因 Pod 重启丢失时,新订阅会收到当前会话快照。
|
||||
- NapCat 设备身份按账号持久化到 `napcat_device_identity`:同一账号重建容器会复用数据目录、`pc-<8hex>` hostname、machine-id 和实体 OUI 风格 MAC,明确排除 Docker `02:42`、QEMU/KVM `52:54:00`、VMware、Hyper-V 等虚拟化前缀;新增账号首次扫码会先用预留容器 id 创建临时设备身份并应用到第一次 Docker run,扫码成功后归属到真实账号并同步 runtime/protocol profile;Docker run 会注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id`,并同步写入 QQNT Linux `machine-info`,使 `/etc/machine-id`、Docker MAC 和 QQNT 本地设备缓存保持一致。当前策略名为 `qqnt-visible-hostname-v1` / `physical-oui-mac-v1`。
|
||||
|
||||
@ -22,15 +22,13 @@ RUN set -eux; \
|
||||
fc-cache -fv; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY ci/napcat-desktop-cn/patches/qq-login-real-online-guard.sh /tmp/qq-login-real-online-guard.sh
|
||||
COPY NapCat.Shell /tmp/NapCat.Shell
|
||||
COPY ci/napcat-desktop-cn/fork-artifact.json /ci/napcat-desktop-cn/fork-artifact.json
|
||||
RUN set -eux; \
|
||||
sed -i 's/\r$//' /tmp/qq-login-real-online-guard.sh; \
|
||||
rm -rf /tmp/NapCat.Shell; \
|
||||
unzip -q /app/NapCat.Shell.zip -d /tmp/NapCat.Shell; \
|
||||
NAPCAT_PATCH_ROOT=/tmp/NapCat.Shell sh /tmp/qq-login-real-online-guard.sh; \
|
||||
cd /tmp/NapCat.Shell; \
|
||||
zip -qr /app/NapCat.Shell.zip .; \
|
||||
rm -rf /tmp/NapCat.Shell /tmp/qq-login-real-online-guard.sh
|
||||
sha256sum /app/NapCat.Shell.zip | awk '{print $1}' > /ci/napcat-desktop-cn/NapCat.Shell.zip.sha256; \
|
||||
rm -rf /tmp/NapCat.Shell
|
||||
COPY ci/napcat-desktop-cn/verify.sh /ci/napcat-desktop-cn/verify.sh
|
||||
RUN sed -i 's/\r$//' /ci/napcat-desktop-cn/verify.sh && chmod +x /ci/napcat-desktop-cn/verify.sh
|
||||
|
||||
|
||||
@ -1,24 +1,38 @@
|
||||
# NapCat Chinese Desktop Runtime Image
|
||||
|
||||
Build from the locally inspected upstream digest:
|
||||
This image consumes a source-built NapCatQQ Shell artifact staged from `D:\MyFiles\KT\GitHub\NapCatQQ`.
|
||||
|
||||
Build NapCatQQ first:
|
||||
|
||||
```powershell
|
||||
corepack pnpm --dir D:\MyFiles\KT\GitHub\NapCatQQ install --frozen-lockfile
|
||||
corepack pnpm --dir D:\MyFiles\KT\GitHub\NapCatQQ --filter napcat-webui-frontend run build
|
||||
corepack pnpm --dir D:\MyFiles\KT\GitHub\NapCatQQ run build:shell
|
||||
```
|
||||
|
||||
Stage Docker build context:
|
||||
|
||||
```powershell
|
||||
node scripts/napcat-desktop-cn-stage-build.mjs `
|
||||
--napcat-root D:\MyFiles\KT\GitHub\NapCatQQ `
|
||||
--out .kt-workspace\napcat-desktop-cn-build
|
||||
```
|
||||
|
||||
Build and verify:
|
||||
|
||||
```powershell
|
||||
$baseImage = docker image inspect mlikiowa/napcat-docker:latest --format '{{index .RepoDigests 0}}'
|
||||
if (-not $baseImage) { throw 'NapCat upstream image digest not found; pull and inspect the image before building.' }
|
||||
docker build `
|
||||
--build-arg NAPCAT_BASE_IMAGE=$baseImage `
|
||||
-t kt-napcat-desktop-cn:desktop-cn-v2 `
|
||||
-f ci/napcat-desktop-cn/Dockerfile .
|
||||
-t kt-napcat-desktop-cn:desktop-cn-v3 `
|
||||
-f .kt-workspace/napcat-desktop-cn-build/ci/napcat-desktop-cn/Dockerfile `
|
||||
.kt-workspace/napcat-desktop-cn-build
|
||||
|
||||
$name = "kt-napcat-v3-verify-$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())"
|
||||
docker run -d --name $name kt-napcat-desktop-cn:desktop-cn-v3
|
||||
docker exec $name sh /ci/napcat-desktop-cn/verify.sh
|
||||
docker rm -f $name
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
name="kt-napcat-verify-$(date +%s)"
|
||||
trap 'docker rm -f "$name" >/dev/null 2>&1 || true' EXIT
|
||||
docker run -d --name "$name" kt-napcat-desktop-cn:desktop-cn-v2 >/dev/null
|
||||
sleep 3
|
||||
docker exec "$name" sh /ci/napcat-desktop-cn/verify.sh
|
||||
```
|
||||
|
||||
Record the final digest in `QQBOT_NAPCAT_IMAGE`.
|
||||
Record the final image digest in `QQBOT_NAPCAT_IMAGE`.
|
||||
|
||||
@ -1,65 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ROOT="${NAPCAT_PATCH_ROOT:-/app/napcat}"
|
||||
PATCHED_LIST="$(mktemp)"
|
||||
PERL_PATCH="$(mktemp)"
|
||||
trap 'rm -f "$PATCHED_LIST" "$PERL_PATCH"' EXIT
|
||||
|
||||
cat > "$PERL_PATCH" <<'PERL'
|
||||
use strict;
|
||||
use warnings;
|
||||
|
||||
my ($file) = @ARGV;
|
||||
open my $in, '<', $file or die "read $file failed: $!";
|
||||
local $/;
|
||||
my $source = <$in>;
|
||||
close $in;
|
||||
|
||||
exit 0 if index($source, 'QQ Is Logined') < 0;
|
||||
exit 0 if index($source, 'RefreshQRcode') < 0;
|
||||
exit 0 if index($source, 'getQQLoginStatus') < 0;
|
||||
|
||||
my ($runtime) = $source =~ /([A-Za-z_\$][\w\$]*)\.getOneBotContext\(\)\?\.core\?\.selfInfo\?\.online,\s*[A-Za-z_\$][\w\$]*\s*=\s*\1\.getQQLoginStatus\(\)/;
|
||||
die "Unable to identify NapCat WebUI runtime guard symbols in $file\n" unless $runtime;
|
||||
|
||||
my ($send_error) = $source =~ /return\s+([A-Za-z_\$][\w\$]*)\(e,\s*"QQ Is Logined"\)/;
|
||||
die "Unable to identify NapCat sendError helper in $file\n" unless $send_error;
|
||||
|
||||
my $count = 0;
|
||||
$count += $source =~ s/if \(\Q$runtime\E\.getQQLoginStatus\(\)\)\n(\s*)return \Q$send_error\E\(e, "QQ Is Logined"\);/
|
||||
"if ($runtime.getQQLoginStatus() && $runtime.getOneBotContext()?.core?.selfInfo?.online !== false)\n"
|
||||
. "$1return $send_error(e, \"QQ Is Logined\");\n"
|
||||
. " if ($runtime.getQQLoginStatus() && $runtime.getOneBotContext()?.core?.selfInfo?.online === false)\n"
|
||||
. "$1$runtime.setQQLoginStatus(false);"
|
||||
/ge;
|
||||
|
||||
my $refresh_pattern = qr/\Q$runtime\E\.getQQLoginStatus\(\) \? \Q$send_error\E\(e, "QQ Is Logined"\) : \(await \Q$runtime\E\.refreshQRCode\(\),/;
|
||||
my $refresh_replacement =
|
||||
"$runtime.getQQLoginStatus() && $runtime.getOneBotContext()?.core?.selfInfo?.online !== false "
|
||||
. "? $send_error(e, \"QQ Is Logined\") "
|
||||
. ": ($runtime.getQQLoginStatus() && $runtime.getOneBotContext()?.core?.selfInfo?.online === false && $runtime.setQQLoginStatus(false), await $runtime.refreshQRCode(),";
|
||||
$count += $source =~ s/$refresh_pattern/$refresh_replacement/g;
|
||||
|
||||
exit 0 if $count == 0;
|
||||
open my $out, '>', $file or die "write $file failed: $!";
|
||||
print {$out} $source;
|
||||
close $out;
|
||||
print "$file\n";
|
||||
PERL
|
||||
|
||||
find "$ROOT" \
|
||||
-type f \( -name '*.js' -o -name '*.mjs' \) \
|
||||
! -path '*/node_modules/*' \
|
||||
! -path '*/static/*' \
|
||||
-size -20M \
|
||||
-print | while IFS= read -r file; do
|
||||
perl "$PERL_PATCH" "$file" >> "$PATCHED_LIST"
|
||||
done
|
||||
|
||||
if [ ! -s "$PATCHED_LIST" ]; then
|
||||
echo 'No NapCat WebUI login guard file was patched' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Patched NapCat WebUI real-online login guard in %s file(s).\n' "$(wc -l < "$PATCHED_LIST" | tr -d ' ')"
|
||||
@ -1,6 +1,10 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
MARKER=/ci/napcat-desktop-cn/fork-artifact.json
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
locale -a | grep -i '^zh_CN.utf8$'
|
||||
locale | grep 'LANG=zh_CN.UTF-8'
|
||||
test "$(cat /etc/timezone)" = "Asia/Shanghai"
|
||||
@ -10,5 +14,20 @@ test "XDG_CACHE_HOME=${XDG_CACHE_HOME:-}" = "XDG_CACHE_HOME=/app/.cache"
|
||||
test "XDG_DATA_HOME=${XDG_DATA_HOME:-}" = "XDG_DATA_HOME=/app/.local/share"
|
||||
test ! -e /.dockerenv
|
||||
grep -q '^0::/$' /proc/1/cgroup
|
||||
unzip -p /app/NapCat.Shell.zip napcat.mjs | grep -q 'selfInfo?.online !== false'
|
||||
unzip -p /app/NapCat.Shell.zip napcat.mjs | grep -q 'setQQLoginStatus(false)'
|
||||
|
||||
test -s "$MARKER"
|
||||
grep -q '"upstreamBaseCommit"' "$MARKER"
|
||||
grep -q '"forkCommit"' "$MARKER"
|
||||
grep -q '"napcatMjsSha256"' "$MARKER"
|
||||
test -s /app/NapCat.Shell.zip
|
||||
test -s /ci/napcat-desktop-cn/NapCat.Shell.zip.sha256
|
||||
|
||||
unzip -q /app/NapCat.Shell.zip -d "$TMP_DIR"
|
||||
test -s "$TMP_DIR/napcat.mjs"
|
||||
|
||||
EXPECTED_MJS_SHA="$(sed -n 's/.*"napcatMjsSha256"[[:space:]]*:[[:space:]]*"\([a-f0-9]\{64\}\)".*/\1/p' "$MARKER")"
|
||||
ACTUAL_MJS_SHA="$(sha256sum "$TMP_DIR/napcat.mjs" | awk '{print $1}')"
|
||||
test "$EXPECTED_MJS_SHA" = "$ACTUAL_MJS_SHA"
|
||||
|
||||
grep -R -q 'getQQLoginRuntimeState' "$TMP_DIR"
|
||||
grep -R -q 'qrcodeRevision' "$TMP_DIR"
|
||||
|
||||
179
scripts/napcat-desktop-cn-stage-build.mjs
Normal file
179
scripts/napcat-desktop-cn-stage-build.mjs
Normal file
@ -0,0 +1,179 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const DEFAULT_OUTPUT = '.kt-workspace/napcat-desktop-cn-build';
|
||||
const DEFAULT_UPSTREAM_BASE = '5c18a62530d87dbadf53d267002894faa6ca7e90';
|
||||
|
||||
/**
|
||||
* Reads a named CLI argument in `--key value` form.
|
||||
* @param {string} name - Argument name without the leading dashes.
|
||||
* @param {string} fallback - Value used when the argument is absent.
|
||||
* @returns {string} Parsed argument value.
|
||||
*/
|
||||
function readArg(name, fallback = '') {
|
||||
const index = process.argv.indexOf(`--${name}`);
|
||||
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a SHA256 digest for a file.
|
||||
* @param {string} filePath - Absolute path to the file being fingerprinted.
|
||||
* @returns {string} Lowercase hex SHA256 digest.
|
||||
*/
|
||||
function sha256File(filePath) {
|
||||
return createHash('sha256').update(readFileSync(filePath)).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists directory entries in stable order.
|
||||
* @param {string} directory - Directory to list.
|
||||
* @returns {string[]} Sorted child names.
|
||||
*/
|
||||
function listDirectory(directory) {
|
||||
return readdirSync(directory).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively computes a stable digest for a directory from relative paths and file contents.
|
||||
* @param {string} directory - Absolute directory path.
|
||||
* @returns {string} Lowercase hex SHA256 digest.
|
||||
*/
|
||||
function sha256Directory(directory) {
|
||||
const hash = createHash('sha256');
|
||||
const stack = [directory];
|
||||
const files = [];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
for (const entry of listDirectory(current)) {
|
||||
const absolute = join(current, entry);
|
||||
if (statSync(absolute).isDirectory()) {
|
||||
stack.push(absolute);
|
||||
} else {
|
||||
files.push(absolute);
|
||||
}
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
for (const file of files) {
|
||||
hash.update(relative(directory, file).split(sep).join('/'));
|
||||
hash.update('\0');
|
||||
hash.update(readFileSync(file));
|
||||
hash.update('\0');
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the current git commit for the NapCat fork.
|
||||
* @param {string} repoRoot - Absolute repository path.
|
||||
* @returns {string} Current commit hash.
|
||||
*/
|
||||
function gitCommit(repoRoot) {
|
||||
return execFileSync('git', ['-C', repoRoot, 'rev-parse', 'HEAD'], {
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies one file or directory into the staged Docker context.
|
||||
* @param {string} source - Source path.
|
||||
* @param {string} target - Target path.
|
||||
*/
|
||||
function copyIntoContext(source, target) {
|
||||
cpSync(source, target, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a candidate path is inside an expected parent directory.
|
||||
* @param {string} parent - Absolute parent directory that owns the allowed subtree.
|
||||
* @param {string} candidate - Absolute path requested by the caller.
|
||||
* @returns {boolean} Whether candidate is inside parent and is not parent itself.
|
||||
*/
|
||||
function isInsideDirectory(parent, candidate) {
|
||||
const relativePath = relative(parent, candidate);
|
||||
return Boolean(relativePath) && !relativePath.startsWith('..') && !isAbsolute(relativePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects recursive-delete targets outside the API `.kt-workspace` staging area.
|
||||
* @param {string} outputRootToCheck - Absolute output path that will be cleaned and regenerated.
|
||||
* @param {string} apiRoot - Absolute API repository root.
|
||||
* @param {string} napcatRootToCheck - Absolute NapCatQQ fork path passed as source input.
|
||||
*/
|
||||
function assertSafeOutputRoot(outputRootToCheck, apiRoot, napcatRootToCheck) {
|
||||
const workspaceRoot = resolve(apiRoot, '.kt-workspace');
|
||||
const forbiddenRoots = new Set([
|
||||
parse(outputRootToCheck).root,
|
||||
resolve(apiRoot),
|
||||
resolve(apiRoot, '..', '..'),
|
||||
workspaceRoot,
|
||||
resolve(napcatRootToCheck),
|
||||
dirname(resolve(napcatRootToCheck)),
|
||||
]);
|
||||
|
||||
if (forbiddenRoots.has(resolve(outputRootToCheck))) {
|
||||
throw new Error(`Refusing to delete unsafe output root: ${outputRootToCheck}`);
|
||||
}
|
||||
if (!isInsideDirectory(workspaceRoot, outputRootToCheck)) {
|
||||
throw new Error(`Output root must stay inside ${workspaceRoot}`);
|
||||
}
|
||||
}
|
||||
|
||||
const apiRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||||
const napcatRootArg = readArg('napcat-root');
|
||||
const napcatRoot = resolve(napcatRootArg);
|
||||
const outputRoot = resolve(apiRoot, readArg('out', DEFAULT_OUTPUT));
|
||||
const upstreamBaseCommit = readArg('upstream-base-commit', DEFAULT_UPSTREAM_BASE);
|
||||
const shellDist = resolve(napcatRoot, 'packages/napcat-shell/dist');
|
||||
const napcatMjs = resolve(shellDist, 'napcat.mjs');
|
||||
|
||||
if (!napcatRootArg || !existsSync(napcatRoot)) {
|
||||
throw new Error('--napcat-root must point to the NapCatQQ fork repository');
|
||||
}
|
||||
if (!existsSync(napcatMjs)) {
|
||||
throw new Error(`NapCat shell build output is missing: ${napcatMjs}`);
|
||||
}
|
||||
assertSafeOutputRoot(outputRoot, apiRoot, napcatRoot);
|
||||
|
||||
rmSync(outputRoot, { force: true, recursive: true });
|
||||
mkdirSync(resolve(outputRoot, 'ci/napcat-desktop-cn'), { recursive: true });
|
||||
copyIntoContext(
|
||||
resolve(apiRoot, 'ci/napcat-desktop-cn/Dockerfile'),
|
||||
resolve(outputRoot, 'ci/napcat-desktop-cn/Dockerfile'),
|
||||
);
|
||||
copyIntoContext(
|
||||
resolve(apiRoot, 'ci/napcat-desktop-cn/verify.sh'),
|
||||
resolve(outputRoot, 'ci/napcat-desktop-cn/verify.sh'),
|
||||
);
|
||||
copyIntoContext(shellDist, resolve(outputRoot, 'NapCat.Shell'));
|
||||
|
||||
const marker = {
|
||||
builtAt: new Date().toISOString(),
|
||||
distSha256: sha256Directory(shellDist),
|
||||
forkCommit: gitCommit(napcatRoot),
|
||||
napcatMjsSha256: sha256File(napcatMjs),
|
||||
upstreamBaseCommit,
|
||||
};
|
||||
|
||||
writeFileSync(
|
||||
resolve(outputRoot, 'ci/napcat-desktop-cn/fork-artifact.json'),
|
||||
`${JSON.stringify(marker, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
marker,
|
||||
outputRoot,
|
||||
}, null, 2)}\n`);
|
||||
@ -71,7 +71,7 @@ export class NapcatRuntimeProfileService {
|
||||
dataDir: input.dataDir,
|
||||
desktopProfileVersion: this.getString(
|
||||
'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION',
|
||||
'desktop-cn-v2',
|
||||
'desktop-cn-v3',
|
||||
),
|
||||
deviceIdentityId: input.deviceIdentityId,
|
||||
imageRef: this.getString('QQBOT_NAPCAT_IMAGE', ''),
|
||||
|
||||
@ -41,39 +41,35 @@ describe('NapCat Chinese Desktop Runtime image assets', () => {
|
||||
expect(verify).toContain('/proc/1/cgroup');
|
||||
expect(verify).toContain('XDG_CONFIG_HOME=/app/.config');
|
||||
expect(verify).toContain('Asia/Shanghai');
|
||||
expect(verify).toContain('selfInfo?.online !== false');
|
||||
expect(verify).toContain('setQQLoginStatus(false)');
|
||||
});
|
||||
|
||||
it('patches NapCat WebUI login guards to allow qrcode refresh after real QQ offline', () => {
|
||||
const dockerfile = readSource('ci/napcat-desktop-cn/Dockerfile');
|
||||
expect(dockerfile).toContain(
|
||||
'ci/napcat-desktop-cn/patches/qq-login-real-online-guard.sh',
|
||||
);
|
||||
expect(dockerfile).toContain(
|
||||
'sh /tmp/qq-login-real-online-guard.sh',
|
||||
);
|
||||
expect(dockerfile).toContain(
|
||||
"sed -i 's/\\r$//' /tmp/qq-login-real-online-guard.sh",
|
||||
);
|
||||
expect(dockerfile).toContain('NAPCAT_PATCH_ROOT=/tmp/NapCat.Shell');
|
||||
expect(dockerfile).toContain('zip -qr /app/NapCat.Shell.zip .');
|
||||
expect(dockerfile).toContain(
|
||||
'COPY ci/napcat-desktop-cn/verify.sh /ci/napcat-desktop-cn/verify.sh',
|
||||
);
|
||||
expect(dockerfile).toContain(
|
||||
"sed -i 's/\\r$//' /ci/napcat-desktop-cn/verify.sh",
|
||||
);
|
||||
it('stages source-built NapCat Shell artifacts for Docker build context', () => {
|
||||
const script = readSource('scripts/napcat-desktop-cn-stage-build.mjs');
|
||||
|
||||
const patch = readSource(
|
||||
'ci/napcat-desktop-cn/patches/qq-login-real-online-guard.sh',
|
||||
);
|
||||
expect(patch).toContain('QQ Is Logined');
|
||||
expect(patch).toContain('getQQLoginStatus');
|
||||
expect(patch).toContain('selfInfo?.online');
|
||||
expect(patch).toContain('setQQLoginStatus(false)');
|
||||
expect(patch).toContain('RefreshQRcode');
|
||||
expect(patch).toContain('[A-Za-z_\\$]');
|
||||
expect(patch).toContain('[\\w\\$]');
|
||||
expect(script).toContain('napcatMjsSha256');
|
||||
expect(script).toContain('forkCommit');
|
||||
expect(script).toContain('upstreamBaseCommit');
|
||||
expect(script).toContain('packages/napcat-shell/dist');
|
||||
expect(script).toContain('fork-artifact.json');
|
||||
expect(script).toContain('.kt-workspace/napcat-desktop-cn-build');
|
||||
expect(script).toContain('assertSafeOutputRoot');
|
||||
expect(script).toContain('workspaceRoot');
|
||||
expect(script).toContain('Output root must stay inside');
|
||||
expect(script).toContain('Refusing to delete unsafe output root');
|
||||
});
|
||||
|
||||
it('uses source-built NapCat Shell artifact instead of bundled JS patching', () => {
|
||||
const dockerfile = readSource('ci/napcat-desktop-cn/Dockerfile');
|
||||
const verify = readSource('ci/napcat-desktop-cn/verify.sh');
|
||||
|
||||
expect(dockerfile).toContain('COPY NapCat.Shell /tmp/NapCat.Shell');
|
||||
expect(dockerfile).toContain('fork-artifact.json');
|
||||
expect(dockerfile).toContain('zip -qr /app/NapCat.Shell.zip .');
|
||||
expect(dockerfile).not.toContain('qq-login-real-online-guard.sh');
|
||||
expect(dockerfile).not.toContain('NAPCAT_PATCH_ROOT');
|
||||
expect(verify).toContain('napcatMjsSha256');
|
||||
expect(verify).toContain('getQQLoginRuntimeState');
|
||||
expect(verify).toContain('qrcodeRevision');
|
||||
expect(verify).not.toContain('selfInfo?.online !== false');
|
||||
});
|
||||
});
|
||||
|
||||
@ -123,7 +123,7 @@ describe('NapCat runtime profile generation', () => {
|
||||
});
|
||||
|
||||
expect(profile).toMatchObject({
|
||||
desktopProfileVersion: 'desktop-cn-v2',
|
||||
desktopProfileVersion: 'desktop-cn-v3',
|
||||
imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest',
|
||||
locale: 'zh_CN.UTF-8',
|
||||
runtimeGid: 1101,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user