fix: 拆分QQBot连接态和登录态
This commit is contained in:
parent
d03e7c09a4
commit
6c33f5d056
2
API.md
2
API.md
@ -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 分钟内只复读一次,并限制普通文本长度,减少自动行为被风控识别的概率。
|
||||
|
||||
|
||||
@ -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`。
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 = `${
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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 自动登录后运行态密码清理失败,请手动更新登录',
|
||||
},
|
||||
);
|
||||
|
||||
142
test/qqbot/connection/qqbot-reverse-ws.service.spec.ts
Normal file
142
test/qqbot/connection/qqbot-reverse-ws.service.spec.ts
Normal 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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user