diff --git a/.env.example b/.env.example index 6f1cd88..d5a2297 100644 --- a/.env.example +++ b/.env.example @@ -66,7 +66,7 @@ QQBOT_NAPCAT_SSH_PORT= QQBOT_NAPCAT_SSH_KEY_PATH= QQBOT_NAPCAT_SSH_TIMEOUT_MS=120000 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_HOST=192.168.31.224 QQBOT_NAPCAT_PORT_START=6100 diff --git a/API.md b/API.md index 77ecbdc..a0e9c17 100644 --- a/API.md +++ b/API.md @@ -316,7 +316,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 扫码链路返回 `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 diff --git a/README.md b/README.md index fa10798..7288d9b 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,8 @@ pnpm exec jest --runInBand --runTestsByPath test/path/to/file.spec.ts - 系统日志由 pino 输出,Loki 查询统一通过后端 `/system/logs/*` 代理,前端不直连 Loki。 - 日志级站内信只承接运行期事件:接口 5xx、QQBot 下线 notice、NapCat 容器最新离线日志会自动聚合通知 `super` 角色;服务端强制 `super` 访问,Admin 不再暴露人工新增/编辑入口;长路径接口错误会压缩 `dedupeKey/title` 到表字段长度内,避免通知入库失败。 - 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 在线命令以 `registry/operation-registry.ts` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 registry/command-SQL 测试。 - BangDream event stage 大图必须保持分页拆图行为,线上 smoke 关注 `imageCount=5`,避免大 canvas OOM 回归。 diff --git a/sql/qqbot-init.sql b/sql/qqbot-init.sql index 3080ccc..f4e7d1e 100644 --- a/sql/qqbot-init.sql +++ b/sql/qqbot-init.sql @@ -260,6 +260,231 @@ CREATE TABLE IF NOT EXISTS `qqbot_dedupe` ( UNIQUE KEY `uk_qqbot_dedupe_event_key` (`event_key`) ) 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 = ( SELECT IF( COUNT(*) > 0, diff --git a/src/qqbot/account/qqbot-account.service.ts b/src/qqbot/account/qqbot-account.service.ts index 31b0454..7fb8e49 100644 --- a/src/qqbot/account/qqbot-account.service.ts +++ b/src/qqbot/account/qqbot-account.service.ts @@ -346,7 +346,9 @@ export class QqbotAccountService { connectStatus: 'offline', }; if (lastError !== undefined) { - payload.lastError = lastError || null; + payload.lastError = lastError + ? this.toolsService.toColumnText(lastError, 500) + : null; } await this.accountRepository.update({ selfId }, payload); } @@ -428,7 +430,11 @@ export class QqbotAccountService { const cachedOfflineReason = this.getFreshCachedOfflineReason(container); if (cachedOfflineReason) { - await this.applyNapcatOfflineState(account, container, cachedOfflineReason); + await this.applyNapcatOfflineState( + account, + container, + cachedOfflineReason, + ); return; } if (this.isFreshRuntimeCheck(container.lastCheckedAt)) return; @@ -457,7 +463,9 @@ export class QqbotAccountService { private getFreshCachedOfflineReason(container: QqbotNapcatContainer) { if (!this.isFreshRuntimeCheck(container.lastCheckedAt)) return null; const reason = this.toolsService.toTrimmedString(container.lastError); - return this.toolsService.isNapcatOfflineLoginMessage(reason) ? reason : null; + return this.toolsService.isNapcatOfflineLoginMessage(reason) + ? reason + : null; } private isAccountStateNewerThanRuntimeCheck( diff --git a/src/qqbot/event/qqbot-event-normalizer.ts b/src/qqbot/event/qqbot-event-normalizer.ts index 81461c7..a41fea1 100644 --- a/src/qqbot/event/qqbot-event-normalizer.ts +++ b/src/qqbot/event/qqbot-event-normalizer.ts @@ -29,8 +29,8 @@ export function normalizeOneBotMessage( messageType === 'group' ? groupId || '' : messageType === 'channel' - ? channelId || '' - : userId; + ? channelId || '' + : userId; const messageText = extractMessageText(payload); return { @@ -84,7 +84,15 @@ export function getOneBotOfflineReason(payload: QqbotOneBotEvent) { .join(' ') .trim(); 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; } diff --git a/src/qqbot/napcat/qqbot-napcat-container.service.ts b/src/qqbot/napcat/qqbot-napcat-container.service.ts index 28b1ff1..4af2014 100644 --- a/src/qqbot/napcat/qqbot-napcat-container.service.ts +++ b/src/qqbot/napcat/qqbot-napcat-container.service.ts @@ -231,7 +231,14 @@ export class QqbotNapcatContainerService { { lastCheckedAt: new Date(), ...(loginState.state === 'offline' - ? { lastError: loginState.offlineReason } + ? { + lastError: loginState.offlineReason + ? this.toolsService.toColumnText( + loginState.offlineReason, + 500, + ) + : null, + } : {}), ...(loginState.state === 'online' ? { lastError: null } : {}), }, @@ -242,7 +249,10 @@ export class QqbotNapcatContainerService { { id: container.id }, { lastCheckedAt: new Date(), - lastError: this.toolsService.getErrorMessage(err), + lastError: this.toolsService.toColumnText( + this.toolsService.getErrorMessage(err), + 500, + ), }, ); return null; @@ -463,13 +473,13 @@ docker logs --tail 300 "$NAME" 2>&1 || true throwVbenError('当前仅支持通过 SSH 创建 NapCat 容器'); } + const image = this.getConfig('QQBOT_NAPCAT_IMAGE'); + if (!image) { + throwVbenError('NapCat 镜像未配置,请先设置 QQBOT_NAPCAT_IMAGE'); + } const port = await this.allocatePort(); const name = this.buildContainerName(selfId); const token = randomBytes(24).toString('hex'); - const image = this.getConfig( - 'QQBOT_NAPCAT_IMAGE', - 'mlikiowa/napcat-docker:latest', - ); const dataDir = `${this.getRootDir()}/${name}`; const baseUrl = this.buildBaseUrl(port); const reverseWsUrl = this.buildReverseWsUrl(); @@ -519,7 +529,7 @@ docker logs --tail 300 "$NAME" 2>&1 || true await this.containerRepository.update( { id: container.id }, { - lastError: message, + lastError: this.toolsService.toColumnText(message, 500), status: 'error', }, ); diff --git a/test/qqbot/account/qqbot-account.service.spec.ts b/test/qqbot/account/qqbot-account.service.spec.ts index 3d67738..02315aa 100644 --- a/test/qqbot/account/qqbot-account.service.spec.ts +++ b/test/qqbot/account/qqbot-account.service.spec.ts @@ -12,7 +12,7 @@ describe('QqbotAccountService', () => { {} as any, {} as any, {} as any, - {} as any, + new ToolsService(), ); 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 () => { const account = { connectStatus: 'online', diff --git a/test/qqbot/event/qqbot-event.service.spec.ts b/test/qqbot/event/qqbot-event.service.spec.ts index c17fd38..d58bc27 100644 --- a/test/qqbot/event/qqbot-event.service.spec.ts +++ b/test/qqbot/event/qqbot-event.service.spec.ts @@ -27,7 +27,8 @@ describe('QqbotEventService', () => { ); const payload = { - message: '[KickedOffLine] [下线通知] 你的帐号当前登录已失效,请重新登录。', + message: + '[KickedOffLine] [下线通知] 你的帐号当前登录已失效,请重新登录。', notice_type: 'bot_offline', post_type: 'notice', self_id: 1914728559, @@ -42,7 +43,8 @@ describe('QqbotEventService', () => { ); expect(systemNoticePublisher.publishSystemNotice).toHaveBeenCalledWith( expect.objectContaining({ - content: 'bot_offline/kick_offline:你的帐号当前登录已失效,请重新登录。', + content: + 'bot_offline/kick_offline:你的帐号当前登录已失效,请重新登录。', dedupeKey: 'qqbot:offline:1914728559', eventType: 'qqbot.account.offline', metadata: expect.objectContaining({ @@ -52,9 +54,44 @@ describe('QqbotEventService', () => { notifyRoleCode: 'super', severity: 'error', source: 'qqbot', - summary: 'bot_offline/kick_offline:你的帐号当前登录已失效,请重新登录。', + summary: + 'bot_offline/kick_offline:你的帐号当前登录已失效,请重新登录。', 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(); + }); }); diff --git a/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts index 8974822..10a5a0c 100644 --- a/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts +++ b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts @@ -209,4 +209,101 @@ describe('QqbotNapcatContainerService', () => { 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 = { + 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 = { + 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 = { + 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(); + }); });