fix: 修复QQBot NapCat下线诊断护栏

This commit is contained in:
sunlei 2026-06-10 11:40:33 +08:00
parent 0f567ee46f
commit 5f0351132d
9 changed files with 241 additions and 8 deletions

2
API.md
View File

@ -302,6 +302,8 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。
同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器后端会释放旧绑定和未共享的旧容器避免同账号多实例互相挤下线。NapCat 上报下线 notice 时,账号会被标记为离线,并把下线原因写入 `lastError`;后续无错误的普通断连只更新离线状态,不清空该原因。
### Command / Rule / Permission
| 方法 | 路径 | 说明 |

View File

@ -131,6 +131,7 @@ pnpm exec jest --runInBand --runTestsByPath test/path/to/file.spec.ts
- WordPress 自动登录失败不会阻断 Admin 主登录,会通过菜单和权限码过滤不可用的 Blog 管理入口。
- 系统日志由 pino 输出Loki 查询统一通过后端 `/system/logs/*` 代理,前端不直连 Loki。
- QQBot 扫码登录通过 SSE `/qqbot/account/scan/events` 暴露进度,耗时链路不应阻塞普通 HTTP 响应。
- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,下线 notice 会写入账号 `lastError`,后续无错误的普通断连不能清空该原因。
- BangDream 当前源码根目录是 `src/qqbot/plugins/bangDream`;不要恢复旧 `tsugu` 层级或旧大桶目录。
- BangDream 在线命令以 `registry/operation-registry.ts` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 registry/command-SQL 测试。
- BangDream event stage 大图必须保持分页拆图行为,线上 smoke 关注 `imageCount=5`,避免大 canvas OOM 回归。

View File

@ -332,13 +332,13 @@ export class QqbotAccountService {
}
async markOffline(selfId: string, lastError?: string) {
await this.accountRepository.update(
{ selfId },
{
connectStatus: 'offline',
lastError: lastError || null,
},
);
const payload: Partial<QqbotAccount> = {
connectStatus: 'offline',
};
if (lastError !== undefined) {
payload.lastError = lastError || null;
}
await this.accountRepository.update({ selfId }, payload);
}
private async appendNapcatRuntime(

View File

@ -67,6 +67,36 @@ export function buildDedupeKey(message: QqbotNormalizedMessage) {
].join(':');
}
export function getOneBotOfflineReason(payload: QqbotOneBotEvent) {
if (payload?.post_type !== 'notice') return null;
const noticeType = `${payload.notice_type || ''}`.trim();
const subType = `${payload.sub_type || ''}`.trim();
const content = [
payload.message,
payload.reason,
payload.raw_message,
payload.title,
payload.tips,
payload.loginError,
]
.filter((item) => typeof item === 'string' && item.trim())
.join(' ')
.trim();
const probe = `${noticeType} ${subType} ${content}`;
if (!/offline|kick|KickedOffLine|下线|离线|登录已失效/i.test(probe)) {
return null;
}
const source = [noticeType, subType].filter(Boolean).join('/') || 'offline';
const message = content
.replace(/\[KickedOffLine\]/gi, '')
.replace(/\[下线通知\]/g, '')
.replace(/\s+/g, ' ')
.trim();
return `${source}${message || '账号已离线,请重新登录'}`;
}
function extractMessageText(payload: QqbotOneBotEvent) {
if (payload.raw_message) return payload.raw_message;
if (typeof payload.message === 'string') return payload.message;

View File

@ -7,10 +7,12 @@ import { QqbotBusService } from '../mqtt/qqbot-bus.service';
import type { QqbotOneBotEvent } from '../qqbot.types';
import {
buildDedupeKey,
getOneBotOfflineReason,
isOneBotMessageEvent,
normalizeOneBotMessage,
} from './qqbot-event-normalizer';
import { QqbotRuleEngineService } from '../rule/qqbot-rule-engine.service';
import { QqbotAccountService } from '../account/qqbot-account.service';
@Injectable()
export class QqbotEventService {
@ -22,6 +24,7 @@ export class QqbotEventService {
private readonly messageService: QqbotMessageService,
private readonly ruleEngineService: QqbotRuleEngineService,
private readonly toolsService: ToolsService,
private readonly accountService: QqbotAccountService,
) {}
async handleIncoming(payload: QqbotOneBotEvent) {
@ -33,7 +36,10 @@ export class QqbotEventService {
);
}
if (!isOneBotMessageEvent(payload)) return;
if (!isOneBotMessageEvent(payload)) {
await this.handleRuntimeNotice(selfId, payload);
return;
}
const message = normalizeOneBotMessage(payload, this.toolsService);
if (!message.selfId || !message.targetId || !message.userId) {
this.logger.warn('QQBot 收到缺少关键字段的消息事件,已忽略');
@ -50,4 +56,14 @@ export class QqbotEventService {
);
await this.ruleEngineService.handleMessage(message);
}
private async handleRuntimeNotice(
selfId: string,
payload: QqbotOneBotEvent,
) {
if (!selfId) return;
const offlineReason = getOneBotOfflineReason(payload);
if (!offlineReason) return;
await this.accountService.markOffline(selfId, offlineReason);
}
}

View File

@ -79,6 +79,7 @@ export class QqbotNapcatContainerService {
lastLoginAt: new Date(),
},
);
await this.removeOtherAccountContainers(accountId, containerId);
return;
}
@ -92,6 +93,7 @@ export class QqbotNapcatContainerService {
remark: '',
}),
);
await this.removeOtherAccountContainers(accountId, containerId);
}
async removeAccountContainers(accountId: string) {
@ -229,6 +231,42 @@ export class QqbotNapcatContainerService {
return true;
}
private async removeOtherAccountContainers(
accountId: string,
keepContainerId: string,
) {
const bindings = await this.bindingRepository.find({
where: {
accountId,
isDeleted: false,
},
});
for (const binding of bindings) {
if (binding.containerId === keepContainerId) continue;
const sharedCount = await this.bindingRepository
.createQueryBuilder('binding')
.where('binding.containerId = :containerId', {
containerId: binding.containerId,
})
.andWhere('binding.accountId != :accountId', { accountId })
.andWhere('binding.isDeleted = :isDeleted', { isDeleted: false })
.getCount();
if (sharedCount <= 0) {
await this.removeContainer(binding.containerId);
}
await this.bindingRepository.update(
{ id: binding.id },
{
bindStatus: 'disabled',
isDeleted: true,
isPrimary: false,
},
);
}
}
private async removeRemoteDockerContainer(container: QqbotNapcatContainer) {
const script = this.buildRemoteRemoveScript(container);
await this.runProcess('ssh', [...this.getSshArgs(), 'sh -s'], script);

View File

@ -0,0 +1,26 @@
import { QqbotAccountService } from '@/qqbot/account/qqbot-account.service';
describe('QqbotAccountService', () => {
it('preserves previous offline reason when later disconnect has no explicit error', async () => {
const accountRepository = {
update: jest.fn(),
};
const service = new QqbotAccountService(
accountRepository as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
);
await service.markOffline('1914728559');
expect(accountRepository.update).toHaveBeenCalledWith(
{ selfId: '1914728559' },
{
connectStatus: 'offline',
},
);
});
});

View File

@ -0,0 +1,38 @@
jest.mock('@/qqbot/rule/qqbot-rule-engine.service', () => ({
QqbotRuleEngineService: class QqbotRuleEngineService {},
}));
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(),
};
const service = new QqbotEventService(
{ publish: jest.fn() } as any,
{} as any,
{} as any,
{} as any,
{
getErrorMessage: (error: unknown) =>
error instanceof Error ? error.message : `${error}`,
toStringId: (value: unknown) => `${value || ''}`,
} as any,
accountService as any,
);
await service.handleIncoming({
message: '[KickedOffLine] [下线通知] 你的帐号当前登录已失效,请重新登录。',
notice_type: 'bot_offline',
post_type: 'notice',
self_id: 1914728559,
sub_type: 'kick_offline',
});
expect(accountService.markOffline).toHaveBeenCalledWith(
'1914728559',
'bot_offline/kick_offline你的帐号当前登录已失效请重新登录。',
);
});
});

View File

@ -0,0 +1,82 @@
jest.mock('@/common', () => {
const actualCommon = jest.requireActual('@/common');
return {
FormatDateTime: actualCommon.FormatDateTime,
ToolsService: actualCommon.ToolsService,
ensureSnowflakeId: jest.fn(),
formatKtDateTime: actualCommon.formatKtDateTime,
setDictDecodeCache: jest.fn(),
throwVbenError: (message: string) => {
throw new Error(message);
},
};
});
import { ConfigService } from '@nestjs/config';
import { ToolsService } from '@/common';
import { QqbotNapcatContainerService } from '@/qqbot/napcat/qqbot-napcat-container.service';
describe('QqbotNapcatContainerService', () => {
it('removes previous account containers when binding a new primary container', async () => {
const bindingRepository = {
create: jest.fn((input) => input),
createQueryBuilder: jest.fn(() => ({
andWhere: jest.fn().mockReturnThis(),
getCount: jest.fn().mockResolvedValue(0),
where: jest.fn().mockReturnThis(),
})),
find: jest.fn().mockResolvedValue([
{
accountId: 'account-1',
containerId: 'container-old',
id: 'binding-old',
isDeleted: false,
},
]),
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn(),
update: jest.fn(),
};
const containerRepository = {
findOne: jest.fn().mockResolvedValue({
id: 'container-old',
isDeleted: false,
name: 'napcat-old',
}),
update: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{ get: jest.fn().mockReturnValue('') } as unknown as ConfigService,
containerRepository as any,
bindingRepository as any,
new ToolsService(),
);
jest.spyOn(service as any, 'getManagedMode').mockReturnValue('');
await service.bindAccount('account-1', 'container-new');
expect(bindingRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
accountId: 'account-1',
bindStatus: 'bound',
containerId: 'container-new',
isPrimary: true,
}),
);
expect(containerRepository.update).toHaveBeenCalledWith(
{ id: 'container-old' },
expect.objectContaining({
isDeleted: true,
status: 'stopped',
}),
);
expect(bindingRepository.update).toHaveBeenCalledWith(
{ id: 'binding-old' },
expect.objectContaining({
bindStatus: 'disabled',
isDeleted: true,
isPrimary: false,
}),
);
});
});