fix: 修复NapCat已登录误判旧二维码复用

This commit is contained in:
sunlei 2026-06-23 09:33:17 +08:00
parent 0497233798
commit 3647ec9a7c
11 changed files with 362 additions and 5 deletions

View File

@ -12,6 +12,8 @@ RUN set -eux; \
fonts-wqy-microhei \ fonts-wqy-microhei \
locales \ locales \
tzdata \ tzdata \
unzip \
zip \
xdg-utils; \ xdg-utils; \
sed -i 's/^# *zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen; \ sed -i 's/^# *zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen; \
locale-gen zh_CN.UTF-8; \ locale-gen zh_CN.UTF-8; \
@ -20,6 +22,18 @@ RUN set -eux; \
fc-cache -fv; \ fc-cache -fv; \
rm -rf /var/lib/apt/lists/* 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 \ ENV LANG=zh_CN.UTF-8 \
LC_ALL=zh_CN.UTF-8 \ LC_ALL=zh_CN.UTF-8 \
LANGUAGE=zh_CN:zh \ LANGUAGE=zh_CN:zh \

View File

@ -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.' } if (-not $baseImage) { throw 'NapCat upstream image digest not found; pull and inspect the image before building.' }
docker build ` docker build `
--build-arg NAPCAT_BASE_IMAGE=$baseImage ` --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 . -f ci/napcat-desktop-cn/Dockerfile .
``` ```
Verify: Verify:
```powershell ```bash
docker run --rm kt-napcat-desktop-cn:desktop-cn-v1 sh /ci/napcat-desktop-cn/verify.sh 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 digest in `QQBOT_NAPCAT_IMAGE`.

View File

@ -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 ' ')"

View File

@ -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 "XDG_DATA_HOME=${XDG_DATA_HOME:-}" = "XDG_DATA_HOME=/app/.local/share"
test ! -e /.dockerenv test ! -e /.dockerenv
grep -q '^0::/$' /proc/1/cgroup 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)'

View File

@ -33,7 +33,7 @@ spec:
- name: DB_TIMEZONE - name: DB_TIMEZONE
value: "+08:00" value: "+08:00"
- name: QQBOT_NAPCAT_IMAGE - 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 - name: QQBOT_NAPCAT_SSH_KEY_PATH
value: /app/secrets/napcat-ssh/id_rsa value: /app/secrets/napcat-ssh/id_rsa
- name: QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX - name: QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX

View File

@ -596,6 +596,7 @@ export class ToolsService {
'NapCat 请求超时', 'NapCat 请求超时',
'NapCat 未返回登录二维码', 'NapCat 未返回登录二维码',
'NapCat 二维码仍未刷新', 'NapCat 二维码仍未刷新',
'NapCat WebUI 登录态仍阻止生成新二维码',
'QRCode Get Error', 'QRCode Get Error',
'socket hang up', 'socket hang up',
]); ]);

View File

@ -2110,6 +2110,9 @@ export class QqbotNapcatLoginService {
return this.toolsService.ensureFreshQrcode(qrcode, options); return this.toolsService.ensureFreshQrcode(qrcode, options);
} catch (err) { } catch (err) {
if (this.toolsService.isNapcatAlreadyLoggedInError(err)) { if (this.toolsService.isNapcatAlreadyLoggedInError(err)) {
if (options.requireFresh) {
throw new Error('NapCat WebUI 登录态仍阻止生成新二维码');
}
const status = await this.getLoginStatus(container); const status = await this.getLoginStatus(container);
return this.toolsService.ensureFreshQrcode( return this.toolsService.ensureFreshQrcode(
status.qrcodeurl || '', status.qrcodeurl || '',
@ -2405,6 +2408,13 @@ export class QqbotNapcatLoginService {
return false; return false;
} }
} catch (err) { } catch (err) {
if (this.toolsService.isNapcatAlreadyLoggedInError(err)) {
return this.completeAlreadyLoggedInQuickRelogin(
session,
container,
hasPasswordFallback,
);
}
this.publishQuickLoginFallback( this.publishQuickLoginFallback(
session, session,
this.toolsService.getErrorMessage(err), this.toolsService.getErrorMessage(err),
@ -2420,6 +2430,81 @@ export class QqbotNapcatLoginService {
return true; 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 * NapCat
* @param session - MD5 * @param session - MD5

View File

@ -71,7 +71,7 @@ export class NapcatRuntimeProfileService {
dataDir: input.dataDir, dataDir: input.dataDir,
desktopProfileVersion: this.getString( desktopProfileVersion: this.getString(
'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION', 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION',
'desktop-cn-v1', 'desktop-cn-v2',
), ),
deviceIdentityId: input.deviceIdentityId, deviceIdentityId: input.deviceIdentityId,
imageRef: this.getString('QQBOT_NAPCAT_IMAGE', ''), imageRef: this.getString('QQBOT_NAPCAT_IMAGE', ''),

View File

@ -28,6 +28,8 @@ describe('NapCat Chinese Desktop Runtime image assets', () => {
expect(dockerfile).toContain('fontconfig'); expect(dockerfile).toContain('fontconfig');
expect(dockerfile).toContain('fc-cache -fv'); expect(dockerfile).toContain('fc-cache -fv');
expect(dockerfile).toContain('dbus-x11'); expect(dockerfile).toContain('dbus-x11');
expect(dockerfile).toContain('unzip');
expect(dockerfile).toContain('zip');
}); });
it('verifies locale, fontconfig, XDG, process user, and container hiding evidence', () => { 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('/proc/1/cgroup');
expect(verify).toContain('XDG_CONFIG_HOME=/app/.config'); expect(verify).toContain('XDG_CONFIG_HOME=/app/.config');
expect(verify).toContain('Asia/Shanghai'); 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\\$]');
}); });
}); });

View File

@ -123,6 +123,7 @@ describe('NapCat runtime profile generation', () => {
}); });
expect(profile).toMatchObject({ expect(profile).toMatchObject({
desktopProfileVersion: 'desktop-cn-v2',
imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest', imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest',
locale: 'zh_CN.UTF-8', locale: 'zh_CN.UTF-8',
runtimeGid: 1101, runtimeGid: 1101,

View File

@ -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 () => { it('does not rebuild even when the refresh session already has a rebuild count', async () => {
const container = { const container = {
baseUrl: 'http://127.0.0.1:6103/', baseUrl: 'http://127.0.0.1:6103/',
@ -3456,6 +3588,23 @@ describe('QqbotNapcatLoginService', () => {
expect((service as any).postNapcat).toHaveBeenCalledTimes(2); 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 () => { it('does not replace current qrcode with expired status qrcode', async () => {
(service as any).sessions.set('session-2', { (service as any).sessions.set('session-2', {
containerId: 'container-2', containerId: 'container-2',