diff --git a/API.md b/API.md index 5366ab9..57c0bb5 100644 --- a/API.md +++ b/API.md @@ -345,8 +345,12 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。已有账号的更新登录会先重启目标 NapCat 容器尝试 `ACCOUNT`/`-q` 快速登录;目标账号在线则直接完成会话。没有历史登录态的新容器会跳过快速登录,优先尝试保存的登录密码;快速登录失败后,如果账号保存了登录密码,会临时注入 `NAPCAT_QUICK_PASSWORD` 并按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询密码登录结果,准备阶段的扫码会话会持续续期,避免后台密码登录未完成时前端先判过期。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。会话已有 `captchaUrl` 后,`/qqbot/account/scan/status` 遇到 NapCat 继续返回“需要验证码/继续完成验证/安全验证”但不带 URL 时仍保持 `pending` 和原 `captchaUrl`。密码登录成功后会重建容器移除该运行态密码,清理失败则本次登录失败;密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,再进入重置登录态和二维码兜底流程。看门狗自动登录使用同样的 quick -> password 顺序,但不会自动进入扫码阶段。 +密码验证码通过后如果 NapCat 返回 `needNewDevice`,后端不会只把 `jumpUrl` 透给 Admin,而是在同一会话中继续调用 `/api/QQLogin/GetNewDeviceQRCode` 生成新设备验证二维码;`/qqbot/account/scan/status` 后续轮询会代理 `/api/QQLogin/PollNewDeviceQR`,状态映射为 `newDeviceStatus=qr-pending|scanned|confirming|verified|expired|failed`,进入确认态后再调用 `/api/QQLogin/NewDeviceLogin` 并回到密码登录完成检查。扫码会话结果新增 `newDeviceQrcode`、`newDeviceStatus`、`deviceVerifyUrl` 字段;`captchaUrl` 和 `newDeviceQrcode` 分别表示腾讯安全验证码和 QQ 新设备验证二维码,前端必须分开展示。SSE 进度文案包含快速登录、密码登录、验证码、新设备二维码、已扫码、确认中、登录成功/失败和运行态清理失败。 + 同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器,后端会释放旧绑定和未共享的旧容器,避免同账号多实例互相挤下线。OneBot notice 只有机器人下线、登录失效、`KickedOffLine` 等账号级信号才会记录 QQ 登录态异常并生成 `qqbot.account.offline` 站内信,普通群成员 kick 不属于账号离线信号。下线原因写入 `lastError` 前按 `last_error` 500 字符列宽截断;后续无错误的普通断连只更新 OneBot 连接状态,不清空该原因。账号列表会按近期缓存检查绑定 NapCat 容器的最新登录状态日志,日志检测默认 5 秒超时;`isOnline:false` 属于 QQ 登录态离线信号;心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态。托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像。 +托管 NapCat 容器按账号持久化设备身份,`napcat_device_identity` 保存账号对应的数据目录、hostname、machine-id 路径、MAC 地址、验证状态和最近登录证据。重建同一账号容器时会复用这些设备字段,并在 Docker run 中注入 `--hostname`、`--mac-address` 和只读 `/etc/machine-id` 挂载,降低每次重建都被 QQ 判定为新设备的概率。 + 外发消息不直接抢发:后端会按 `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 分钟内只复读一次,并限制普通文本长度,减少自动行为被风控识别的概率。 ### Command / Rule / Permission diff --git a/README.md b/README.md index 9d94ca0..76206f3 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,8 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow - NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。 - NapCat 账号新增/编辑支持可选 QQ 登录密码:Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。 - NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。`ACCOUNT` 只负责指定快速登录账号;如果数据卷内没有该 QQ 的历史登录记录,后端会跳过快速登录并优先尝试账号保存的登录密码,密码登录会按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果,准备中的扫码会话会续期,避免后台密码登录未结束时前端先过期;如果 NapCat 返回 `proofWaterUrl` 或日志出现“需要验证码”,会保持会话 pending 并把腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`,不是让用户只打开外链;密码登录成功后会移除运行态 `NAPCAT_QUICK_PASSWORD`,清理失败则本次登录失败,避免成功态残留明文 env;密码登录也失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时才重置登录态并生成二维码。Admin「更新登录」的 SSE 步骤顺序按实际路径为 `quick-login-*`(已有历史会话)-> `password-login-*` / `password-login-captcha` -> `password-env-cleanup` -> `relogin-reset/qrcode/waiting-scan`。 +- NapCat 设备身份按账号持久化到 `napcat_device_identity`:同一账号重建容器会复用数据目录、hostname、machine-id 和 MAC,并在 Docker run 中注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id`,避免频繁重建被 QQ 判定为全新设备。 +- NapCat 新设备验证走同一 scan session:`CaptchaLogin` 返回 `needNewDevice` 后,后端继续调用 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`,Admin/SSE 分开展示 `captchaUrl`、`newDeviceQrcode`、已扫码、确认中、验证成功、登录成功/失败等中文进度,不把 `jumpUrl` 当作唯一完成入口。 - NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000`,`QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后先尝试 `ACCOUNT` 历史会话快速登录,再尝试账号保存的登录密码,仍失败时写入离线原因并复用 `super` 站内信告警;看门狗不自动进入扫码阶段。 - BangDream 当前源码根目录是 `src/modules/qqbot/plugins/bangDream`;不要恢复旧 `tsugu` 层级或旧大桶目录。 - BangDream 在线命令以 `registry/operation-registry.ts` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 registry/command-SQL 测试。 diff --git a/src/modules/qqbot/core/qqbot-core.module.ts b/src/modules/qqbot/core/qqbot-core.module.ts index 920de21..2950864 100644 --- a/src/modules/qqbot/core/qqbot-core.module.ts +++ b/src/modules/qqbot/core/qqbot-core.module.ts @@ -25,6 +25,10 @@ import { QqbotDedupe } from '@/qqbot/dedupe/qqbot-dedupe.entity'; import { QqbotDedupeService } from '@/qqbot/dedupe/qqbot-dedupe.service'; import { QqbotEventService } from '@/qqbot/event/qqbot-event.service'; import { QqbotPluginHttpClientService } from '@/modules/qqbot/plugin-platform/sdk'; +import { + NapcatDeviceIdentity, + NapcatDeviceIdentityService, +} from '@/modules/qqbot/napcat'; import { QqbotConversation } from '@/qqbot/message/qqbot-conversation.entity'; import { QqbotMessageController } from '@/qqbot/message/qqbot-message.controller'; import { QqbotMessage } from '@/qqbot/message/qqbot-message.entity'; @@ -71,6 +75,7 @@ export const QQBOT_CORE_ENTITIES = [ QqbotConversation, QqbotDedupe, QqbotMessage, + NapcatDeviceIdentity, QqbotAccountNapcat, QqbotNapcatContainer, QqbotRule, @@ -108,6 +113,7 @@ export const QQBOT_CORE_PROVIDERS = [ QqbotFflogsClientService, QqbotFflogsPluginService, QqbotMessageService, + NapcatDeviceIdentityService, QqbotNapcatLoginService, QqbotNapcatWatchdogService, QqbotNapcatContainerService, @@ -125,6 +131,7 @@ export const QQBOT_CORE_PROVIDERS = [ export const QQBOT_CORE_EXPORTS = [ QqbotAccountService, + NapcatDeviceIdentityService, QqbotNapcatLoginService, QqbotNapcatContainerService, QqbotReverseWsService, diff --git a/src/modules/qqbot/napcat/container/napcat-docker-device-options.ts b/src/modules/qqbot/napcat/container/napcat-docker-device-options.ts new file mode 100644 index 0000000..b355b33 --- /dev/null +++ b/src/modules/qqbot/napcat/container/napcat-docker-device-options.ts @@ -0,0 +1,30 @@ +import type { NapcatDeviceIdentity } from '../device/napcat-device-identity.entity'; + +export type NapcatDockerDeviceOptions = { + dataDir: string; + deviceEnvPath: string; + hostname: string; + machineIdPath: string; + macAddress: string; + runFlags: string[]; +}; + +export function toNapcatDockerDeviceOptions( + identity: Pick< + NapcatDeviceIdentity, + 'dataDir' | 'hostname' | 'machineIdPath' | 'macAddress' + >, +): NapcatDockerDeviceOptions { + return { + dataDir: identity.dataDir, + deviceEnvPath: `${identity.dataDir}/device.env`, + hostname: identity.hostname, + machineIdPath: identity.machineIdPath, + macAddress: identity.macAddress, + runFlags: [ + '--hostname "$NAPCAT_HOSTNAME"', + '--mac-address "$NAPCAT_MAC_ADDRESS"', + '-v "$MACHINE_ID_PATH:/etc/machine-id:ro"', + ], + }; +} diff --git a/src/modules/qqbot/napcat/device/napcat-device-identity.entity.ts b/src/modules/qqbot/napcat/device/napcat-device-identity.entity.ts new file mode 100644 index 0000000..a7397b2 --- /dev/null +++ b/src/modules/qqbot/napcat/device/napcat-device-identity.entity.ts @@ -0,0 +1,56 @@ +import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { + ensureSnowflakeId, + KtCreateDateColumn, + KtDateTime, + KtUpdateDateColumn, +} from '@/common'; + +export type NapcatDeviceVerificationStatus = + | 'pending' + | 'trusted' + | 'unknown' + | 'unverified'; + +@Entity('napcat_device_identity') +@Index('uk_napcat_device_identity_account', ['accountId'], { unique: true }) +@Index('idx_napcat_device_identity_container', ['containerId']) +export class NapcatDeviceIdentity { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ name: 'account_id', type: 'bigint' }) + accountId: string; + + @Column({ default: null, name: 'container_id', nullable: true, type: 'bigint' }) + containerId: null | string; + + @Column({ length: 512, name: 'data_dir' }) + dataDir: string; + + @Column({ length: 128 }) + hostname: string; + + @Column({ length: 512, name: 'machine_id_path' }) + machineIdPath: string; + + @Column({ length: 64, name: 'mac_address' }) + macAddress: string; + + @Column({ length: 32, name: 'verification_status' }) + verificationStatus: NapcatDeviceVerificationStatus; + + @Column({ default: null, name: 'last_login_evidence', nullable: true, type: 'json' }) + lastLoginEvidence: null | Record; + + @KtCreateDateColumn({ name: 'create_time' }) + createTime: KtDateTime; + + @KtUpdateDateColumn({ name: 'update_time' }) + updateTime: KtDateTime; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/modules/qqbot/napcat/device/napcat-device-identity.service.ts b/src/modules/qqbot/napcat/device/napcat-device-identity.service.ts new file mode 100644 index 0000000..70dbdea --- /dev/null +++ b/src/modules/qqbot/napcat/device/napcat-device-identity.service.ts @@ -0,0 +1,101 @@ +import { createHash } from 'crypto'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ensureSnowflakeId } from '@/common'; +import { NapcatDeviceIdentity } from './napcat-device-identity.entity'; + +type ResolveNapcatDeviceIdentityInput = { + accountId: string; + containerId?: string; + selfId?: string; +}; + +@Injectable() +export class NapcatDeviceIdentityService { + constructor( + @InjectRepository(NapcatDeviceIdentity) + private readonly identityRepository: Repository, + private readonly configService: ConfigService, + ) {} + + async resolveForAccount(input: ResolveNapcatDeviceIdentityInput) { + const accountId = `${input.accountId}`.trim(); + const existing = await this.identityRepository.findOne({ + where: { accountId }, + }); + + if (existing) { + const containerId = input.containerId || null; + if (containerId && existing.containerId !== containerId) { + await this.identityRepository.update( + { id: existing.id }, + { containerId }, + ); + existing.containerId = containerId; + } + return existing; + } + + const containerName = this.buildContainerName(input.selfId || accountId); + const dataDir = `${this.getRootDir()}/${containerName}`; + const identity = this.identityRepository.create({ + accountId, + containerId: input.containerId || null, + dataDir, + hostname: this.buildHostname(containerName), + lastLoginEvidence: null, + macAddress: this.buildMacAddress(accountId, containerName), + machineIdPath: `${dataDir}/machine-id`, + verificationStatus: 'pending', + }); + ensureSnowflakeId(identity); + + return this.identityRepository.save(identity); + } + + private buildContainerName(seed: string) { + const prefix = this.getConfig( + 'QQBOT_NAPCAT_CONTAINER_PREFIX', + 'kt-qqbot-napcat', + ); + const suffix = `${seed || 'unknown'}` + .replace(/[^a-zA-Z0-9_.-]/g, '-') + .toLowerCase(); + return `${prefix}-${suffix}`.replace(/-+/g, '-').slice(0, 120); + } + + private buildHostname(containerName: string) { + const normalized = containerName + .replace(/[^a-zA-Z0-9-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + if (normalized.length <= 63) return normalized; + + const hash = createHash('sha256').update(containerName).digest('hex'); + return `${normalized.slice(0, 50)}-${hash.slice(0, 12)}`.slice(0, 63); + } + + private buildMacAddress(accountId: string, containerName: string) { + const hash = createHash('sha256') + .update(`${accountId}:${containerName}`) + .digest('hex'); + return ['02', '42', hash.slice(0, 2), hash.slice(2, 4), hash.slice(4, 6), hash.slice(6, 8)].join( + ':', + ); + } + + private getRootDir() { + return ( + this.getConfig( + 'QQBOT_NAPCAT_ROOT', + '/vol1/docker/kt-qqbot/napcat-instances', + ) + ).replace(/[\\/]+$/, ''); + } + + private getConfig(key: string, defaultValue = '') { + return `${this.configService.get(key) || defaultValue}`.trim(); + } +} diff --git a/src/modules/qqbot/napcat/index.ts b/src/modules/qqbot/napcat/index.ts new file mode 100644 index 0000000..0463a98 --- /dev/null +++ b/src/modules/qqbot/napcat/index.ts @@ -0,0 +1,6 @@ +export * from './container/napcat-docker-device-options'; +export * from './device/napcat-device-identity.entity'; +export * from './device/napcat-device-identity.service'; +export * from './integration/napcat-login-api.client'; +export * from './login/napcat-login-state-machine'; +export * from './persistence'; diff --git a/src/modules/qqbot/napcat/integration/napcat-login-api.client.ts b/src/modules/qqbot/napcat/integration/napcat-login-api.client.ts new file mode 100644 index 0000000..5385a57 --- /dev/null +++ b/src/modules/qqbot/napcat/integration/napcat-login-api.client.ts @@ -0,0 +1,109 @@ +export type NewDeviceQrStatus = + | 'confirming' + | 'expired' + | 'failed' + | 'qr-pending' + | 'scanned' + | 'verified'; + +export type NewDeviceQrCode = { + pullQrCodeSig?: string; + qrcodeUrl: string; + sessionId: string; + status: 'qr-pending'; +}; + +export type NewDeviceQrPollResult = { + message?: string; + sessionId: string; + status: Exclude; +}; + +export type NewDeviceLoginResult = { + message?: string; + sessionId: string; + status: 'failed' | 'verified'; + success: boolean; +}; + +export type NapcatLoginApiTransport = { + post(path: string, body: Record): Promise; +}; + +export class NapcatLoginApiClient { + constructor(private readonly transport: NapcatLoginApiTransport) {} + + async getNewDeviceQRCode(sessionId: string): Promise { + const data = (await this.transport.post( + '/api/QQLogin/GetNewDeviceQRCode', + { sessionId }, + )) as Record; + const qrcodeUrl = this.pickString( + data.qrcodeUrl, + data.qrcodeurl, + data.qrcode, + data.url, + ); + if (!qrcodeUrl) { + throw new Error('NapCat 未返回新设备验证二维码'); + } + + return { + pullQrCodeSig: this.pickString(data.newDevicePullQrCodeSig, data.sig), + qrcodeUrl, + sessionId, + status: 'qr-pending', + }; + } + + async pollNewDeviceQR(sessionId: string): Promise { + const data = (await this.transport.post( + '/api/QQLogin/PollNewDeviceQR', + { sessionId }, + )) as Record; + const status = this.normalizePollStatus( + this.pickString(data.status, data.state, data.result), + ); + + return { + message: this.pickString(data.message, data.reason) || undefined, + sessionId, + status, + }; + } + + async newDeviceLogin(sessionId: string): Promise { + const data = (await this.transport.post( + '/api/QQLogin/NewDeviceLogin', + { sessionId }, + )) as Record; + const success = data.success !== false; + + return { + message: this.pickString(data.message, data.reason) || undefined, + sessionId, + status: success ? 'verified' : 'failed', + success, + }; + } + + private normalizePollStatus(status: string): NewDeviceQrPollResult['status'] { + const normalized = status.toLowerCase().replace(/[_\s-]+/g, ''); + if (['scan', 'scanned'].includes(normalized)) return 'scanned'; + if (['confirm', 'confirming'].includes(normalized)) return 'confirming'; + if (['expire', 'expired', 'timeout'].includes(normalized)) return 'expired'; + if (['fail', 'failed', 'error', 'denied'].includes(normalized)) { + return 'failed'; + } + return 'qr-pending'; + } + + private pickString(...values: unknown[]) { + for (const value of values) { + if (typeof value !== 'string') continue; + const trimmed = value.trim(); + if (trimmed) return trimmed; + } + return ''; + } +} diff --git a/src/modules/qqbot/napcat/login/napcat-login-state-machine.ts b/src/modules/qqbot/napcat/login/napcat-login-state-machine.ts new file mode 100644 index 0000000..417de23 --- /dev/null +++ b/src/modules/qqbot/napcat/login/napcat-login-state-machine.ts @@ -0,0 +1,277 @@ +export type NapcatLoginSessionStatus = 'failure' | 'pending' | 'success'; + +export type NapcatLoginStage = + | 'captcha' + | 'cleanup-failed' + | 'failure' + | 'manual-qr' + | 'new-device' + | 'password-login' + | 'quick-login' + | 'success'; + +export type NapcatLoginChallenge = + | { + reason?: string; + status: 'failed' | 'pending' | 'submitted'; + type: 'captcha'; + url: string; + } + | { + pullQrCodeSig?: string; + qrcodeUrl?: string; + reason?: string; + status: 'confirming' | 'expired' | 'failed' | 'qr-pending' | 'scanned'; + type: 'new-device'; + }; + +export type NapcatLoginSessionState = { + accountId: string; + challenge?: NapcatLoginChallenge; + hasHistoricalSession: boolean; + hasSavedPassword: boolean; + progressMessage: string; + selfId?: string; + sessionId: string; + stage: NapcatLoginStage; + status: NapcatLoginSessionStatus; +}; + +export type CreateNapcatLoginSessionInput = { + accountId: string; + hasHistoricalSession: boolean; + hasSavedPassword: boolean; + sessionId: string; +}; + +export type NapcatLoginStateEvent = + | { type: 'captcha-still-required' } + | { type: 'captcha-submitted' } + | { newDevicePullQrCodeSig?: string; type: 'captcha-new-device-required' } + | { reason?: string; type: 'login-failed' } + | { selfId?: string; type: 'login-success' } + | { type: 'manual-qr-required' } + | { captchaUrl: string; type: 'password-login-captcha-required' } + | { type: 'password-login-failed' } + | { type: 'quick-login-failed' } + | { qrcodeUrl?: string; type: 'new-device-qr-ready' } + | { type: 'new-device-scanned' } + | { type: 'new-device-confirming' } + | { type: 'new-device-poll-pending' } + | { type: 'new-device-verified' } + | { reason?: string; type: 'new-device-expired' } + | { reason?: string; type: 'new-device-failed' } + | { reason?: string; type: 'runtime-cleanup-failed' }; + +export const NAPCAT_LOGIN_PROGRESS_MESSAGES = { + captchaNeeded: '需要验证码', + captchaSubmitted: '验证码已提交,等待确认', + cleanupFailed: '运行态清理失败', + failed: '登录失败', + manualQr: '正在生成手动二维码', + newDeviceConfirming: '新设备确认中', + newDeviceNeeded: '需要新设备验证二维码', + newDeviceQrPending: '新设备二维码待扫码', + newDeviceScanned: '新设备二维码已扫码', + newDeviceVerified: '新设备验证成功,继续登录', + passwordLogin: '正在密码登录', + quickLogin: '正在快速登录', + quickToPassword: '快速登录失败,进入密码登录', + success: '登录成功', +} as const; + +export function createNapcatLoginSession( + input: CreateNapcatLoginSessionInput, +): NapcatLoginSessionState { + const stage = input.hasHistoricalSession ? 'quick-login' : 'password-login'; + + return { + accountId: input.accountId, + hasHistoricalSession: input.hasHistoricalSession, + hasSavedPassword: input.hasSavedPassword, + progressMessage: input.hasHistoricalSession + ? NAPCAT_LOGIN_PROGRESS_MESSAGES.quickLogin + : NAPCAT_LOGIN_PROGRESS_MESSAGES.passwordLogin, + sessionId: input.sessionId, + stage, + status: 'pending', + }; +} + +export class NapcatLoginStateMachine { + advance( + session: NapcatLoginSessionState, + event: NapcatLoginStateEvent, + ): NapcatLoginSessionState { + if (session.status === 'failure') return session; + + switch (event.type) { + case 'quick-login-failed': + return { + ...session, + progressMessage: session.hasSavedPassword + ? NAPCAT_LOGIN_PROGRESS_MESSAGES.quickToPassword + : NAPCAT_LOGIN_PROGRESS_MESSAGES.manualQr, + stage: session.hasSavedPassword ? 'password-login' : 'manual-qr', + }; + + case 'password-login-failed': + case 'manual-qr-required': + return { + ...session, + progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.manualQr, + stage: 'manual-qr', + }; + + case 'password-login-captcha-required': + return { + ...session, + challenge: { + status: 'pending', + type: 'captcha', + url: event.captchaUrl, + }, + progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.captchaNeeded, + stage: 'captcha', + }; + + case 'captcha-still-required': + return session.challenge?.type === 'captcha' + ? { + ...session, + challenge: { + ...session.challenge, + status: + session.challenge.status === 'submitted' + ? 'submitted' + : 'pending', + }, + progressMessage: + session.challenge.status === 'submitted' + ? NAPCAT_LOGIN_PROGRESS_MESSAGES.captchaSubmitted + : NAPCAT_LOGIN_PROGRESS_MESSAGES.captchaNeeded, + stage: 'captcha', + } + : session; + + case 'captcha-submitted': + return session.challenge?.type === 'captcha' + ? { + ...session, + challenge: { + ...session.challenge, + status: 'submitted', + }, + progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.captchaSubmitted, + stage: 'captcha', + } + : session; + + case 'captcha-new-device-required': + return { + ...session, + challenge: { + pullQrCodeSig: event.newDevicePullQrCodeSig, + status: 'qr-pending', + type: 'new-device', + }, + progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.newDeviceNeeded, + stage: 'new-device', + }; + + case 'new-device-qr-ready': + case 'new-device-poll-pending': + return this.updateNewDeviceChallenge(session, { + qrcodeUrl: + event.type === 'new-device-qr-ready' + ? event.qrcodeUrl + : undefined, + status: 'qr-pending', + progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.newDeviceQrPending, + }); + + case 'new-device-scanned': + return this.updateNewDeviceChallenge(session, { + status: 'scanned', + progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.newDeviceScanned, + }); + + case 'new-device-confirming': + return this.updateNewDeviceChallenge(session, { + status: 'confirming', + progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.newDeviceConfirming, + }); + + case 'new-device-expired': + return this.updateNewDeviceChallenge(session, { + reason: event.reason, + status: 'expired', + progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.newDeviceQrPending, + }); + + case 'new-device-failed': + return this.updateNewDeviceChallenge(session, { + reason: event.reason, + status: 'failed', + progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.failed, + }); + + case 'new-device-verified': + return { + ...session, + challenge: undefined, + progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.newDeviceVerified, + stage: 'password-login', + }; + + case 'runtime-cleanup-failed': + return { + ...session, + progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.cleanupFailed, + stage: 'cleanup-failed', + status: 'failure', + }; + + case 'login-success': + return { + ...session, + progressMessage: NAPCAT_LOGIN_PROGRESS_MESSAGES.success, + selfId: event.selfId, + stage: 'success', + status: 'success', + }; + + case 'login-failed': + return { + ...session, + progressMessage: event.reason || NAPCAT_LOGIN_PROGRESS_MESSAGES.failed, + stage: 'failure', + status: 'failure', + }; + } + } + + private updateNewDeviceChallenge( + session: NapcatLoginSessionState, + patch: { + progressMessage: string; + qrcodeUrl?: string; + reason?: string; + status: Extract['status']; + }, + ): NapcatLoginSessionState { + if (session.challenge?.type !== 'new-device') return session; + + return { + ...session, + challenge: { + ...session.challenge, + qrcodeUrl: patch.qrcodeUrl || session.challenge.qrcodeUrl, + reason: patch.reason || session.challenge.reason, + status: patch.status, + }, + progressMessage: patch.progressMessage, + stage: 'new-device', + }; + } +} diff --git a/src/modules/qqbot/napcat/persistence.ts b/src/modules/qqbot/napcat/persistence.ts new file mode 100644 index 0000000..8f13ce5 --- /dev/null +++ b/src/modules/qqbot/napcat/persistence.ts @@ -0,0 +1,14 @@ +import { NapcatDeviceIdentity } from './device/napcat-device-identity.entity'; + +export const NAPCAT_RUNTIME_DOMAIN_CONTRACT = { + tables: [ + 'napcat_container', + 'napcat_device_identity', + 'napcat_account_binding', + 'napcat_login_session', + 'napcat_login_challenge', + 'napcat_runtime_cleanup', + ], +} as const; + +export const NAPCAT_RUNTIME_ENTITIES = [NapcatDeviceIdentity]; diff --git a/src/modules/qqbot/plugins/bangDream/config/runtime-config.ts b/src/modules/qqbot/plugins/bangDream/config/runtime-config.ts index 635af3a..09dedb0 100644 --- a/src/modules/qqbot/plugins/bangDream/config/runtime-config.ts +++ b/src/modules/qqbot/plugins/bangDream/config/runtime-config.ts @@ -1,4 +1,5 @@ import * as path from 'path'; +import * as fs from 'fs'; import type { Server } from '@/modules/qqbot/plugins/bangDream/catalog/server.model'; import { BANGDREAM_BESTDORI_API_PATHS } from '@/modules/qqbot/plugins/bangDream/shared/bangdream-protocol'; import { @@ -13,7 +14,24 @@ import { } from '@/modules/qqbot/plugins/bangDream/config/runtime-options'; import { logger } from '@/modules/qqbot/plugins/bangDream/shared/bangdream-logger'; -export const projectRoot: string = path.resolve(__dirname, '..'); +export function resolveBangDreamProjectRoot(moduleDir = __dirname): string { + const directRoot = path.resolve(moduleDir, '..'); + if (fs.existsSync(path.join(directRoot, 'assets', 'err.png'))) { + return directRoot; + } + + const sourceRoot = path.resolve( + process.cwd(), + 'src/modules/qqbot/plugins/bangDream', + ); + if (fs.existsSync(path.join(sourceRoot, 'assets', 'err.png'))) { + return sourceRoot; + } + + return directRoot; +} + +export const projectRoot: string = resolveBangDreamProjectRoot(); export const assetsRootPath: string = path.join(projectRoot, 'assets'); export const configPath: string = path.join(projectRoot, 'static-config'); export const fuzzySearchPath = path.join( diff --git a/src/modules/qqbot/plugins/bangDream/theme/canvas-image.ts b/src/modules/qqbot/plugins/bangDream/theme/canvas-image.ts index 5751c0c..51ef820 100644 --- a/src/modules/qqbot/plugins/bangDream/theme/canvas-image.ts +++ b/src/modules/qqbot/plugins/bangDream/theme/canvas-image.ts @@ -1,9 +1,8 @@ import * as fs from 'fs'; -import * as path from 'path'; import { Canvas, loadImage, Image } from 'skia-canvas'; import * as svg2img from 'svg2img'; +import { assetsRootPath } from '@/modules/qqbot/plugins/bangDream/config/runtime-config'; -const assetsRootPath = path.resolve(__dirname, '..', 'assets'); const convertSvg = svg2img as unknown as ( svg: string, callback: (error: Error | null, buffer: Buffer) => void, diff --git a/src/qqbot/account/qqbot-napcat-login.service.ts b/src/qqbot/account/qqbot-napcat-login.service.ts index 2a97db2..c6dc888 100644 --- a/src/qqbot/account/qqbot-napcat-login.service.ts +++ b/src/qqbot/account/qqbot-napcat-login.service.ts @@ -5,6 +5,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Observable } from 'rxjs'; import { throwVbenError, ToolsService } from '@/common'; +import { NapcatLoginApiClient } from '@/modules/qqbot/napcat'; import type { NapcatApiResponse, NapcatCaptchaLoginResult, @@ -169,6 +170,9 @@ export class QqbotNapcatLoginService { } const container = await this.getSessionContainer(session); + if (session.newDeviceStatus && session.newDeviceStatus !== 'verified') { + return this.pollNewDeviceVerification(session, container); + } let status: NapcatLoginStatus; try { status = await this.getLoginStatus(container); @@ -285,67 +289,19 @@ export class QqbotNapcatLoginService { ); } - if (captchaResult?.needNewDevice && captchaResult.jumpUrl) { - return this.keepSessionPending( + if (captchaResult?.needNewDevice) { + return this.startNewDeviceVerification( session, - '验证码已通过,但 QQ 仍要求新设备验证,请在 NapCat WebUI 继续完成', - true, + container, + captchaResult, ); } - const loginStatus = await this.waitForPasswordLoginStatus(container); - if (!loginStatus.isLogin) { - const captchaUrl = this.getCaptchaUrlFromStatus(loginStatus); - if (captchaUrl) { - return this.keepPasswordCaptchaPending( - session, - captchaUrl, - loginStatus.loginError, - ); - } - return this.failCaptchaLogin( - session, - container, - `验证码登录未完成:${loginStatus.loginError || 'NapCat 未返回登录成功'}`, - ); - } - - const loginInfo = await this.getLoginInfo(container); - const selfId = this.toolsService.pickNapcatSelfId(loginInfo); - if (loginInfo.online === false || !selfId) { - return this.failCaptchaLogin( - session, - container, - loginInfo.online === false - ? 'NapCat 当前账号已离线' - : 'NapCat 未返回 QQ 号', - ); - } - if (session.expectedSelfId && session.expectedSelfId !== selfId) { - await this.clearRuntimeLoginPasswordAfterFailedPassword( - session, - container, - selfId, - ); - return this.failSession( - session, - `当前密码登录账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`, - ); - } - - try { - await this.clearRuntimeLoginPasswordAfterSuccess( - session, - container, - selfId, - ); - } catch { - return this.toResult(session); - } - return this.completeLogin(session, container, { - loginInfo, - successMessage: '验证码登录成功', - }); + return this.completePasswordLoginAfterChallenge( + session, + container, + '验证码登录成功', + ); } events(sessionId: string) { @@ -577,9 +533,12 @@ export class QqbotNapcatLoginService { captchaUrl: session.captchaUrl, containerId: session.containerId, containerName: session.containerName, + deviceVerifyUrl: session.deviceVerifyUrl, errorMessage: session.errorMessage, expiresAt: session.expiresAt, mode: session.mode, + newDeviceQrcode: session.newDeviceQrcode, + newDeviceStatus: session.newDeviceStatus, qrcode: session.qrcode, sessionId: session.id, status: session.status, @@ -587,6 +546,219 @@ export class QqbotNapcatLoginService { }; } + private async startNewDeviceVerification( + session: QqbotLoginScanSession, + container: QqbotNapcatRuntime, + captchaResult: NapcatCaptchaLoginResult, + ) { + session.status = 'pending'; + session.captchaUrl = undefined; + session.qrcode = undefined; + session.deviceVerifyUrl = this.toolsService.toTrimmedString( + captchaResult.jumpUrl, + ); + session.newDevicePullQrCodeSig = this.toolsService.toTrimmedString( + captchaResult.newDevicePullQrCodeSig, + ); + session.newDeviceStatus = 'qr-pending'; + session.errorMessage = '需要新设备验证二维码'; + this.sessions.set(session.id, session); + this.publishScanResultEvent( + session, + 'new-device-required', + 'processing', + '需要新设备验证二维码', + ); + + try { + const client = new NapcatLoginApiClient({ + post: (path, body) => this.postNapcat(container, path, body), + }); + const qrcode = await client.getNewDeviceQRCode(session.id); + session.newDeviceQrcode = qrcode.qrcodeUrl; + session.newDevicePullQrCodeSig = + qrcode.pullQrCodeSig || session.newDevicePullQrCodeSig; + session.newDeviceStatus = qrcode.status; + session.errorMessage = '新设备二维码待扫码'; + this.sessions.set(session.id, session); + this.publishScanResultEvent( + session, + 'new-device-qrcode-ready', + 'processing', + '新设备二维码待扫码', + ); + return this.toResult(session); + } catch (err) { + return this.keepSessionPending( + session, + this.toolsService.getErrorMessage(err) || '新设备二维码生成失败', + true, + ); + } + } + + private async pollNewDeviceVerification( + session: QqbotLoginScanSession, + container: QqbotNapcatRuntime, + ) { + const client = new NapcatLoginApiClient({ + post: (path, body) => this.postNapcat(container, path, body), + }); + const poll = await client.pollNewDeviceQR(session.id); + if (poll.status === 'scanned') { + return this.keepNewDevicePending( + session, + 'scanned', + poll.message || '新设备二维码已扫码', + 'new-device-scanned', + ); + } + if (poll.status === 'confirming') { + this.keepNewDevicePending( + session, + 'confirming', + poll.message || '新设备确认中', + 'new-device-confirming', + ); + const loginResult = await client.newDeviceLogin(session.id); + if (!loginResult.success) { + return this.failNewDeviceVerification( + session, + container, + loginResult.message || '新设备验证失败', + ); + } + session.newDeviceQrcode = undefined; + session.newDeviceStatus = 'verified'; + session.errorMessage = '新设备验证成功,继续登录'; + this.sessions.set(session.id, session); + this.publishScanResultEvent( + session, + 'new-device-verified', + 'success', + '新设备验证成功,继续登录', + ); + return this.completePasswordLoginAfterChallenge( + session, + container, + '新设备验证登录成功', + ); + } + if (poll.status === 'expired') { + return this.failNewDeviceVerification( + session, + container, + poll.message || '新设备二维码已过期', + ); + } + if (poll.status === 'failed') { + return this.failNewDeviceVerification( + session, + container, + poll.message || '新设备验证失败', + ); + } + return this.keepNewDevicePending( + session, + 'qr-pending', + poll.message || '新设备二维码待扫码', + 'new-device-qrcode-ready', + ); + } + + private keepNewDevicePending( + session: QqbotLoginScanSession, + status: NonNullable, + message: string, + step: string, + ) { + const shouldPublish = + session.newDeviceStatus !== status || session.errorMessage !== message; + session.status = 'pending'; + session.captchaUrl = undefined; + session.qrcode = undefined; + session.newDeviceStatus = status; + session.errorMessage = message; + session.expiresAt = Date.now() + this.getSessionTtlMs(); + this.sessions.set(session.id, session); + if (shouldPublish) { + this.publishScanResultEvent(session, step, 'processing', message); + } + return this.toResult(session); + } + + private async failNewDeviceVerification( + session: QqbotLoginScanSession, + container: QqbotNapcatRuntime, + message: string, + ) { + session.newDeviceQrcode = undefined; + session.newDeviceStatus = 'failed'; + session.errorMessage = message; + this.sessions.set(session.id, session); + return this.failCaptchaLogin(session, container, message); + } + + private async completePasswordLoginAfterChallenge( + session: QqbotLoginScanSession, + container: QqbotNapcatRuntime, + successMessage: string, + ) { + const loginStatus = await this.waitForPasswordLoginStatus(container); + if (!loginStatus.isLogin) { + const captchaUrl = this.getCaptchaUrlFromStatus(loginStatus); + if (captchaUrl) { + return this.keepPasswordCaptchaPending( + session, + captchaUrl, + loginStatus.loginError, + ); + } + return this.failCaptchaLogin( + session, + container, + `验证码登录未完成:${loginStatus.loginError || 'NapCat 未返回登录成功'}`, + ); + } + + const loginInfo = await this.getLoginInfo(container); + const selfId = this.toolsService.pickNapcatSelfId(loginInfo); + if (loginInfo.online === false || !selfId) { + return this.failCaptchaLogin( + session, + container, + loginInfo.online === false + ? 'NapCat 当前账号已离线' + : 'NapCat 未返回 QQ 号', + ); + } + if (session.expectedSelfId && session.expectedSelfId !== selfId) { + await this.clearRuntimeLoginPasswordAfterFailedPassword( + session, + container, + selfId, + ); + return this.failSession( + session, + `当前密码登录账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`, + ); + } + + try { + await this.clearRuntimeLoginPasswordAfterSuccess( + session, + container, + selfId, + ); + } catch { + return this.toResult(session); + } + return this.completeLogin(session, container, { + loginInfo, + successMessage, + }); + } + private publishScanEvent( session: QqbotLoginScanSession, input: Omit, diff --git a/src/qqbot/napcat/qqbot-napcat-container.service.ts b/src/qqbot/napcat/qqbot-napcat-container.service.ts index 0b6833a..e38be1c 100644 --- a/src/qqbot/napcat/qqbot-napcat-container.service.ts +++ b/src/qqbot/napcat/qqbot-napcat-container.service.ts @@ -7,6 +7,11 @@ import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { throwVbenError, ToolsService } from '@/common'; +import { + NapcatDeviceIdentityService, + toNapcatDockerDeviceOptions, + type NapcatDockerDeviceOptions, +} from '@/modules/qqbot/napcat'; import { QqbotAccount } from '../account/qqbot-account.entity'; import { QqbotAccountNapcat } from './qqbot-account-napcat.entity'; import { QqbotNapcatContainer } from './qqbot-napcat-container.entity'; @@ -52,6 +57,7 @@ export class QqbotNapcatContainerService { @InjectRepository(QqbotAccountNapcat) private readonly bindingRepository: Repository, private readonly toolsService: ToolsService, + private readonly deviceIdentityService?: NapcatDeviceIdentityService, ) {} async prepareCreateContainer() { @@ -73,7 +79,11 @@ export class QqbotNapcatContainerService { return { ...existing, hasExistingPrimaryBinding: true }; } - const created = await this.createManagedContainer(account.selfId); + const created = await this.createManagedContainer( + account.selfId, + undefined, + account.id, + ); return { ...created, hasExistingPrimaryBinding: false }; } @@ -849,6 +859,7 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true private async createManagedContainer( selfId?: string, loginPassword?: string, + accountId?: string, ) { const mode = this.getManagedMode(); if (mode !== 'ssh') { @@ -862,7 +873,16 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true const port = await this.allocatePort(); const name = this.buildContainerName(selfId); const token = randomBytes(24).toString('hex'); - const dataDir = `${this.getRootDir()}/${name}`; + let dataDir = `${this.getRootDir()}/${name}`; + let deviceIdentity: NapcatDockerDeviceOptions | undefined; + if (accountId && this.deviceIdentityService) { + const identity = await this.deviceIdentityService.resolveForAccount({ + accountId, + selfId, + }); + dataDir = identity.dataDir || dataDir; + deviceIdentity = toNapcatDockerDeviceOptions(identity); + } const baseUrl = this.buildBaseUrl(port); const reverseWsUrl = this.buildReverseWsUrl(); @@ -886,6 +906,7 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true await this.createRemoteDockerContainer({ account: selfId, dataDir, + deviceIdentity, image, loginPassword, name, @@ -901,8 +922,16 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true status: 'running', }, ); + if (accountId && this.deviceIdentityService) { + await this.deviceIdentityService.resolveForAccount({ + accountId, + containerId: container.id, + selfId, + }); + } return { baseUrl, + dataDir, id: container.id, name, webuiPort: port, @@ -924,6 +953,7 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true private async createRemoteDockerContainer(input: { account?: string; dataDir: string; + deviceIdentity?: NapcatDockerDeviceOptions; image: string; loginPassword?: string; name: string; @@ -939,6 +969,7 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true private buildRemoteCreateScript(input: { account?: string; dataDir: string; + deviceIdentity?: NapcatDockerDeviceOptions; image: string; loginPassword?: string; name: string; @@ -963,6 +994,29 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true ? ' -e NAPCAT_QUICK_PASSWORD="$NAPCAT_QUICK_PASSWORD" \\\n' : ''; const pullCmd = input.skipPull ? '' : 'docker pull "$IMAGE" >/dev/null\n'; + const deviceHeader = input.deviceIdentity + ? [ + `NAPCAT_HOSTNAME=${this.sh(input.deviceIdentity.hostname)}`, + `NAPCAT_MAC_ADDRESS=${this.sh(input.deviceIdentity.macAddress)}`, + `MACHINE_ID_PATH=${this.sh(input.deviceIdentity.machineIdPath)}`, + `DEVICE_ENV_PATH=${this.sh(input.deviceIdentity.deviceEnvPath)}`, + ].join('\n') + '\n' + : ''; + const devicePrepareScript = input.deviceIdentity + ? ` +cat > "$DEVICE_ENV_PATH" < "$MACHINE_ID_PATH" +fi +` + : ''; + const deviceRunFlags = input.deviceIdentity + ? ' --hostname "$NAPCAT_HOSTNAME" \\\n --mac-address "$NAPCAT_MAC_ADDRESS" \\\n -v "$MACHINE_ID_PATH:/etc/machine-id:ro" \\\n' + : ''; return ` set -eu @@ -974,8 +1028,10 @@ REVERSE_WS_URL=${reverseWsUrl} WEBUI_TOKEN=${token} ${accountHeader} ${passwordHeader} +${deviceHeader} mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs" chmod 700 "$DATA_DIR" +${devicePrepareScript} cat > "$DATA_DIR/config/webui.json" < { 'QqbotEventPluginRegistryService', 'QqbotEventService', 'QqbotMessageService', + 'NapcatDeviceIdentityService', 'QqbotNapcatContainerService', 'QqbotNapcatLoginService', 'QqbotNapcatWatchdogService', @@ -242,6 +243,7 @@ describe('QQBot core module contract', () => { 'QqbotAccountNapcat', 'QqbotAllowlist', 'QqbotBlocklist', + 'NapcatDeviceIdentity', 'QqbotCommand', 'QqbotCommandLog', 'QqbotConfig', diff --git a/test/modules/qqbot/napcat/device-identity.spec.ts b/test/modules/qqbot/napcat/device-identity.spec.ts new file mode 100644 index 0000000..cc967f0 --- /dev/null +++ b/test/modules/qqbot/napcat/device-identity.spec.ts @@ -0,0 +1,273 @@ +import { getMetadataArgsStorage } from 'typeorm'; +import { ToolsService } from '@/common'; +import { + NapcatDeviceIdentity, + NapcatDeviceIdentityService, + NAPCAT_RUNTIME_DOMAIN_CONTRACT, + NAPCAT_RUNTIME_ENTITIES, + toNapcatDockerDeviceOptions, +} from '../../../../src/modules/qqbot/napcat'; +import { QqbotNapcatContainerService } from '../../../../src/qqbot/napcat/qqbot-napcat-container.service'; +import { readRefactorV3SqlSchema } from '../../../helpers/sql-schema.helper'; + +type EntityClass = new (...args: never[]) => unknown; + +const getEntityTableName = (entity: EntityClass) => { + return getMetadataArgsStorage().tables.find( + (table) => table.target === entity, + )?.name; +}; + +const getEntityColumnNames = (entity: EntityClass) => { + return getMetadataArgsStorage() + .columns.filter((column) => column.target === entity) + .map((column) => `${column.options.name || column.propertyName}`); +}; + +const createIdentityRepository = () => { + const identities = new Map(); + + return { + create: jest.fn((input: Partial) => ({ + ...input, + })), + findOne: jest.fn(async ({ where }: { where: { accountId: string } }) => { + return identities.get(where.accountId) || null; + }), + save: jest.fn(async (identity: NapcatDeviceIdentity) => { + identities.set(identity.accountId, identity); + return identity; + }), + update: jest.fn(async ({ id }: { id: string }, input: Partial) => { + const current = [...identities.values()].find((item) => item.id === id); + if (current) Object.assign(current, input); + return { affected: current ? 1 : 0 }; + }), + }; +}; + +const createIdentityConfig = () => + ({ + get: jest.fn((key: string, defaultValue?: string) => { + const values: Record = { + QQBOT_NAPCAT_CONTAINER_PREFIX: 'kt-qqbot-napcat', + QQBOT_NAPCAT_ROOT: '/vol1/docker/kt-qqbot/napcat-instances', + }; + return values[key] || defaultValue || ''; + }), + }) as any; + +describe('NapCat device identity persistence', () => { + const schema = readRefactorV3SqlSchema(); + + it('declares Batch 7 NapCat runtime persistence tables', () => { + expect(NAPCAT_RUNTIME_DOMAIN_CONTRACT.tables).toEqual([ + 'napcat_container', + 'napcat_device_identity', + 'napcat_account_binding', + 'napcat_login_session', + 'napcat_login_challenge', + 'napcat_runtime_cleanup', + ]); + + for (const table of NAPCAT_RUNTIME_DOMAIN_CONTRACT.tables) { + expect(schema.hasTable(table)).toBe(true); + } + }); + + it('maps the device identity entity to the v3 SQL schema', () => { + expect(NAPCAT_RUNTIME_ENTITIES).toContain(NapcatDeviceIdentity); + + const tableName = getEntityTableName(NapcatDeviceIdentity); + const columns = getEntityColumnNames(NapcatDeviceIdentity); + + expect(tableName).toBe('napcat_device_identity'); + schema.expectTableColumns(tableName || '', columns); + expect(columns).toEqual( + expect.arrayContaining([ + 'account_id', + 'container_id', + 'data_dir', + 'hostname', + 'machine_id_path', + 'mac_address', + 'verification_status', + 'last_login_evidence', + ]), + ); + }); + + it('reuses data dir, hostname, machine-id path, and MAC when rebuilding the same account container', async () => { + const repository = createIdentityRepository(); + const service = new NapcatDeviceIdentityService( + repository as any, + createIdentityConfig(), + ); + + const first = await service.resolveForAccount({ + accountId: 'account-10001', + containerId: 'container-first', + selfId: '10001', + }); + const second = await service.resolveForAccount({ + accountId: 'account-10001', + containerId: 'container-rebuilt', + selfId: '10001', + }); + + expect(second.dataDir).toBe(first.dataDir); + expect(second.hostname).toBe(first.hostname); + expect(second.machineIdPath).toBe(first.machineIdPath); + expect(second.macAddress).toBe(first.macAddress); + expect(repository.save).toHaveBeenCalledTimes(1); + expect(repository.update).toHaveBeenCalledWith( + { id: first.id }, + expect.objectContaining({ + containerId: 'container-rebuilt', + }), + ); + }); + + it('turns a persisted device identity into Docker device options', async () => { + const repository = createIdentityRepository(); + const service = new NapcatDeviceIdentityService( + repository as any, + createIdentityConfig(), + ); + + const identity = await service.resolveForAccount({ + accountId: 'account-10001', + containerId: 'container-first', + selfId: '10001', + }); + const dockerOptions = toNapcatDockerDeviceOptions(identity); + + expect(dockerOptions).toEqual( + expect.objectContaining({ + dataDir: identity.dataDir, + deviceEnvPath: `${identity.dataDir}/device.env`, + hostname: identity.hostname, + machineIdPath: identity.machineIdPath, + macAddress: identity.macAddress, + }), + ); + expect(dockerOptions.runFlags).toEqual( + expect.arrayContaining([ + '--hostname "$NAPCAT_HOSTNAME"', + '--mac-address "$NAPCAT_MAC_ADDRESS"', + '-v "$MACHINE_ID_PATH:/etc/machine-id:ro"', + ]), + ); + }); + + it('applies persisted device options to the NapCat docker create script', async () => { + const repository = createIdentityRepository(); + const service = new NapcatDeviceIdentityService( + repository as any, + createIdentityConfig(), + ); + const identity = await service.resolveForAccount({ + accountId: 'account-10001', + containerId: 'container-first', + selfId: '10001', + }); + const containerService = new QqbotNapcatContainerService( + { get: jest.fn().mockReturnValue('') } as any, + {} as any, + {} as any, + new ToolsService(), + ) as any; + + const script = containerService.buildRemoteCreateScript({ + dataDir: identity.dataDir, + deviceIdentity: toNapcatDockerDeviceOptions(identity), + image: 'mlikiowa/napcat-docker:latest', + name: 'kt-qqbot-napcat-10001', + port: 6100, + reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse', + token: 'token-test', + }); + + expect(script).toContain(`NAPCAT_HOSTNAME='${identity.hostname}'`); + expect(script).toContain(`NAPCAT_MAC_ADDRESS='${identity.macAddress}'`); + expect(script).toContain(`MACHINE_ID_PATH='${identity.machineIdPath}'`); + expect(script).toContain('cat > "$DEVICE_ENV_PATH" < { + const identityRepository = createIdentityRepository(); + const identityService = new NapcatDeviceIdentityService( + identityRepository as any, + createIdentityConfig(), + ); + const bindingRepository = { + createQueryBuilder: jest.fn(() => ({ + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(null), + where: jest.fn().mockReturnThis(), + })), + findOne: jest.fn().mockResolvedValue(null), + }; + const containerRepository = { + create: jest.fn((input) => input), + find: jest.fn().mockResolvedValue([]), + save: jest.fn(async (input) => ({ + ...input, + id: 'container-created', + })), + update: jest.fn(), + }; + const containerService = new QqbotNapcatContainerService( + { + get: jest.fn((key: string, defaultValue?: string) => { + const values: Record = { + QQBOT_NAPCAT_CONTAINER_MODE: 'ssh', + QQBOT_NAPCAT_CONTAINER_PREFIX: 'kt-qqbot-napcat', + QQBOT_NAPCAT_IMAGE: 'mlikiowa/napcat-docker:latest', + QQBOT_NAPCAT_PORT_START: '6100', + QQBOT_NAPCAT_ROOT: '/vol1/docker/kt-qqbot/napcat-instances', + QQBOT_NAPCAT_SSH_TARGET: 'nas', + }; + return values[key] || defaultValue || ''; + }), + } as any, + containerRepository as any, + bindingRepository as any, + new ToolsService(), + identityService, + ) as any; + containerService.runProcess = jest.fn().mockResolvedValue({ + stderr: '', + stdout: '', + }); + + const runtime = await containerService.prepareAccountContainer({ + id: 'account-10001', + selfId: '10001', + }); + + expect(runtime).toEqual( + expect.objectContaining({ + dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-10001', + id: 'container-created', + name: 'kt-qqbot-napcat-10001', + }), + ); + expect(identityRepository.update).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.any(String) }), + expect.objectContaining({ containerId: 'container-created' }), + ); + expect(containerRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-10001', + }), + ); + const createScript = containerService.runProcess.mock.calls[0][2]; + expect(createScript).toContain('--hostname "$NAPCAT_HOSTNAME"'); + expect(createScript).toContain('--mac-address "$NAPCAT_MAC_ADDRESS"'); + expect(createScript).toContain('-v "$MACHINE_ID_PATH:/etc/machine-id:ro"'); + }); +}); diff --git a/test/modules/qqbot/napcat/login-state-machine.spec.ts b/test/modules/qqbot/napcat/login-state-machine.spec.ts new file mode 100644 index 0000000..a8e506a --- /dev/null +++ b/test/modules/qqbot/napcat/login-state-machine.spec.ts @@ -0,0 +1,171 @@ +import { + createNapcatLoginSession, + NapcatLoginStateMachine, +} from '../../../../src/modules/qqbot/napcat'; + +describe('NapCat login state machine', () => { + it('advances through quick, password, captcha, new-device, manual QR, and success states', () => { + const machine = new NapcatLoginStateMachine(); + let session = createNapcatLoginSession({ + accountId: 'account-10001', + hasHistoricalSession: true, + hasSavedPassword: true, + sessionId: 'session-1', + }); + + expect(session.stage).toBe('quick-login'); + expect(session.progressMessage).toBe('正在快速登录'); + + session = machine.advance(session, { type: 'quick-login-failed' }); + expect(session.stage).toBe('password-login'); + expect(session.progressMessage).toBe('快速登录失败,进入密码登录'); + + session = machine.advance(session, { + captchaUrl: 'https://ti.qq.com/safe/tools/captcha/sms-verify-login', + type: 'password-login-captcha-required', + }); + expect(session.stage).toBe('captcha'); + expect(session.challenge).toEqual( + expect.objectContaining({ + status: 'pending', + type: 'captcha', + url: 'https://ti.qq.com/safe/tools/captcha/sms-verify-login', + }), + ); + expect(session.progressMessage).toBe('需要验证码'); + + session = machine.advance(session, { type: 'captcha-submitted' }); + expect(session.stage).toBe('captcha'); + expect(session.challenge).toEqual( + expect.objectContaining({ + status: 'submitted', + type: 'captcha', + }), + ); + expect(session.progressMessage).toBe('验证码已提交,等待确认'); + + session = machine.advance(session, { + newDevicePullQrCodeSig: 'sig-1', + type: 'captcha-new-device-required', + }); + expect(session.stage).toBe('new-device'); + expect(session.challenge).toEqual( + expect.objectContaining({ + pullQrCodeSig: 'sig-1', + status: 'qr-pending', + type: 'new-device', + }), + ); + expect(session.progressMessage).toBe('需要新设备验证二维码'); + + session = machine.advance(session, { + qrcodeUrl: 'data:image/png;base64,qr', + type: 'new-device-qr-ready', + }); + expect(session.progressMessage).toBe('新设备二维码待扫码'); + + session = machine.advance(session, { type: 'new-device-scanned' }); + expect(session.challenge).toEqual( + expect.objectContaining({ status: 'scanned' }), + ); + expect(session.progressMessage).toBe('新设备二维码已扫码'); + + session = machine.advance(session, { type: 'new-device-confirming' }); + expect(session.challenge).toEqual( + expect.objectContaining({ status: 'confirming' }), + ); + expect(session.progressMessage).toBe('新设备确认中'); + + session = machine.advance(session, { type: 'new-device-verified' }); + expect(session.stage).toBe('password-login'); + expect(session.challenge).toBeUndefined(); + expect(session.progressMessage).toBe('新设备验证成功,继续登录'); + + session = machine.advance(session, { type: 'manual-qr-required' }); + expect(session.stage).toBe('manual-qr'); + expect(session.progressMessage).toBe('正在生成手动二维码'); + + session = machine.advance(session, { + selfId: '10001', + type: 'login-success', + }); + expect(session.stage).toBe('success'); + expect(session.status).toBe('success'); + expect(session.progressMessage).toBe('登录成功'); + }); + + it('keeps captcha and new-device challenges pending until resolved, expired, or failed', () => { + const machine = new NapcatLoginStateMachine(); + let session = createNapcatLoginSession({ + accountId: 'account-10001', + hasHistoricalSession: false, + hasSavedPassword: true, + sessionId: 'session-2', + }); + + expect(session.stage).toBe('password-login'); + session = machine.advance(session, { + captchaUrl: 'https://ti.qq.com/safe/tools/captcha/sms-verify-login', + type: 'password-login-captcha-required', + }); + session = machine.advance(session, { type: 'captcha-still-required' }); + expect(session.challenge).toEqual( + expect.objectContaining({ + status: 'pending', + type: 'captcha', + url: 'https://ti.qq.com/safe/tools/captcha/sms-verify-login', + }), + ); + + session = machine.advance(session, { + newDevicePullQrCodeSig: 'sig-2', + type: 'captcha-new-device-required', + }); + session = machine.advance(session, { type: 'new-device-poll-pending' }); + expect(session.challenge).toEqual( + expect.objectContaining({ + pullQrCodeSig: 'sig-2', + status: 'qr-pending', + type: 'new-device', + }), + ); + + session = machine.advance(session, { + reason: '二维码已过期', + type: 'new-device-expired', + }); + expect(session.status).toBe('pending'); + expect(session.challenge).toEqual( + expect.objectContaining({ + reason: '二维码已过期', + status: 'expired', + }), + ); + }); + + it('treats runtime cleanup failure as a terminal login failure', () => { + const machine = new NapcatLoginStateMachine(); + let session = createNapcatLoginSession({ + accountId: 'account-10001', + hasHistoricalSession: true, + hasSavedPassword: true, + sessionId: 'session-3', + }); + + session = machine.advance(session, { + reason: 'NAPCAT_QUICK_PASSWORD cleanup failed', + type: 'runtime-cleanup-failed', + }); + expect(session.stage).toBe('cleanup-failed'); + expect(session.status).toBe('failure'); + expect(session.progressMessage).toBe('运行态清理失败'); + + session = machine.advance(session, { + selfId: '10001', + type: 'login-success', + }); + expect(session.stage).toBe('cleanup-failed'); + expect(session.status).toBe('failure'); + expect(session.progressMessage).toBe('运行态清理失败'); + }); +}); diff --git a/test/modules/qqbot/napcat/new-device-flow.spec.ts b/test/modules/qqbot/napcat/new-device-flow.spec.ts new file mode 100644 index 0000000..b9ff3fb --- /dev/null +++ b/test/modules/qqbot/napcat/new-device-flow.spec.ts @@ -0,0 +1,96 @@ +import { NapcatLoginApiClient } from '../../../../src/modules/qqbot/napcat'; + +describe('NapCat new-device flow API client', () => { + it('runs GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin with normalized states', async () => { + const calls: Array<{ body: unknown; path: string }> = []; + const client = new NapcatLoginApiClient({ + post: jest.fn(async (path: string, body: unknown) => { + calls.push({ body, path }); + if (path.endsWith('/GetNewDeviceQRCode')) { + return { + newDevicePullQrCodeSig: 'sig-1', + qrcodeUrl: 'data:image/png;base64,new-device', + }; + } + if (path.endsWith('/PollNewDeviceQR') && calls.length === 2) { + return { status: 'scanned' }; + } + if (path.endsWith('/PollNewDeviceQR') && calls.length === 3) { + return { status: 'confirming' }; + } + if (path.endsWith('/NewDeviceLogin')) { + return { message: 'ok', success: true }; + } + throw new Error(`unexpected path ${path}`); + }), + }); + + const qr = await client.getNewDeviceQRCode('session-1'); + const scanned = await client.pollNewDeviceQR('session-1'); + const confirming = await client.pollNewDeviceQR('session-1'); + const verified = await client.newDeviceLogin('session-1'); + + expect(qr).toEqual({ + pullQrCodeSig: 'sig-1', + qrcodeUrl: 'data:image/png;base64,new-device', + sessionId: 'session-1', + status: 'qr-pending', + }); + expect(scanned.status).toBe('scanned'); + expect(confirming.status).toBe('confirming'); + expect(verified).toEqual({ + message: 'ok', + sessionId: 'session-1', + status: 'verified', + success: true, + }); + expect(calls).toEqual([ + { + body: { sessionId: 'session-1' }, + path: '/api/QQLogin/GetNewDeviceQRCode', + }, + { + body: { sessionId: 'session-1' }, + path: '/api/QQLogin/PollNewDeviceQR', + }, + { + body: { sessionId: 'session-1' }, + path: '/api/QQLogin/PollNewDeviceQR', + }, + { + body: { sessionId: 'session-1' }, + path: '/api/QQLogin/NewDeviceLogin', + }, + ]); + }); + + it('normalizes waiting, expired, and failed poll responses without exposing jumpUrl as the completion mechanism', async () => { + const client = new NapcatLoginApiClient({ + post: jest + .fn() + .mockResolvedValueOnce({ jumpUrl: 'https://qq.example/new-device' }) + .mockResolvedValueOnce({ status: 'waiting' }) + .mockResolvedValueOnce({ status: 'expired', message: 'expired' }) + .mockResolvedValueOnce({ status: 'failed', message: 'denied' }), + }); + + await expect(client.getNewDeviceQRCode('session-2')).rejects.toThrow( + 'NapCat 未返回新设备验证二维码', + ); + await expect(client.pollNewDeviceQR('session-2')).resolves.toEqual({ + message: undefined, + sessionId: 'session-2', + status: 'qr-pending', + }); + await expect(client.pollNewDeviceQR('session-2')).resolves.toEqual({ + message: 'expired', + sessionId: 'session-2', + status: 'expired', + }); + await expect(client.pollNewDeviceQR('session-2')).resolves.toEqual({ + message: 'denied', + sessionId: 'session-2', + status: 'failed', + }); + }); +}); diff --git a/test/modules/qqbot/plugins/bangDream/runtime-config.spec.ts b/test/modules/qqbot/plugins/bangDream/runtime-config.spec.ts new file mode 100644 index 0000000..ff56904 --- /dev/null +++ b/test/modules/qqbot/plugins/bangDream/runtime-config.spec.ts @@ -0,0 +1,15 @@ +import { resolve } from 'path'; +import { resolveBangDreamProjectRoot } from '@/modules/qqbot/plugins/bangDream/config/runtime-config'; + +describe('BangDream runtime config', () => { + it('resolves source plugin assets when running from compiled dist output', () => { + const compiledConfigDir = resolve( + process.cwd(), + 'dist/src/modules/qqbot/plugins/bangDream/config', + ); + + expect(resolveBangDreamProjectRoot(compiledConfigDir)).toBe( + resolve(process.cwd(), 'src/modules/qqbot/plugins/bangDream'), + ); + }); +}); diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index 74ec8ad..18918e5 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -1431,6 +1431,197 @@ describe('QqbotNapcatLoginService', () => { expect(session.passwordMd5).toBeUndefined(); }); + it('generates new-device verification QR through NapCat after captcha requires new device', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-new-device', + name: 'napcat-10001', + }; + const containerService = { + findRuntimeById: jest.fn().mockResolvedValue(container), + removeUnboundContainer: jest.fn().mockResolvedValue(false), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + {} as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (refreshService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '10001', + mode: 'refresh', + status: 'pending', + }); + session.captchaUrl = + 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001&sid=sid-from-url'; + session.passwordMd5 = '0123456789abcdef0123456789abcdef'; + (refreshService as any).sessions.set(session.id, session); + const postNapcat = jest + .spyOn(refreshService as any, 'postNapcat') + .mockImplementation(async (_runtime, path: string) => { + if (path === '/api/QQLogin/CaptchaLogin') { + return { + jumpUrl: 'https://ti.qq.com/new-device/verify', + needNewDevice: true, + newDevicePullQrCodeSig: 'sig-new-device', + }; + } + if (path === '/api/QQLogin/GetNewDeviceQRCode') { + return { + newDevicePullQrCodeSig: 'sig-new-device', + qrcodeUrl: 'data:image/png;base64,new-device-qrcode', + }; + } + throw new Error(`unexpected NapCat path ${path}`); + }); + const waitForPasswordLoginStatus = jest.spyOn( + refreshService as any, + 'waitForPasswordLoginStatus', + ); + + const result = await refreshService.submitCaptcha(session.id, { + randstr: '@rand', + sid: 'sid-from-url', + ticket: 'captcha-ticket', + }); + + expect(postNapcat).toHaveBeenNthCalledWith( + 2, + container, + '/api/QQLogin/GetNewDeviceQRCode', + { + sessionId: session.id, + }, + ); + expect(waitForPasswordLoginStatus).not.toHaveBeenCalled(); + expect(result.status).toBe('pending'); + expect(result.captchaUrl).toBeUndefined(); + expect(result.qrcode).toBeUndefined(); + expect(result.newDeviceQrcode).toBe( + 'data:image/png;base64,new-device-qrcode', + ); + expect(result.newDeviceStatus).toBe('qr-pending'); + expect(result.errorMessage).toBe('新设备二维码待扫码'); + expect(session.newDevicePullQrCodeSig).toBe('sig-new-device'); + expect(session.deviceVerifyUrl).toBe('https://ti.qq.com/new-device/verify'); + const steps = ( + (refreshService as any).sessionEventLogs.get(session.id) ?? [] + ).map((event: { step: string }) => event.step); + expect(steps).toEqual( + expect.arrayContaining([ + 'password-login-captcha-submit', + 'new-device-required', + 'new-device-qrcode-ready', + ]), + ); + }); + + it('polls new-device QR and completes NewDeviceLogin before password success', async () => { + const accountService = { + ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), + }; + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-new-device-poll', + name: 'napcat-10001', + }; + const containerService = { + bindAccount: jest.fn().mockResolvedValue(undefined), + findRuntimeById: jest.fn().mockResolvedValue(container), + removeUnboundContainer: jest.fn().mockResolvedValue(false), + }; + 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', + status: 'pending', + }); + session.deviceVerifyUrl = 'https://ti.qq.com/new-device/verify'; + session.newDevicePullQrCodeSig = 'sig-new-device'; + session.newDeviceQrcode = 'data:image/png;base64,new-device-qrcode'; + session.newDeviceStatus = 'qr-pending'; + session.passwordMd5 = '0123456789abcdef0123456789abcdef'; + (refreshService as any).sessions.set(session.id, session); + const postNapcat = jest + .spyOn(refreshService as any, 'postNapcat') + .mockResolvedValueOnce({ + status: 'scanned', + }) + .mockResolvedValueOnce({ + status: 'confirming', + }) + .mockResolvedValueOnce({ + success: true, + }); + jest + .spyOn(refreshService as any, 'waitForPasswordLoginStatus') + .mockResolvedValue({ + isLogin: true, + }); + jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ + nickname: 'Kwi', + online: true, + uin: '10001', + }); + jest + .spyOn(refreshService as any, 'clearRuntimeLoginPasswordAfterSuccess') + .mockResolvedValue(undefined); + + const scanned = (await refreshService.status(session.id)) as any; + const completed = await refreshService.status(session.id); + + expect(scanned.status).toBe('pending'); + expect(scanned.newDeviceStatus).toBe('scanned'); + expect(scanned.errorMessage).toBe('新设备二维码已扫码'); + expect(completed.status).toBe('success'); + expect(postNapcat).toHaveBeenNthCalledWith( + 1, + container, + '/api/QQLogin/PollNewDeviceQR', + { + sessionId: session.id, + }, + ); + expect(postNapcat).toHaveBeenNthCalledWith( + 2, + container, + '/api/QQLogin/PollNewDeviceQR', + { + sessionId: session.id, + }, + ); + expect(postNapcat).toHaveBeenNthCalledWith( + 3, + container, + '/api/QQLogin/NewDeviceLogin', + { + sessionId: session.id, + }, + ); + expect(session.newDeviceQrcode).toBeUndefined(); + expect(session.newDeviceStatus).toBe('verified'); + const steps = ( + (refreshService as any).sessionEventLogs.get(session.id) ?? [] + ).map((event: { step: string }) => event.step); + expect(steps).toEqual( + expect.arrayContaining([ + 'new-device-scanned', + 'new-device-confirming', + 'new-device-verified', + 'login-success', + ]), + ); + }); + it('cleans runtime password env when cancelling captcha pending login', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/',