feat: 重建NapCat设备与登录运行时
This commit is contained in:
parent
bf8aee3a5a
commit
cf89157fd6
4
API.md
4
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
|
||||
|
||||
@ -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 测试。
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"',
|
||||
],
|
||||
};
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
6
src/modules/qqbot/napcat/index.ts
Normal file
6
src/modules/qqbot/napcat/index.ts
Normal 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';
|
||||
109
src/modules/qqbot/napcat/integration/napcat-login-api.client.ts
Normal file
109
src/modules/qqbot/napcat/integration/napcat-login-api.client.ts
Normal 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 '';
|
||||
}
|
||||
}
|
||||
277
src/modules/qqbot/napcat/login/napcat-login-state-machine.ts
Normal file
277
src/modules/qqbot/napcat/login/napcat-login-state-machine.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
14
src/modules/qqbot/napcat/persistence.ts
Normal file
14
src/modules/qqbot/napcat/persistence.ts
Normal 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];
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'>,
|
||||
|
||||
@ -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" \\
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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',
|
||||
|
||||
273
test/modules/qqbot/napcat/device-identity.spec.ts
Normal file
273
test/modules/qqbot/napcat/device-identity.spec.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
171
test/modules/qqbot/napcat/login-state-machine.spec.ts
Normal file
171
test/modules/qqbot/napcat/login-state-machine.spec.ts
Normal 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('运行态清理失败');
|
||||
});
|
||||
});
|
||||
96
test/modules/qqbot/napcat/new-device-flow.spec.ts
Normal file
96
test/modules/qqbot/napcat/new-device-flow.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
15
test/modules/qqbot/plugins/bangDream/runtime-config.spec.ts
Normal file
15
test/modules/qqbot/plugins/bangDream/runtime-config.spec.ts
Normal 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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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/',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user