fix(qqbot): 修复运行态审查问题

This commit is contained in:
sunlei 2026-06-11 15:04:25 +08:00
parent d3c0affa33
commit 2d273994f6
10 changed files with 430 additions and 20 deletions

View File

@ -66,7 +66,7 @@ QQBOT_NAPCAT_SSH_PORT=
QQBOT_NAPCAT_SSH_KEY_PATH= QQBOT_NAPCAT_SSH_KEY_PATH=
QQBOT_NAPCAT_SSH_TIMEOUT_MS=120000 QQBOT_NAPCAT_SSH_TIMEOUT_MS=120000
QQBOT_NAPCAT_ROOT=/vol1/docker/kt-qqbot/napcat-instances QQBOT_NAPCAT_ROOT=/vol1/docker/kt-qqbot/napcat-instances
QQBOT_NAPCAT_IMAGE=mlikiowa/napcat-docker:latest QQBOT_NAPCAT_IMAGE=
QQBOT_NAPCAT_CONTAINER_PREFIX=kt-qqbot-napcat QQBOT_NAPCAT_CONTAINER_PREFIX=kt-qqbot-napcat
QQBOT_NAPCAT_HOST=192.168.31.224 QQBOT_NAPCAT_HOST=192.168.31.224
QQBOT_NAPCAT_PORT_START=6100 QQBOT_NAPCAT_PORT_START=6100

2
API.md
View File

@ -316,7 +316,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。 扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。
同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器,后端会释放旧绑定和未共享的旧容器,避免同账号多实例互相挤下线。NapCat 上报下线 notice 时,账号会被标记为离线,并把下线原因写入 `lastError`;后续无错误的普通断连只更新离线状态,不清空该原因。账号列表会按近期缓存检查绑定 NapCat 容器的最新登录状态日志,日志检测默认 5 秒超时;`isOnline:false` 属于离线信号;如果账号连接时间或心跳晚于容器检测时间,以账号在线态为准;如果最新日志状态是在线,会清空容器旧离线错误;如果最新状态是离线或被踢下线,会同步回写账号离线状态并生成 `qqbot.account.offline` 站内信。 同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器,后端会释放旧绑定和未共享的旧容器,避免同账号多实例互相挤下线。OneBot notice 只有机器人下线、登录失效、`KickedOffLine` 等账号级信号才会把账号标记为离线,普通群成员 kick 不属于账号离线信号。下线原因写入 `lastError` 前按 `last_error` 500 字符列宽截断;后续无错误的普通断连只更新离线状态,不清空该原因。账号列表会按近期缓存检查绑定 NapCat 容器的最新登录状态日志,日志检测默认 5 秒超时;`isOnline:false` 属于离线信号;如果账号连接时间或心跳晚于容器检测时间,以账号在线态为准;如果最新日志状态是在线,会清空容器旧离线错误;如果最新状态是离线或被踢下线,会同步回写账号离线状态并生成 `qqbot.account.offline` 站内信。托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像。
### Command / Rule / Permission ### Command / Rule / Permission

View File

@ -132,7 +132,8 @@ pnpm exec jest --runInBand --runTestsByPath test/path/to/file.spec.ts
- 系统日志由 pino 输出Loki 查询统一通过后端 `/system/logs/*` 代理,前端不直连 Loki。 - 系统日志由 pino 输出Loki 查询统一通过后端 `/system/logs/*` 代理,前端不直连 Loki。
- 日志级站内信只承接运行期事件:接口 5xx、QQBot 下线 notice、NapCat 容器最新离线日志会自动聚合通知 `super` 角色;服务端强制 `super` 访问Admin 不再暴露人工新增/编辑入口;长路径接口错误会压缩 `dedupeKey/title` 到表字段长度内,避免通知入库失败。 - 日志级站内信只承接运行期事件:接口 5xx、QQBot 下线 notice、NapCat 容器最新离线日志会自动聚合通知 `super` 角色;服务端强制 `super` 访问Admin 不再暴露人工新增/编辑入口;长路径接口错误会压缩 `dedupeKey/title` 到表字段长度内,避免通知入库失败。
- QQBot 扫码登录通过 SSE `/qqbot/account/scan/events` 暴露进度,耗时链路不应阻塞普通 HTTP 响应。 - QQBot 扫码登录通过 SSE `/qqbot/account/scan/events` 暴露进度,耗时链路不应阻塞普通 HTTP 响应。
- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,后续无错误的普通断连不能清空该原因;账号列表日志检测带近期缓存和短超时,账号连接时间或心跳晚于容器检测时间时以账号在线态为准,最新日志为在线时清空容器旧离线错误。 - QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表日志检测带近期缓存和短超时,账号连接时间或心跳晚于容器检测时间时以账号在线态为准,最新日志为在线时清空容器旧离线错误。
- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。
- 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 回归。

View File

@ -260,6 +260,231 @@ CREATE TABLE IF NOT EXISTS `qqbot_dedupe` (
UNIQUE KEY `uk_qqbot_dedupe_event_key` (`event_key`) UNIQUE KEY `uk_qqbot_dedupe_event_key` (`event_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SET @qqbot_sql = (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE `qqbot_account` ADD COLUMN `connection_mode` varchar(32) NOT NULL DEFAULT ''reverse-ws''',
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qqbot_account'
AND column_name = 'connection_mode'
);
PREPARE qqbot_stmt FROM @qqbot_sql;
EXECUTE qqbot_stmt;
DEALLOCATE PREPARE qqbot_stmt;
SET @qqbot_sql = (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE `qqbot_account` ADD COLUMN `access_token` varchar(255) DEFAULT NULL',
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qqbot_account'
AND column_name = 'access_token'
);
PREPARE qqbot_stmt FROM @qqbot_sql;
EXECUTE qqbot_stmt;
DEALLOCATE PREPARE qqbot_stmt;
SET @qqbot_sql = (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE `qqbot_account` ADD COLUMN `client_role` varchar(32) DEFAULT NULL',
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qqbot_account'
AND column_name = 'client_role'
);
PREPARE qqbot_stmt FROM @qqbot_sql;
EXECUTE qqbot_stmt;
DEALLOCATE PREPARE qqbot_stmt;
SET @qqbot_sql = (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE `qqbot_account` ADD COLUMN `last_connected_at` datetime DEFAULT NULL',
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qqbot_account'
AND column_name = 'last_connected_at'
);
PREPARE qqbot_stmt FROM @qqbot_sql;
EXECUTE qqbot_stmt;
DEALLOCATE PREPARE qqbot_stmt;
SET @qqbot_sql = (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE `qqbot_account` ADD COLUMN `last_heartbeat_at` datetime DEFAULT NULL',
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qqbot_account'
AND column_name = 'last_heartbeat_at'
);
PREPARE qqbot_stmt FROM @qqbot_sql;
EXECUTE qqbot_stmt;
DEALLOCATE PREPARE qqbot_stmt;
SET @qqbot_sql = (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE `qqbot_account` ADD COLUMN `last_error` varchar(500) DEFAULT NULL',
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qqbot_account'
AND column_name = 'last_error'
);
PREPARE qqbot_stmt FROM @qqbot_sql;
EXECUTE qqbot_stmt;
DEALLOCATE PREPARE qqbot_stmt;
SET @qqbot_sql = (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE `qqbot_napcat_container` ADD COLUMN `webui_port` int DEFAULT NULL',
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qqbot_napcat_container'
AND column_name = 'webui_port'
);
PREPARE qqbot_stmt FROM @qqbot_sql;
EXECUTE qqbot_stmt;
DEALLOCATE PREPARE qqbot_stmt;
SET @qqbot_sql = (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE `qqbot_napcat_container` ADD COLUMN `webui_token` varchar(255) DEFAULT NULL',
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qqbot_napcat_container'
AND column_name = 'webui_token'
);
PREPARE qqbot_stmt FROM @qqbot_sql;
EXECUTE qqbot_stmt;
DEALLOCATE PREPARE qqbot_stmt;
SET @qqbot_sql = (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE `qqbot_napcat_container` ADD COLUMN `image` varchar(255) NOT NULL DEFAULT ''''',
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qqbot_napcat_container'
AND column_name = 'image'
);
PREPARE qqbot_stmt FROM @qqbot_sql;
EXECUTE qqbot_stmt;
DEALLOCATE PREPARE qqbot_stmt;
SET @qqbot_sql = (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE `qqbot_napcat_container` ADD COLUMN `data_dir` varchar(500) NOT NULL DEFAULT ''''',
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qqbot_napcat_container'
AND column_name = 'data_dir'
);
PREPARE qqbot_stmt FROM @qqbot_sql;
EXECUTE qqbot_stmt;
DEALLOCATE PREPARE qqbot_stmt;
SET @qqbot_sql = (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE `qqbot_napcat_container` ADD COLUMN `reverse_ws_url` varchar(500) NOT NULL DEFAULT ''''',
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qqbot_napcat_container'
AND column_name = 'reverse_ws_url'
);
PREPARE qqbot_stmt FROM @qqbot_sql;
EXECUTE qqbot_stmt;
DEALLOCATE PREPARE qqbot_stmt;
SET @qqbot_sql = (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE `qqbot_napcat_container` ADD COLUMN `status` varchar(32) NOT NULL DEFAULT ''creating''',
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qqbot_napcat_container'
AND column_name = 'status'
);
PREPARE qqbot_stmt FROM @qqbot_sql;
EXECUTE qqbot_stmt;
DEALLOCATE PREPARE qqbot_stmt;
SET @qqbot_sql = (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE `qqbot_napcat_container` ADD COLUMN `last_started_at` datetime DEFAULT NULL',
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qqbot_napcat_container'
AND column_name = 'last_started_at'
);
PREPARE qqbot_stmt FROM @qqbot_sql;
EXECUTE qqbot_stmt;
DEALLOCATE PREPARE qqbot_stmt;
SET @qqbot_sql = (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE `qqbot_napcat_container` ADD COLUMN `last_checked_at` datetime DEFAULT NULL',
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qqbot_napcat_container'
AND column_name = 'last_checked_at'
);
PREPARE qqbot_stmt FROM @qqbot_sql;
EXECUTE qqbot_stmt;
DEALLOCATE PREPARE qqbot_stmt;
SET @qqbot_sql = (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE `qqbot_napcat_container` ADD COLUMN `last_error` varchar(500) DEFAULT NULL',
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qqbot_napcat_container'
AND column_name = 'last_error'
);
PREPARE qqbot_stmt FROM @qqbot_sql;
EXECUTE qqbot_stmt;
DEALLOCATE PREPARE qqbot_stmt;
SET @qqbot_sql = ( SET @qqbot_sql = (
SELECT IF( SELECT IF(
COUNT(*) > 0, COUNT(*) > 0,

View File

@ -346,7 +346,9 @@ export class QqbotAccountService {
connectStatus: 'offline', connectStatus: 'offline',
}; };
if (lastError !== undefined) { if (lastError !== undefined) {
payload.lastError = lastError || null; payload.lastError = lastError
? this.toolsService.toColumnText(lastError, 500)
: null;
} }
await this.accountRepository.update({ selfId }, payload); await this.accountRepository.update({ selfId }, payload);
} }
@ -428,7 +430,11 @@ export class QqbotAccountService {
const cachedOfflineReason = this.getFreshCachedOfflineReason(container); const cachedOfflineReason = this.getFreshCachedOfflineReason(container);
if (cachedOfflineReason) { if (cachedOfflineReason) {
await this.applyNapcatOfflineState(account, container, cachedOfflineReason); await this.applyNapcatOfflineState(
account,
container,
cachedOfflineReason,
);
return; return;
} }
if (this.isFreshRuntimeCheck(container.lastCheckedAt)) return; if (this.isFreshRuntimeCheck(container.lastCheckedAt)) return;
@ -457,7 +463,9 @@ export class QqbotAccountService {
private getFreshCachedOfflineReason(container: QqbotNapcatContainer) { private getFreshCachedOfflineReason(container: QqbotNapcatContainer) {
if (!this.isFreshRuntimeCheck(container.lastCheckedAt)) return null; if (!this.isFreshRuntimeCheck(container.lastCheckedAt)) return null;
const reason = this.toolsService.toTrimmedString(container.lastError); const reason = this.toolsService.toTrimmedString(container.lastError);
return this.toolsService.isNapcatOfflineLoginMessage(reason) ? reason : null; return this.toolsService.isNapcatOfflineLoginMessage(reason)
? reason
: null;
} }
private isAccountStateNewerThanRuntimeCheck( private isAccountStateNewerThanRuntimeCheck(

View File

@ -84,7 +84,15 @@ export function getOneBotOfflineReason(payload: QqbotOneBotEvent) {
.join(' ') .join(' ')
.trim(); .trim();
const probe = `${noticeType} ${subType} ${content}`; const probe = `${noticeType} ${subType} ${content}`;
if (!/offline|kick|KickedOffLine|下线|离线|登录已失效/i.test(probe)) { const isBotOfflineNotice =
['bot_offline', 'bot_self_offline', 'bot_login_expired'].includes(
noticeType,
) ||
['kick_offline', 'kicked_offline', 'login_expired'].includes(subType) ||
/KickedOffLine|下线通知|账号状态变更为离线|登录已失效|登录态失效|另一台终端/i.test(
probe,
);
if (!isBotOfflineNotice) {
return null; return null;
} }

View File

@ -231,7 +231,14 @@ export class QqbotNapcatContainerService {
{ {
lastCheckedAt: new Date(), lastCheckedAt: new Date(),
...(loginState.state === 'offline' ...(loginState.state === 'offline'
? { lastError: loginState.offlineReason } ? {
lastError: loginState.offlineReason
? this.toolsService.toColumnText(
loginState.offlineReason,
500,
)
: null,
}
: {}), : {}),
...(loginState.state === 'online' ? { lastError: null } : {}), ...(loginState.state === 'online' ? { lastError: null } : {}),
}, },
@ -242,7 +249,10 @@ export class QqbotNapcatContainerService {
{ id: container.id }, { id: container.id },
{ {
lastCheckedAt: new Date(), lastCheckedAt: new Date(),
lastError: this.toolsService.getErrorMessage(err), lastError: this.toolsService.toColumnText(
this.toolsService.getErrorMessage(err),
500,
),
}, },
); );
return null; return null;
@ -463,13 +473,13 @@ docker logs --tail 300 "$NAME" 2>&1 || true
throwVbenError('当前仅支持通过 SSH 创建 NapCat 容器'); throwVbenError('当前仅支持通过 SSH 创建 NapCat 容器');
} }
const image = this.getConfig('QQBOT_NAPCAT_IMAGE');
if (!image) {
throwVbenError('NapCat 镜像未配置,请先设置 QQBOT_NAPCAT_IMAGE');
}
const port = await this.allocatePort(); const port = await this.allocatePort();
const name = this.buildContainerName(selfId); const name = this.buildContainerName(selfId);
const token = randomBytes(24).toString('hex'); const token = randomBytes(24).toString('hex');
const image = this.getConfig(
'QQBOT_NAPCAT_IMAGE',
'mlikiowa/napcat-docker:latest',
);
const dataDir = `${this.getRootDir()}/${name}`; const dataDir = `${this.getRootDir()}/${name}`;
const baseUrl = this.buildBaseUrl(port); const baseUrl = this.buildBaseUrl(port);
const reverseWsUrl = this.buildReverseWsUrl(); const reverseWsUrl = this.buildReverseWsUrl();
@ -519,7 +529,7 @@ docker logs --tail 300 "$NAME" 2>&1 || true
await this.containerRepository.update( await this.containerRepository.update(
{ id: container.id }, { id: container.id },
{ {
lastError: message, lastError: this.toolsService.toColumnText(message, 500),
status: 'error', status: 'error',
}, },
); );

View File

@ -12,7 +12,7 @@ describe('QqbotAccountService', () => {
{} as any, {} as any,
{} as any, {} as any,
{} as any, {} as any,
{} as any, new ToolsService(),
); );
await service.markOffline('1914728559'); await service.markOffline('1914728559');
@ -25,6 +25,30 @@ describe('QqbotAccountService', () => {
); );
}); });
it('truncates offline reason before writing lastError column', 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.markOffline('1914728559', '错误'.repeat(300));
expect(accountRepository.update).toHaveBeenCalledWith(
{ selfId: '1914728559' },
{
connectStatus: 'offline',
lastError: `${'错误'.repeat(248)}错...`,
},
);
});
it('syncs NapCat runtime offline logs back to account list status', async () => { it('syncs NapCat runtime offline logs back to account list status', async () => {
const account = { const account = {
connectStatus: 'online', connectStatus: 'online',

View File

@ -27,7 +27,8 @@ describe('QqbotEventService', () => {
); );
const payload = { const payload = {
message: '[KickedOffLine] [下线通知] 你的帐号当前登录已失效,请重新登录。', message:
'[KickedOffLine] [下线通知] 你的帐号当前登录已失效,请重新登录。',
notice_type: 'bot_offline', notice_type: 'bot_offline',
post_type: 'notice', post_type: 'notice',
self_id: 1914728559, self_id: 1914728559,
@ -42,7 +43,8 @@ describe('QqbotEventService', () => {
); );
expect(systemNoticePublisher.publishSystemNotice).toHaveBeenCalledWith( expect(systemNoticePublisher.publishSystemNotice).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
content: 'bot_offline/kick_offline你的帐号当前登录已失效请重新登录。', content:
'bot_offline/kick_offline你的帐号当前登录已失效请重新登录。',
dedupeKey: 'qqbot:offline:1914728559', dedupeKey: 'qqbot:offline:1914728559',
eventType: 'qqbot.account.offline', eventType: 'qqbot.account.offline',
metadata: expect.objectContaining({ metadata: expect.objectContaining({
@ -52,9 +54,44 @@ describe('QqbotEventService', () => {
notifyRoleCode: 'super', notifyRoleCode: 'super',
severity: 'error', severity: 'error',
source: 'qqbot', source: 'qqbot',
summary: 'bot_offline/kick_offline你的帐号当前登录已失效请重新登录。', summary:
'bot_offline/kick_offline你的帐号当前登录已失效请重新登录。',
title: 'QQBot 账号已下线1914728559', title: 'QQBot 账号已下线1914728559',
}), }),
); );
}); });
it('ignores normal group kick notices because they are not bot offline signals', async () => {
const accountService = {
markOffline: jest.fn(),
};
const systemNoticePublisher = {
publishSystemNotice: jest.fn(),
};
const service = new (QqbotEventService as any)(
{ 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,
systemNoticePublisher,
);
await service.handleIncoming({
notice_type: 'group_decrease',
operator_id: 12345,
post_type: 'notice',
self_id: 1914728559,
sub_type: 'kick',
user_id: 67890,
});
expect(accountService.markOffline).not.toHaveBeenCalled();
expect(systemNoticePublisher.publishSystemNotice).not.toHaveBeenCalled();
});
}); });

View File

@ -209,4 +209,101 @@ describe('QqbotNapcatContainerService', () => {
5000, 5000,
); );
}); });
it('truncates runtime offline reason before writing container lastError', async () => {
const containerRepository = {
update: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{
get: jest.fn((key: string) => {
const values: Record<string, string> = {
QQBOT_NAPCAT_CONTAINER_MODE: 'ssh',
QQBOT_NAPCAT_SSH_TARGET: 'nas',
};
return values[key] || '';
}),
} as any,
containerRepository as any,
{} as any,
new ToolsService(),
) as any;
service.runProcess = jest.fn().mockResolvedValue({
stderr: '',
stdout: `06-11 08:39:19 [info] Mirror | [KickedOffLine] [下线通知] ${'错误'.repeat(300)}`,
});
await service.detectRuntimeOffline({
id: 'container-1',
name: 'kt-qqbot-napcat-1914728559',
});
expect(containerRepository.update).toHaveBeenCalledWith(
{ id: 'container-1' },
expect.objectContaining({
lastError: `${'错误'.repeat(248)}错...`,
}),
);
});
it('truncates runtime check errors before writing container lastError', async () => {
const containerRepository = {
update: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{
get: jest.fn((key: string) => {
const values: Record<string, string> = {
QQBOT_NAPCAT_CONTAINER_MODE: 'ssh',
QQBOT_NAPCAT_SSH_TARGET: 'nas',
};
return values[key] || '';
}),
} as any,
containerRepository as any,
{} as any,
new ToolsService(),
) as any;
service.runProcess = jest
.fn()
.mockRejectedValue(new Error('错误'.repeat(300)));
await service.detectRuntimeOffline({
id: 'container-1',
name: 'kt-qqbot-napcat-1914728559',
});
expect(containerRepository.update).toHaveBeenCalledWith(
{ id: 'container-1' },
expect.objectContaining({
lastError: `${'错误'.repeat(248)}错...`,
}),
);
});
it('requires an explicit NapCat image when creating a managed container', async () => {
const containerRepository = {
find: jest.fn().mockResolvedValue([]),
save: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{
get: jest.fn((key: string) => {
const values: Record<string, string> = {
QQBOT_NAPCAT_CONTAINER_MODE: 'ssh',
QQBOT_NAPCAT_SSH_TARGET: 'nas',
};
return values[key] || '';
}),
} as any,
containerRepository as any,
{} as any,
new ToolsService(),
) as any;
await expect(service.createManagedContainer('1914728559')).rejects.toThrow(
'NapCat 镜像未配置,请先设置 QQBOT_NAPCAT_IMAGE',
);
expect(containerRepository.save).not.toHaveBeenCalled();
});
}); });