feat: 重建NapCat设备与登录运行时

This commit is contained in:
sunlei 2026-06-15 06:15:33 +08:00
parent bf8aee3a5a
commit cf89157fd6
21 changed files with 1677 additions and 63 deletions

4
API.md
View File

@ -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

View File

@ -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 测试。

View File

@ -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,

View File

@ -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"',
],
};
}

View File

@ -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<string, unknown>;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}

View File

@ -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<NapcatDeviceIdentity>,
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<string>(key) || defaultValue}`.trim();
}
}

View File

@ -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';

View File

@ -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<NewDeviceQrStatus, 'verified'>;
};
export type NewDeviceLoginResult = {
message?: string;
sessionId: string;
status: 'failed' | 'verified';
success: boolean;
};
export type NapcatLoginApiTransport = {
post(path: string, body: Record<string, unknown>): Promise<unknown>;
};
export class NapcatLoginApiClient {
constructor(private readonly transport: NapcatLoginApiTransport) {}
async getNewDeviceQRCode(sessionId: string): Promise<NewDeviceQrCode> {
const data = (await this.transport.post(
'/api/QQLogin/GetNewDeviceQRCode',
{ sessionId },
)) as Record<string, unknown>;
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<NewDeviceQrPollResult> {
const data = (await this.transport.post(
'/api/QQLogin/PollNewDeviceQR',
{ sessionId },
)) as Record<string, unknown>;
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<NewDeviceLoginResult> {
const data = (await this.transport.post(
'/api/QQLogin/NewDeviceLogin',
{ sessionId },
)) as Record<string, unknown>;
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 '';
}
}

View File

@ -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<NapcatLoginChallenge, { type: 'new-device' }>['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',
};
}
}

View File

@ -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];

View File

@ -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(

View File

@ -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,

View File

@ -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<QqbotLoginScanSession['newDeviceStatus']>,
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<QqbotLoginScanEvent, 'createdAt'>,

View File

@ -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<QqbotAccountNapcat>,
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" <<EOF
NAPCAT_HOSTNAME=$NAPCAT_HOSTNAME
NAPCAT_MAC_ADDRESS=$NAPCAT_MAC_ADDRESS
MACHINE_ID_PATH=$MACHINE_ID_PATH
EOF
if [ ! -s "$MACHINE_ID_PATH" ]; then
printf '%s' "$NAME" | sha256sum | cut -c 1-32 > "$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" <<EOF
{
@ -1019,7 +1075,7 @@ docker run -d \\
-e NAPCAT_UID=0 \\
-e NAPCAT_GID=0 \\
-e WEBUI_TOKEN="$WEBUI_TOKEN" \\
${accountRunFlag}${passwordRunFlag} -p "$PORT:6099" \\
${accountRunFlag}${passwordRunFlag}${deviceRunFlags} -p "$PORT:6099" \\
-v "$DATA_DIR/QQ:/app/.config/QQ" \\
-v "$DATA_DIR/config:/app/napcat/config" \\
-v "$DATA_DIR/plugins:/app/napcat/plugins" \\

View File

@ -58,6 +58,14 @@ export type QqbotLoginScanMode = 'create' | 'refresh';
export type QqbotLoginScanStatus = 'error' | 'expired' | 'pending' | 'success';
export type QqbotLoginNewDeviceStatus =
| 'confirming'
| 'expired'
| 'failed'
| 'qr-pending'
| 'scanned'
| 'verified';
export type QqbotMessageDirection = 'inbound' | 'outbound';
export type QqbotMessageType = 'channel' | 'group' | 'private';
@ -266,9 +274,12 @@ export type QqbotLoginScanResult = {
captchaUrl?: string;
containerId?: string;
containerName?: string;
deviceVerifyUrl?: string;
errorMessage?: string;
expiresAt?: number;
mode: QqbotLoginScanMode;
newDeviceQrcode?: string;
newDeviceStatus?: QqbotLoginNewDeviceStatus;
qrcode?: string;
selfId?: string;
sessionId?: string;
@ -296,12 +307,16 @@ export type QqbotLoginScanSession = {
containerId?: string;
containerName?: string;
createdAt: number;
deviceVerifyUrl?: string;
errorMessage?: string;
expiresAt: number;
expectedSelfId?: string;
id: string;
lastRestartedAt?: number;
mode: QqbotLoginScanMode;
newDevicePullQrCodeSig?: string;
newDeviceQrcode?: string;
newDeviceStatus?: QqbotLoginNewDeviceStatus;
passwordMd5?: string;
preparingRelogin?: boolean;
qrcode?: string;

View File

@ -221,6 +221,7 @@ describe('QQBot core module contract', () => {
'QqbotEventPluginRegistryService',
'QqbotEventService',
'QqbotMessageService',
'NapcatDeviceIdentityService',
'QqbotNapcatContainerService',
'QqbotNapcatLoginService',
'QqbotNapcatWatchdogService',
@ -242,6 +243,7 @@ describe('QQBot core module contract', () => {
'QqbotAccountNapcat',
'QqbotAllowlist',
'QqbotBlocklist',
'NapcatDeviceIdentity',
'QqbotCommand',
'QqbotCommandLog',
'QqbotConfig',

View File

@ -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<string, NapcatDeviceIdentity>();
return {
create: jest.fn((input: Partial<NapcatDeviceIdentity>) => ({
...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<NapcatDeviceIdentity>) => {
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<string, string> = {
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" <<EOF');
expect(script).toContain('--hostname "$NAPCAT_HOSTNAME"');
expect(script).toContain('--mac-address "$NAPCAT_MAC_ADDRESS"');
expect(script).toContain('-v "$MACHINE_ID_PATH:/etc/machine-id:ro"');
});
it('uses persisted device identity when preparing an account managed container', async () => {
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<string, string> = {
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"');
});
});

View File

@ -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('运行态清理失败');
});
});

View File

@ -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',
});
});
});

View File

@ -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'),
);
});
});

View File

@ -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/',