fix: 拆分QQBot连接态和登录态

This commit is contained in:
sunlei 2026-06-13 11:06:21 +08:00
parent d03e7c09a4
commit 6c33f5d056
9 changed files with 568 additions and 44 deletions

2
API.md
View File

@ -319,7 +319,7 @@ 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` 继续密码登录第二步。密码登录成功后会重建容器移除该运行态密码,清理失败则本次登录失败;密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,再进入重置登录态和二维码兜底流程。看门狗自动登录使用同样的 quick -> password 顺序,但不会自动进入扫码阶段。
同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器后端会释放旧绑定和未共享的旧容器避免同账号多实例互相挤下线。OneBot notice 只有机器人下线、登录失效、`KickedOffLine` 等账号级信号才会把账号标记为离线,普通群成员 kick 不属于账号离线信号。下线原因写入 `lastError` 前按 `last_error` 500 字符列宽截断;后续无错误的普通断连只更新离线状态,不清空该原因。账号列表会按近期缓存检查绑定 NapCat 容器的最新登录状态日志,日志检测默认 5 秒超时;`isOnline:false` 属于离线信号;如果账号连接时间或心跳晚于容器检测时间,以账号在线态为准;如果最新日志状态是在线,会清空容器旧离线错误;如果最新状态是离线或被踢下线,会同步回写账号离线状态并生成 `qqbot.account.offline` 站内信。托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像。
同一 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` 默认镜像。
外发消息不直接抢发:后端会按 `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 分钟内只复读一次,并限制普通文本长度,减少自动行为被风控识别的概率。

View File

@ -135,7 +135,7 @@ pnpm exec jest --runInBand --runTestsByPath test/path/to/file.spec.ts
- QQBot 外发统一走发送排队:默认全局间隔 `2500ms`、同会话间隔 `8000ms`、排队抖动 `0-800ms`,超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS` 时拒绝本次发送,避免高频自动回复形成突发流量。
- QQBot 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。
- QQBot 复读机默认阈值为 4同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。
- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表拆开展示 OneBot、容器、WebUI 和 QQ 登录态,日志/WebUI 检测带近期缓存和短超时,账号连接时间或心跳晚于容器检测时间时以账号在线态为准,最新日志为在线时清空容器旧离线错误`qqLoginMessage` 只展示 QQ 登录态消息WebUI 配置或请求错误留在 `lastError`
- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表拆开展示 OneBot、容器、WebUI 和 QQ 登录态,心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态`qqLoginMessage` 只展示 QQ 登录态消息WebUI 配置或请求错误留在 `lastError`
- 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`

View File

@ -557,7 +557,7 @@ function getPropertyDescription(propertyName: string) {
code: '响应状态码',
command: '命令触发词',
commandId: '在线命令 ID',
connectStatus: 'OneBot 账号在线状态',
connectStatus: 'OneBot 反向 WS 连接状态',
connectionMode: '连接模式',
connectionRole: 'OneBot 连接角色',
containerName: 'NapCat 容器名称',
@ -576,7 +576,7 @@ function getPropertyDescription(propertyName: string) {
keyword: '匹配关键词',
lastConnectedAt: '最后连接时间',
lastError: '最近异常',
lastHeartbeatAt: '最后心跳时间',
lastHeartbeatAt: 'OneBot/容器最后心跳时间',
lastLoginAt: '最近扫码登录时间',
lastMessage: '最后一条消息',
lastModified: '最后修改时间',
@ -825,8 +825,13 @@ function qqbotAccountExample() {
napcat: {
bindStatus: 'bound',
containerName: 'kt-qqbot-napcat-1914728559',
containerOnline: true,
containerStatus: 'running',
lastLoginAt: '2026-06-02 19:55:00',
oneBotOnline: true,
qqLoginMessage: 'QQ 已登录',
qqLoginStatus: 'online',
webuiOnline: true,
webuiPort: 6100,
},
selfId: '1914728559',

View File

@ -340,16 +340,21 @@ export class QqbotAccountService {
async markOnline(
selfId: string,
clientRole: QqbotConnectionRole,
lastError: null | string = null,
lastError?: null | string,
) {
const payload: Partial<QqbotAccount> = {
clientRole,
connectStatus: 'online',
lastConnectedAt: new Date(),
};
if (lastError !== undefined) {
payload.lastError = lastError
? this.toolsService.toColumnText(lastError, 500)
: null;
}
await this.accountRepository.update(
{ selfId },
{
clientRole,
connectStatus: 'online',
lastConnectedAt: new Date(),
lastError,
},
payload,
);
}
@ -396,6 +401,15 @@ export class QqbotAccountService {
await this.accountRepository.update({ selfId }, payload);
}
async markQqLoginOffline(selfId: string, lastError: string) {
await this.accountRepository.update(
{ selfId },
{
lastError: this.toolsService.toColumnText(lastError, 500),
},
);
}
getNapcatLoginPassword(
account?: Pick<QqbotAccount, 'napcatLoginPasswordSecret'> | null,
) {
@ -493,7 +507,7 @@ export class QqbotAccountService {
if (!container || container.status !== 'running') return runtimeStatus;
if (account.connectStatus !== 'online') return runtimeStatus;
if (this.isAccountStateNewerThanRuntimeCheck(account, container)) {
if (this.isRecentConnectNewerThanRuntimeCheck(account, container)) {
return runtimeStatus;
}
@ -501,7 +515,7 @@ export class QqbotAccountService {
this.getRuntimeStatusOfflineReason(runtimeStatus);
if (runtimeOfflineReason) {
if (options.autoLogin && (await this.tryAutoLogin(account, container))) {
return this.toCachedNapcatRuntimeStatus(account, container);
return this.toCachedNapcatRuntimeStatus(container);
}
await this.applyNapcatOfflineState(
account,
@ -514,7 +528,7 @@ export class QqbotAccountService {
const cachedOfflineReason = this.getFreshCachedOfflineReason(container);
if (cachedOfflineReason) {
if (options.autoLogin && (await this.tryAutoLogin(account, container))) {
return this.toCachedNapcatRuntimeStatus(account, container);
return this.toCachedNapcatRuntimeStatus(container);
}
await this.applyNapcatOfflineState(
account,
@ -532,7 +546,7 @@ export class QqbotAccountService {
if (!offlineReason) return runtimeStatus;
if (options.autoLogin && (await this.tryAutoLogin(account, container))) {
return this.toCachedNapcatRuntimeStatus(account, container);
return this.toCachedNapcatRuntimeStatus(container);
}
await this.applyNapcatOfflineState(account, container, offlineReason);
@ -550,9 +564,9 @@ export class QqbotAccountService {
container?: QqbotNapcatContainer,
): Promise<QqbotNapcatRuntimeStatusSnapshot | undefined> {
if (!container) return undefined;
const cached = this.toCachedNapcatRuntimeStatus(account, container);
const cached = this.toCachedNapcatRuntimeStatus(container);
if (container.status !== 'running') return cached;
if (this.isAccountStateNewerThanRuntimeCheck(account, container)) {
if (this.isRecentConnectNewerThanRuntimeCheck(account, container)) {
return cached;
}
if (this.isFreshRuntimeCheck(container.lastCheckedAt)) return cached;
@ -566,11 +580,26 @@ export class QqbotAccountService {
await this.napcatContainerService.inspectRuntimeStatus(container);
container.lastCheckedAt = inspected.checkedAt as any;
container.lastError = inspected.lastError || null;
await this.clearQqLoginErrorIfConfirmedOnline(account, inspected);
return inspected;
}
private toCachedNapcatRuntimeStatus(
private async clearQqLoginErrorIfConfirmedOnline(
account: QqbotAccount,
runtimeStatus: QqbotNapcatRuntimeStatusSnapshot,
) {
if (runtimeStatus.qqLoginStatus !== 'online') return;
const lastError = this.toolsService.toTrimmedString(account.lastError);
if (!lastError || !this.isQqLoginStateError(lastError)) return;
await this.accountRepository.update(
{ selfId: account.selfId },
{ lastError: null },
);
account.lastError = null;
}
private toCachedNapcatRuntimeStatus(
container: QqbotNapcatContainer,
): QqbotNapcatRuntimeStatusSnapshot {
const containerOnline = container.status === 'running';
@ -585,17 +614,12 @@ export class QqbotAccountService {
containerOnline,
lastError: lastError || null,
qqLoginMessage: offlineReason,
qqLoginStatus: this.toCachedQqLoginStatus(
account,
containerOnline,
lastError,
),
qqLoginStatus: this.toCachedQqLoginStatus(containerOnline, lastError),
webuiOnline: containerOnline ? null : false,
};
}
private toCachedQqLoginStatus(
account: QqbotAccount,
containerOnline: boolean,
lastError: string,
): QqbotNapcatRuntimeStatusSnapshot['qqLoginStatus'] {
@ -609,10 +633,17 @@ export class QqbotAccountService {
if (this.toolsService.isNapcatOfflineLoginMessage(lastError)) {
return 'offline';
}
if (account.connectStatus === 'online') return 'online';
return 'unknown';
}
private isQqLoginStateError(message: string) {
return (
this.toolsService.isNapcatOfflineLoginMessage(message) ||
message.includes('二维码已过期') ||
message.includes('二维码过期')
);
}
private getRuntimeStatusOfflineReason(
runtimeStatus?: QqbotNapcatRuntimeStatusSnapshot,
) {
@ -649,7 +680,7 @@ export class QqbotAccountService {
}
if (!result.success) return false;
await this.markOnline(account.selfId, 'Universal');
await this.markOnline(account.selfId, 'Universal', null);
account.clientRole = 'Universal';
account.connectStatus = 'online';
account.lastConnectedAt = new Date() as any;
@ -665,8 +696,7 @@ export class QqbotAccountService {
container: QqbotNapcatContainer,
offlineReason: string,
) {
await this.markOffline(account.selfId, offlineReason);
account.connectStatus = 'offline';
await this.markQqLoginOffline(account.selfId, offlineReason);
account.lastError = offlineReason;
this.publishOfflineNotice(account.selfId, offlineReason, {
containerId: container.id,
@ -682,17 +712,16 @@ export class QqbotAccountService {
: null;
}
private isAccountStateNewerThanRuntimeCheck(
private isRecentConnectNewerThanRuntimeCheck(
account: QqbotAccount,
container: QqbotNapcatContainer,
) {
const checkedAt = this.toTime(container.lastCheckedAt);
if (!checkedAt) return false;
const connectedAt = this.toTime(account.lastConnectedAt);
if (connectedAt <= checkedAt) return false;
return (
this.toTime(account.lastConnectedAt) > checkedAt ||
this.toTime(account.lastHeartbeatAt) > checkedAt
);
return Date.now() - connectedAt < NAPCAT_RUNTIME_CHECK_TTL_MS;
}
private isFreshRuntimeCheck(lastCheckedAt?: Date | null) {

View File

@ -91,6 +91,7 @@ export class QqbotReverseWsService
(resolve, reject) => {
const timer = setTimeout(() => {
this.pendingActions.delete(echo);
this.closeTimedOutConnection(selfId, ws);
reject(new Error('OneBot action timeout'));
}, this.getActionTimeout());
this.pendingActions.set(echo, { reject, resolve, timer });
@ -153,6 +154,7 @@ export class QqbotReverseWsService
});
ws.on('close', async () => {
if (!this.isCurrentConnection(key, ws)) return;
this.connections.delete(key);
await this.accountService.markOffline(context.selfId);
await this.busService.publish(QQBOT_MQTT_TOPICS.status(context.selfId), {
@ -163,6 +165,7 @@ export class QqbotReverseWsService
});
ws.on('error', async (err) => {
this.logger.warn(`QQBot WS 错误 ${context.selfId}: ${err.message}`);
if (!this.isCurrentConnection(key, ws)) return;
await this.accountService.markOffline(context.selfId, err.message);
});
@ -229,6 +232,33 @@ export class QqbotReverseWsService
pending.resolve(payload);
}
private closeTimedOutConnection(selfId: string, ws: WebSocket) {
const reason = 'OneBot action timeout';
let closedCurrentConnection = false;
[...this.connections.entries()].forEach(([key, connection]) => {
if (!key.startsWith(`${selfId}:`) || connection !== ws) return;
this.connections.delete(key);
closedCurrentConnection = true;
try {
connection.close(1011, reason);
} catch {
// The connection is already unusable; state cleanup is the important part.
}
});
if (!closedCurrentConnection) return;
void this.accountService.markOffline(selfId, reason).catch(() => undefined);
void this.busService
.publish(QQBOT_MQTT_TOPICS.status(selfId), {
selfId,
status: 'offline',
})
.catch(() => undefined);
}
private isCurrentConnection(key: string, ws: WebSocket) {
return this.connections.get(key) === ws;
}
private async authorize(request: IncomingMessage) {
const url = new URL(request.url || '', `http://${request.headers.host}`);
const selfId = `${

View File

@ -71,7 +71,7 @@ export class QqbotEventService {
if (!selfId) return;
const offlineReason = getOneBotOfflineReason(payload);
if (!offlineReason) return;
await this.accountService.markOffline(selfId, offlineReason);
await this.accountService.markQqLoginOffline(selfId, offlineReason);
this.publishOfflineNotice(selfId, offlineReason, payload);
}

View File

@ -209,6 +209,58 @@ describe('QqbotAccountService', () => {
);
});
it('preserves QQ login error when OneBot connection comes online', async () => {
const accountRepository = {
update: jest.fn(),
};
const service = new QqbotAccountService(
accountRepository as any,
{} as any,
{} as any,
{} as any,
{} as any,
new ToolsService(),
);
await service.markOnline('1914728559', 'Universal');
const updatePayload = accountRepository.update.mock.calls[0][1];
expect(updatePayload).toEqual(
expect.objectContaining({
clientRole: 'Universal',
connectStatus: 'online',
lastConnectedAt: expect.any(Date),
}),
);
expect(updatePayload).not.toHaveProperty('lastError');
});
it('clears QQ login error only when online state is explicitly confirmed', async () => {
const accountRepository = {
update: jest.fn(),
};
const service = new QqbotAccountService(
accountRepository as any,
{} as any,
{} as any,
{} as any,
{} as any,
new ToolsService(),
);
await service.markOnline('1914728559', 'Universal', null);
expect(accountRepository.update).toHaveBeenCalledWith(
{ selfId: '1914728559' },
expect.objectContaining({
clientRole: 'Universal',
connectStatus: 'online',
lastConnectedAt: expect.any(Date),
lastError: null,
}),
);
});
it('truncates offline reason before writing lastError column', async () => {
const accountRepository = {
update: jest.fn(),
@ -306,14 +358,17 @@ describe('QqbotAccountService', () => {
expect(accountRepository.update).toHaveBeenCalledWith(
{ selfId: '1914728559' },
{
connectStatus: 'offline',
lastError: 'NapCat 账号状态变更为离线',
},
);
expect(page.list[0]).toEqual(
expect.objectContaining({
connectStatus: 'offline',
connectStatus: 'online',
lastError: 'NapCat 账号状态变更为离线',
napcat: expect.objectContaining({
oneBotOnline: true,
qqLoginStatus: 'offline',
}),
}),
);
expect(systemNoticePublisher.publishSystemNotice).toHaveBeenCalledWith(
@ -575,14 +630,90 @@ describe('QqbotAccountService', () => {
);
});
it('ignores cached NapCat offline reason after the account reconnects', async () => {
const checkedAt = new Date('2026-06-11T02:00:00.000Z');
it('does not derive QQ login online status from OneBot heartbeat cache', async () => {
const checkedAt = new Date();
const account = {
connectStatus: 'online',
enabled: true,
id: 'account-1',
isDeleted: false,
lastConnectedAt: new Date('2026-06-11T02:00:10.000Z'),
lastError: null,
lastHeartbeatAt: new Date(),
name: '主账号',
selfId: '1914728559',
};
const binding = {
accountId: 'account-1',
bindStatus: 'bound',
containerId: 'container-1',
isDeleted: false,
isPrimary: true,
lastLoginAt: checkedAt,
};
const container = {
id: 'container-1',
isDeleted: false,
lastCheckedAt: checkedAt,
lastError: null,
name: 'kt-qqbot-napcat-1914728559',
status: 'running',
webuiPort: 6101,
};
const accountRepository = {
createQueryBuilder: jest.fn(() => ({
andWhere: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[account], 1]),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const service = new QqbotAccountService(
accountRepository as any,
{} as any,
{
createQueryBuilder: jest.fn(() => ({
addOrderBy: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([binding]),
orderBy: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
} as any,
{
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([container]),
where: jest.fn().mockReturnThis(),
})),
} as any,
{} as any,
new ToolsService(),
);
const page = await service.page({});
expect(page.list[0].napcat).toEqual(
expect.objectContaining({
oneBotOnline: true,
qqLoginStatus: 'unknown',
webuiOnline: null,
}),
);
});
it('ignores cached NapCat offline reason after the account reconnects', async () => {
const now = Date.now();
const checkedAt = new Date(now - 10_000);
const account = {
connectStatus: 'online',
enabled: true,
id: 'account-1',
isDeleted: false,
lastConnectedAt: new Date(now - 5_000),
lastError: null,
name: '主账号',
selfId: '1914728559',
@ -658,6 +789,194 @@ describe('QqbotAccountService', () => {
);
});
it('does not let heartbeat bypass stale NapCat offline inspection', async () => {
const checkedAt = new Date(Date.now() - 60_000);
const account = {
connectStatus: 'online',
enabled: true,
id: 'account-1',
isDeleted: false,
lastConnectedAt: new Date(Date.now() - 120_000),
lastError: null,
lastHeartbeatAt: new Date(),
name: '主账号',
selfId: '1914728559',
};
const binding = {
accountId: 'account-1',
bindStatus: 'bound',
containerId: 'container-1',
isDeleted: false,
isPrimary: true,
lastLoginAt: checkedAt,
};
const container = {
id: 'container-1',
isDeleted: false,
lastCheckedAt: checkedAt,
lastError: null,
name: 'kt-qqbot-napcat-1914728559',
status: 'running',
webuiPort: 6101,
};
const accountRepository = {
createQueryBuilder: jest.fn(() => ({
andWhere: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[account], 1]),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const napcatContainerService = {
inspectRuntimeStatus: jest.fn().mockResolvedValue({
checkedAt: new Date(),
containerOnline: true,
lastError: '账号状态变更为离线',
qqLoginMessage: '账号状态变更为离线',
qqLoginStatus: 'offline',
webuiOnline: true,
}),
};
const service = new QqbotAccountService(
accountRepository as any,
{} as any,
{
createQueryBuilder: jest.fn(() => ({
addOrderBy: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([binding]),
orderBy: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
} as any,
{
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([container]),
where: jest.fn().mockReturnThis(),
})),
} as any,
napcatContainerService as any,
new ToolsService(),
);
const page = await service.page({});
expect(napcatContainerService.inspectRuntimeStatus).toHaveBeenCalledWith(
container,
);
expect(accountRepository.update).toHaveBeenCalledWith(
{ selfId: '1914728559' },
{
lastError: '账号状态变更为离线',
},
);
expect(page.list[0]).toEqual(
expect.objectContaining({
connectStatus: 'online',
lastError: '账号状态变更为离线',
napcat: expect.objectContaining({
oneBotOnline: true,
qqLoginStatus: 'offline',
}),
}),
);
});
it('clears previous QQ login error when NapCat WebUI confirms QQ is online', async () => {
const checkedAt = new Date(Date.now() - 60_000);
const account = {
connectStatus: 'online',
enabled: true,
id: 'account-1',
isDeleted: false,
lastConnectedAt: new Date(Date.now() - 120_000),
lastError: '账号状态变更为离线',
name: '主账号',
selfId: '1914728559',
};
const binding = {
accountId: 'account-1',
bindStatus: 'bound',
containerId: 'container-1',
isDeleted: false,
isPrimary: true,
lastLoginAt: checkedAt,
};
const container = {
id: 'container-1',
isDeleted: false,
lastCheckedAt: checkedAt,
lastError: '账号状态变更为离线',
name: 'kt-qqbot-napcat-1914728559',
status: 'running',
webuiPort: 6101,
};
const accountRepository = {
createQueryBuilder: jest.fn(() => ({
andWhere: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[account], 1]),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const napcatContainerService = {
inspectRuntimeStatus: jest.fn().mockResolvedValue({
checkedAt: new Date(),
containerOnline: true,
lastError: null,
qqLoginMessage: null,
qqLoginStatus: 'online',
webuiOnline: true,
}),
};
const service = new QqbotAccountService(
accountRepository as any,
{} as any,
{
createQueryBuilder: jest.fn(() => ({
addOrderBy: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([binding]),
orderBy: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
})),
} as any,
{
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([container]),
where: jest.fn().mockReturnThis(),
})),
} as any,
napcatContainerService as any,
new ToolsService(),
);
const page = await service.page({});
expect(accountRepository.update).toHaveBeenCalledWith(
{ selfId: '1914728559' },
{ lastError: null },
);
expect(page.list[0]).toEqual(
expect.objectContaining({
lastError: null,
napcat: expect.objectContaining({
qqLoginStatus: 'online',
}),
}),
);
});
it('lets watchdog auto-login before marking an account offline', async () => {
const toolsService = new ToolsService();
const account = {
@ -848,7 +1167,6 @@ describe('QqbotAccountService', () => {
expect(accountRepository.update).toHaveBeenCalledWith(
{ selfId: '1914728559' },
{
connectStatus: 'offline',
lastError: 'NapCat 自动登录后运行态密码清理失败,请手动更新登录',
},
);

View File

@ -0,0 +1,142 @@
jest.mock('@/qqbot/event/qqbot-event.service', () => ({
QqbotEventService: class {},
}));
import { EventEmitter } from 'events';
import { ToolsService } from '@/common';
import { QqbotReverseWsService } from '@/qqbot/connection/qqbot-reverse-ws.service';
class FakeWebSocket extends EventEmitter {
closed: Array<{ code?: number; reason?: string }> = [];
readyState = 1;
sent: string[] = [];
close(code?: number, reason?: string) {
this.closed.push({ code, reason });
this.readyState = 3;
this.emit('close', code, reason);
}
send(payload: string) {
this.sent.push(payload);
}
}
function createService() {
const accountService = {
ensureRuntimeAccount: jest.fn(),
findBySelfId: jest.fn(),
findEnabledBySelfIdWithToken: jest.fn().mockResolvedValue({
accessToken: '',
selfId: '1914728559',
}),
markHeartbeat: jest.fn().mockResolvedValue(undefined),
markOffline: jest.fn().mockResolvedValue(undefined),
markOnline: jest.fn().mockResolvedValue(undefined),
};
const busService = {
publish: jest.fn().mockResolvedValue(undefined),
};
const service = new QqbotReverseWsService(
{
get: jest.fn((key: string) => {
if (key === 'QQBOT_ENABLED') return 'true';
if (key === 'QQBOT_API_TIMEOUT_MS') return '10';
return '';
}),
} as any,
{} as any,
{} as any,
accountService as any,
busService as any,
new ToolsService(),
);
return { accountService, busService, service };
}
function createRequest() {
return {
headers: { host: '127.0.0.1:48085' },
url: '/onebot/v11/ws?self_id=1914728559&role=Universal',
} as any;
}
describe('QqbotReverseWsService', () => {
it('ignores close events from a replaced OneBot connection', async () => {
const { accountService, busService, service } = createService();
const oldWs = new FakeWebSocket();
const currentWs = new FakeWebSocket();
await (service as any).handleConnection(oldWs, createRequest());
await (service as any).handleConnection(currentWs, createRequest());
accountService.markOffline.mockClear();
busService.publish.mockClear();
oldWs.emit('close');
await Promise.resolve();
expect((service as any).connections.get('1914728559:Universal')).toBe(
currentWs,
);
expect(accountService.markOffline).not.toHaveBeenCalled();
expect(busService.publish).not.toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ status: 'offline' }),
);
});
it('does not mark OneBot offline when a replaced connection times out', async () => {
const { accountService, busService, service } = createService();
const oldWs = new FakeWebSocket();
const currentWs = new FakeWebSocket();
await (service as any).handleConnection(oldWs, createRequest());
await (service as any).handleConnection(currentWs, createRequest());
accountService.markOffline.mockClear();
busService.publish.mockClear();
(service as any).closeTimedOutConnection('1914728559', oldWs);
await Promise.resolve();
expect((service as any).connections.get('1914728559:Universal')).toBe(
currentWs,
);
expect(oldWs.closed).toEqual([]);
expect(accountService.markOffline).not.toHaveBeenCalled();
expect(busService.publish).not.toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ status: 'offline' }),
);
});
it('marks OneBot offline when the current connection times out', async () => {
const { accountService, busService, service } = createService();
const ws = new FakeWebSocket();
await (service as any).handleConnection(ws, createRequest());
accountService.markOffline.mockClear();
busService.publish.mockClear();
(service as any).closeTimedOutConnection('1914728559', ws);
await Promise.resolve();
expect((service as any).connections.has('1914728559:Universal')).toBe(
false,
);
expect(ws.closed).toEqual([
{ code: 1011, reason: 'OneBot action timeout' },
]);
expect(accountService.markOffline).toHaveBeenCalledWith(
'1914728559',
'OneBot action timeout',
);
expect(busService.publish).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
selfId: '1914728559',
status: 'offline',
}),
);
});
});

View File

@ -7,7 +7,7 @@ import { QqbotEventService } from '@/qqbot/event/qqbot-event.service';
describe('QqbotEventService', () => {
it('marks account offline with reason when NapCat reports kicked offline notice', async () => {
const accountService = {
markOffline: jest.fn(),
markQqLoginOffline: jest.fn(),
};
const systemNoticePublisher = {
publishSystemNotice: jest.fn().mockResolvedValue('notice-1'),
@ -37,7 +37,7 @@ describe('QqbotEventService', () => {
await service.handleIncoming(payload);
expect(accountService.markOffline).toHaveBeenCalledWith(
expect(accountService.markQqLoginOffline).toHaveBeenCalledWith(
'1914728559',
'bot_offline/kick_offline你的帐号当前登录已失效请重新登录。',
);
@ -63,7 +63,7 @@ describe('QqbotEventService', () => {
it('ignores normal group kick notices because they are not bot offline signals', async () => {
const accountService = {
markOffline: jest.fn(),
markQqLoginOffline: jest.fn(),
};
const systemNoticePublisher = {
publishSystemNotice: jest.fn(),
@ -91,7 +91,7 @@ describe('QqbotEventService', () => {
user_id: 67890,
});
expect(accountService.markOffline).not.toHaveBeenCalled();
expect(accountService.markQqLoginOffline).not.toHaveBeenCalled();
expect(systemNoticePublisher.publishSystemNotice).not.toHaveBeenCalled();
});
});