diff --git a/ci/napcat-desktop-cn/Dockerfile b/ci/napcat-desktop-cn/Dockerfile index f2c8d41..476e6b0 100644 --- a/ci/napcat-desktop-cn/Dockerfile +++ b/ci/napcat-desktop-cn/Dockerfile @@ -12,6 +12,8 @@ RUN set -eux; \ fonts-wqy-microhei \ locales \ tzdata \ + unzip \ + zip \ xdg-utils; \ sed -i 's/^# *zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen; \ locale-gen zh_CN.UTF-8; \ @@ -20,6 +22,18 @@ 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 +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 +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 + ENV LANG=zh_CN.UTF-8 \ LC_ALL=zh_CN.UTF-8 \ LANGUAGE=zh_CN:zh \ diff --git a/ci/napcat-desktop-cn/README.md b/ci/napcat-desktop-cn/README.md index a9c91bb..77c7d13 100644 --- a/ci/napcat-desktop-cn/README.md +++ b/ci/napcat-desktop-cn/README.md @@ -7,14 +7,18 @@ $baseImage = docker image inspect mlikiowa/napcat-docker:latest --format '{{inde 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-v1 ` + -t kt-napcat-desktop-cn:desktop-cn-v2 ` -f ci/napcat-desktop-cn/Dockerfile . ``` Verify: -```powershell -docker run --rm kt-napcat-desktop-cn:desktop-cn-v1 sh /ci/napcat-desktop-cn/verify.sh +```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`. diff --git a/ci/napcat-desktop-cn/patches/qq-login-real-online-guard.sh b/ci/napcat-desktop-cn/patches/qq-login-real-online-guard.sh new file mode 100644 index 0000000..6a32c38 --- /dev/null +++ b/ci/napcat-desktop-cn/patches/qq-login-real-online-guard.sh @@ -0,0 +1,65 @@ +#!/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 ' ')" diff --git a/ci/napcat-desktop-cn/verify.sh b/ci/napcat-desktop-cn/verify.sh index c53a247..d3c941a 100644 --- a/ci/napcat-desktop-cn/verify.sh +++ b/ci/napcat-desktop-cn/verify.sh @@ -10,3 +10,5 @@ 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)' diff --git a/k8s/prod/api.yaml b/k8s/prod/api.yaml index 511956c..9219d1e 100644 --- a/k8s/prod/api.yaml +++ b/k8s/prod/api.yaml @@ -33,7 +33,7 @@ spec: - name: DB_TIMEZONE value: "+08:00" - name: QQBOT_NAPCAT_IMAGE - value: kt-napcat-desktop-cn:desktop-cn-v1 + value: kt-napcat-desktop-cn:desktop-cn-v2 - name: QQBOT_NAPCAT_SSH_KEY_PATH value: /app/secrets/napcat-ssh/id_rsa - name: QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX diff --git a/src/common/services/tool.service.ts b/src/common/services/tool.service.ts index a7fa835..fe7b109 100644 --- a/src/common/services/tool.service.ts +++ b/src/common/services/tool.service.ts @@ -596,6 +596,7 @@ export class ToolsService { 'NapCat 请求超时', 'NapCat 未返回登录二维码', 'NapCat 二维码仍未刷新', + 'NapCat WebUI 登录态仍阻止生成新二维码', 'QRCode Get Error', 'socket hang up', ]); diff --git a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts index 0f331d7..4c6ea43 100644 --- a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts +++ b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts @@ -2110,6 +2110,9 @@ export class QqbotNapcatLoginService { return this.toolsService.ensureFreshQrcode(qrcode, options); } catch (err) { if (this.toolsService.isNapcatAlreadyLoggedInError(err)) { + if (options.requireFresh) { + throw new Error('NapCat WebUI 登录态仍阻止生成新二维码'); + } const status = await this.getLoginStatus(container); return this.toolsService.ensureFreshQrcode( status.qrcodeurl || '', @@ -2405,6 +2408,13 @@ export class QqbotNapcatLoginService { return false; } } catch (err) { + if (this.toolsService.isNapcatAlreadyLoggedInError(err)) { + return this.completeAlreadyLoggedInQuickRelogin( + session, + container, + hasPasswordFallback, + ); + } this.publishQuickLoginFallback( session, this.toolsService.getErrorMessage(err), @@ -2420,6 +2430,81 @@ export class QqbotNapcatLoginService { return true; } + /** + * 在 NapCat WebUI 拒绝重复 quick 登录时读取真实 QQ 在线态。 + * @param session - 当前更新登录会话;成功时直接完成会话,失败时写入 fallback 进度。 + * @param container - 当前 NapCat WebUI 容器;只读调用 CheckLoginStatus/GetQQLoginInfo。 + * @param hasPasswordFallback - 是否还有密码登录分支;决定 fallback 文案里的下一步。 + * @returns 真实 QQ 在线且账号匹配时返回 true,否则返回 false 继续后续登录分支。 + */ + private async completeAlreadyLoggedInQuickRelogin( + session: QqbotLoginScanSession, + container: QqbotNapcatRuntime, + hasPasswordFallback: boolean, + ) { + this.publishScanResultEvent( + session, + 'quick-login-status-check', + 'processing', + 'NapCat 报告账号已登录,正在确认真实在线状态', + ); + + let loginInfo: NapcatLoginInfo; + try { + const loginStatus = await this.getLoginStatus(container, true); + if (!loginStatus.isLogin) { + await this.syncSessionQqLoginStatus(session, loginStatus); + this.publishQuickLoginFallback( + session, + loginStatus.loginError || 'NapCat 已登录标记残留但真实 QQ 已离线', + hasPasswordFallback, + ); + return false; + } + + loginInfo = await this.getLoginInfo(container); + if (loginInfo.online === false) { + this.publishQuickLoginFallback( + session, + 'NapCat 已登录标记残留但真实 QQ 已离线', + hasPasswordFallback, + ); + return false; + } + + const selfId = this.toolsService.pickNapcatSelfId(loginInfo); + if (!selfId) { + this.publishQuickLoginFallback( + session, + 'NapCat 未返回 QQ 号', + hasPasswordFallback, + ); + return false; + } + if (session.expectedSelfId && session.expectedSelfId !== selfId) { + this.publishQuickLoginFallback( + session, + `当前已登录账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`, + hasPasswordFallback, + ); + return false; + } + } catch (err) { + this.publishQuickLoginFallback( + session, + this.toolsService.getErrorMessage(err), + hasPasswordFallback, + ); + return false; + } + + await this.completeLogin(session, container, { + loginInfo, + successMessage: 'NapCat 已登录,已确认真实在线状态', + }); + return true; + } + /** * 执行 NapCat 登录运行态流程。 * @param session - 更新登录会话;保存密码 MD5、验证码和新设备验证上下文。 diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts index b95651a..09ffb39 100644 --- a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts +++ b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts @@ -71,7 +71,7 @@ export class NapcatRuntimeProfileService { dataDir: input.dataDir, desktopProfileVersion: this.getString( 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION', - 'desktop-cn-v1', + 'desktop-cn-v2', ), deviceIdentityId: input.deviceIdentityId, imageRef: this.getString('QQBOT_NAPCAT_IMAGE', ''), diff --git a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts index 1177910..12ce489 100644 --- a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts +++ b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts @@ -28,6 +28,8 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(dockerfile).toContain('fontconfig'); expect(dockerfile).toContain('fc-cache -fv'); expect(dockerfile).toContain('dbus-x11'); + expect(dockerfile).toContain('unzip'); + expect(dockerfile).toContain('zip'); }); it('verifies locale, fontconfig, XDG, process user, and container hiding evidence', () => { @@ -39,5 +41,39 @@ 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", + ); + + 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\\$]'); }); }); diff --git a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts index 5527de6..765570c 100644 --- a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts +++ b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts @@ -123,6 +123,7 @@ describe('NapCat runtime profile generation', () => { }); expect(profile).toMatchObject({ + desktopProfileVersion: 'desktop-cn-v2', imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest', locale: 'zh_CN.UTF-8', runtimeGid: 1101, diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index 4b113bf..c9eba09 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -894,6 +894,138 @@ describe('QqbotNapcatLoginService', () => { ); }); + it('confirms real login status when quick login reports the account is already logged in', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-quick-already-online', + name: 'napcat-10001', + }; + const accountService = { + ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), + }; + const containerService = { + bindAccount: jest.fn().mockResolvedValue(undefined), + ensureRuntimeLoginEnv: jest.fn(), + resetRuntimeLoginState: jest.fn(), + restartRuntimeContainer: jest.fn(), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (refreshService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '10001', + mode: 'refresh', + preparingRelogin: true, + status: 'pending', + }); + (refreshService as any).sessions.set(session.id, session); + mockWebuiLoginPost(refreshService, { + quickError: 'QQ Is Logined', + }); + const getLoginStatus = jest + .spyOn(refreshService as any, 'getLoginStatus') + .mockResolvedValue({ isLogin: true }); + jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ + nickname: 'Kwi', + online: true, + uin: '10001', + }); + const refreshQrcode = jest + .spyOn(refreshService as any, 'refreshOrGetQrcode') + .mockResolvedValue('fallback-qrcode'); + + await (refreshService as any).prepareReloginQrcode(session, container); + + expect(getLoginStatus).toHaveBeenCalledWith(container, true); + expect(refreshQrcode).not.toHaveBeenCalled(); + expect(accountService.ensureScannedAccount).toHaveBeenCalledWith({ + accountId: 'account-1', + name: 'Kwi', + selfId: '10001', + }); + expect(containerService.bindAccount).toHaveBeenCalledWith( + 'account-1', + 'container-quick-already-online', + '10001', + ); + expect(session.status).toBe('success'); + const events = (refreshService as any).sessionEventLogs.get(session.id); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ step: 'quick-login-start' }), + expect.objectContaining({ + message: 'NapCat 已登录,已确认真实在线状态', + step: 'login-success', + }), + ]), + ); + }); + + it('keeps binding errors terminal after already-logged quick status is confirmed online', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-quick-already-bind-error', + name: 'napcat-10001', + }; + const accountService = { + ensureScannedAccount: jest + .fn() + .mockRejectedValue(new Error('账号绑定写入失败')), + }; + const containerService = { + bindAccount: jest.fn().mockResolvedValue(undefined), + resetRuntimeLoginState: jest.fn(), + restartRuntimeContainer: jest.fn(), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (refreshService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '10001', + mode: 'refresh', + preparingRelogin: true, + status: 'pending', + }); + (refreshService as any).sessions.set(session.id, session); + mockWebuiLoginPost(refreshService, { + quickError: 'QQ Is Logined', + }); + jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ + isLogin: true, + }); + jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ + nickname: 'Kwi', + online: true, + uin: '10001', + }); + const refreshQrcode = jest + .spyOn(refreshService as any, 'refreshOrGetQrcode') + .mockResolvedValue('fallback-qrcode'); + + await (refreshService as any).prepareReloginQrcode(session, container); + + expect(refreshQrcode).not.toHaveBeenCalled(); + expect(session.status).toBe('error'); + expect(session.qrcode).toBeUndefined(); + expect(session.errorMessage).toBe('账号绑定写入失败'); + const steps = ( + (refreshService as any).sessionEventLogs.get(session.id) ?? [] + ).map((event: { step: string }) => event.step); + expect(steps).toContain('quick-login-status-check'); + expect(steps).not.toContain('quick-login-fallback'); + expect(steps).toContain('relogin-error'); + }); + it('does not rebuild even when the refresh session already has a rebuild count', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', @@ -3456,6 +3588,23 @@ describe('QqbotNapcatLoginService', () => { expect((service as any).postNapcat).toHaveBeenCalledTimes(2); }); + it('rejects stale status qrcode when NapCat still blocks qrcode generation as already logged in', async () => { + jest + .spyOn(service as any, 'postNapcat') + .mockRejectedValue(new Error('QQ Is Logined')); + jest.spyOn(service as any, 'getLoginStatus').mockResolvedValue({ + isLogin: false, + isOffline: true, + qrcodeurl: 'old-status-qrcode', + }); + + await expect( + (service as any).getQrcode({ id: 'container-stale-login-guard' }, false, { + requireFresh: true, + }), + ).rejects.toThrow('NapCat WebUI 登录态仍阻止生成新二维码'); + }); + it('does not replace current qrcode with expired status qrcode', async () => { (service as any).sessions.set('session-2', { containerId: 'container-2',