fix: 修复QQBot NapCat下线诊断护栏
This commit is contained in:
parent
0f567ee46f
commit
5f0351132d
2
API.md
2
API.md
@ -302,6 +302,8 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
|
||||
|
||||
扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。
|
||||
|
||||
同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器,后端会释放旧绑定和未共享的旧容器,避免同账号多实例互相挤下线。NapCat 上报下线 notice 时,账号会被标记为离线,并把下线原因写入 `lastError`;后续无错误的普通断连只更新离线状态,不清空该原因。
|
||||
|
||||
### Command / Rule / Permission
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|
||||
@ -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 回归。
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
26
test/qqbot/account/qqbot-account.service.spec.ts
Normal file
26
test/qqbot/account/qqbot-account.service.spec.ts
Normal 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',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
38
test/qqbot/event/qqbot-event.service.spec.ts
Normal file
38
test/qqbot/event/qqbot-event.service.spec.ts
Normal 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:你的帐号当前登录已失效,请重新登录。',
|
||||
);
|
||||
});
|
||||
});
|
||||
82
test/qqbot/napcat/qqbot-napcat-container.service.spec.ts
Normal file
82
test/qqbot/napcat/qqbot-napcat-container.service.spec.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user