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 请求完成。
|
扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。
|
||||||
|
|
||||||
|
同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器,后端会释放旧绑定和未共享的旧容器,避免同账号多实例互相挤下线。NapCat 上报下线 notice 时,账号会被标记为离线,并把下线原因写入 `lastError`;后续无错误的普通断连只更新离线状态,不清空该原因。
|
||||||
|
|
||||||
### Command / Rule / Permission
|
### Command / Rule / Permission
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|
|||||||
@ -131,6 +131,7 @@ pnpm exec jest --runInBand --runTestsByPath test/path/to/file.spec.ts
|
|||||||
- WordPress 自动登录失败不会阻断 Admin 主登录,会通过菜单和权限码过滤不可用的 Blog 管理入口。
|
- WordPress 自动登录失败不会阻断 Admin 主登录,会通过菜单和权限码过滤不可用的 Blog 管理入口。
|
||||||
- 系统日志由 pino 输出,Loki 查询统一通过后端 `/system/logs/*` 代理,前端不直连 Loki。
|
- 系统日志由 pino 输出,Loki 查询统一通过后端 `/system/logs/*` 代理,前端不直连 Loki。
|
||||||
- QQBot 扫码登录通过 SSE `/qqbot/account/scan/events` 暴露进度,耗时链路不应阻塞普通 HTTP 响应。
|
- QQBot 扫码登录通过 SSE `/qqbot/account/scan/events` 暴露进度,耗时链路不应阻塞普通 HTTP 响应。
|
||||||
|
- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,下线 notice 会写入账号 `lastError`,后续无错误的普通断连不能清空该原因。
|
||||||
- BangDream 当前源码根目录是 `src/qqbot/plugins/bangDream`;不要恢复旧 `tsugu` 层级或旧大桶目录。
|
- BangDream 当前源码根目录是 `src/qqbot/plugins/bangDream`;不要恢复旧 `tsugu` 层级或旧大桶目录。
|
||||||
- BangDream 在线命令以 `registry/operation-registry.ts` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 registry/command-SQL 测试。
|
- BangDream 在线命令以 `registry/operation-registry.ts` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 registry/command-SQL 测试。
|
||||||
- BangDream event stage 大图必须保持分页拆图行为,线上 smoke 关注 `imageCount=5`,避免大 canvas OOM 回归。
|
- BangDream event stage 大图必须保持分页拆图行为,线上 smoke 关注 `imageCount=5`,避免大 canvas OOM 回归。
|
||||||
|
|||||||
@ -332,13 +332,13 @@ export class QqbotAccountService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async markOffline(selfId: string, lastError?: string) {
|
async markOffline(selfId: string, lastError?: string) {
|
||||||
await this.accountRepository.update(
|
const payload: Partial<QqbotAccount> = {
|
||||||
{ selfId },
|
|
||||||
{
|
|
||||||
connectStatus: 'offline',
|
connectStatus: 'offline',
|
||||||
lastError: lastError || null,
|
};
|
||||||
},
|
if (lastError !== undefined) {
|
||||||
);
|
payload.lastError = lastError || null;
|
||||||
|
}
|
||||||
|
await this.accountRepository.update({ selfId }, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async appendNapcatRuntime(
|
private async appendNapcatRuntime(
|
||||||
|
|||||||
@ -67,6 +67,36 @@ export function buildDedupeKey(message: QqbotNormalizedMessage) {
|
|||||||
].join(':');
|
].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) {
|
function extractMessageText(payload: QqbotOneBotEvent) {
|
||||||
if (payload.raw_message) return payload.raw_message;
|
if (payload.raw_message) return payload.raw_message;
|
||||||
if (typeof payload.message === 'string') return payload.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 type { QqbotOneBotEvent } from '../qqbot.types';
|
||||||
import {
|
import {
|
||||||
buildDedupeKey,
|
buildDedupeKey,
|
||||||
|
getOneBotOfflineReason,
|
||||||
isOneBotMessageEvent,
|
isOneBotMessageEvent,
|
||||||
normalizeOneBotMessage,
|
normalizeOneBotMessage,
|
||||||
} from './qqbot-event-normalizer';
|
} from './qqbot-event-normalizer';
|
||||||
import { QqbotRuleEngineService } from '../rule/qqbot-rule-engine.service';
|
import { QqbotRuleEngineService } from '../rule/qqbot-rule-engine.service';
|
||||||
|
import { QqbotAccountService } from '../account/qqbot-account.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class QqbotEventService {
|
export class QqbotEventService {
|
||||||
@ -22,6 +24,7 @@ export class QqbotEventService {
|
|||||||
private readonly messageService: QqbotMessageService,
|
private readonly messageService: QqbotMessageService,
|
||||||
private readonly ruleEngineService: QqbotRuleEngineService,
|
private readonly ruleEngineService: QqbotRuleEngineService,
|
||||||
private readonly toolsService: ToolsService,
|
private readonly toolsService: ToolsService,
|
||||||
|
private readonly accountService: QqbotAccountService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handleIncoming(payload: QqbotOneBotEvent) {
|
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);
|
const message = normalizeOneBotMessage(payload, this.toolsService);
|
||||||
if (!message.selfId || !message.targetId || !message.userId) {
|
if (!message.selfId || !message.targetId || !message.userId) {
|
||||||
this.logger.warn('QQBot 收到缺少关键字段的消息事件,已忽略');
|
this.logger.warn('QQBot 收到缺少关键字段的消息事件,已忽略');
|
||||||
@ -50,4 +56,14 @@ export class QqbotEventService {
|
|||||||
);
|
);
|
||||||
await this.ruleEngineService.handleMessage(message);
|
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(),
|
lastLoginAt: new Date(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
await this.removeOtherAccountContainers(accountId, containerId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,6 +93,7 @@ export class QqbotNapcatContainerService {
|
|||||||
remark: '',
|
remark: '',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
await this.removeOtherAccountContainers(accountId, containerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeAccountContainers(accountId: string) {
|
async removeAccountContainers(accountId: string) {
|
||||||
@ -229,6 +231,42 @@ export class QqbotNapcatContainerService {
|
|||||||
return true;
|
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) {
|
private async removeRemoteDockerContainer(container: QqbotNapcatContainer) {
|
||||||
const script = this.buildRemoteRemoveScript(container);
|
const script = this.buildRemoteRemoveScript(container);
|
||||||
await this.runProcess('ssh', [...this.getSshArgs(), 'sh -s'], script);
|
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