From 4d8e8c3bbc89275f9a5cdc8d78f0a3058617c7b7 Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 12 Jun 2026 13:21:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BC=93=E8=A7=A3QQBot=E9=A3=8E?= =?UTF-8?q?=E6=8E=A7=E8=A7=A6=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 11 +- API.md | 4 +- README.md | 5 +- src/qqbot/command/qqbot-command.service.ts | 22 ++- .../plugins/repeater/qqbot-repeater.plugin.ts | 36 ++++- src/qqbot/qqbot-cooldown.policy.ts | 25 +++ src/qqbot/qqbot.types.ts | 1 + src/qqbot/rule/qqbot-rule.service.ts | 20 ++- src/qqbot/send/qqbot-rate-limit.service.ts | 148 ++++++++++++++++-- src/qqbot/send/qqbot-send.service.ts | 5 +- .../repeater/qqbot-repeater.plugin.spec.ts | 121 ++++++++++++++ test/qqbot/qqbot-cooldown.policy.spec.ts | 29 ++++ .../rule/qqbot-rule-cooldown.service.spec.ts | 37 +++++ .../send/qqbot-rate-limit.service.spec.ts | 115 ++++++++++++++ test/qqbot/send/qqbot-send.service.spec.ts | 2 +- 15 files changed, 546 insertions(+), 35 deletions(-) create mode 100644 src/qqbot/qqbot-cooldown.policy.ts create mode 100644 test/qqbot/plugins/repeater/qqbot-repeater.plugin.spec.ts create mode 100644 test/qqbot/qqbot-cooldown.policy.spec.ts create mode 100644 test/qqbot/rule/qqbot-rule-cooldown.service.spec.ts create mode 100644 test/qqbot/send/qqbot-rate-limit.service.spec.ts diff --git a/.env.example b/.env.example index d5a2297..c1c0448 100644 --- a/.env.example +++ b/.env.example @@ -50,8 +50,15 @@ QQBOT_REVERSE_WS_TOKEN= QQBOT_AUTO_REGISTER_ACCOUNT=true QQBOT_API_TIMEOUT_MS=10000 QQBOT_SEND_RATE_PER_SECOND=1 -QQBOT_REPEATER_THRESHOLD=2 -QQBOT_REPEATER_MAX_TEXT_LENGTH=300 +QQBOT_SEND_GLOBAL_INTERVAL_MS=2500 +QQBOT_SEND_TARGET_INTERVAL_MS=8000 +QQBOT_SEND_JITTER_MS=800 +QQBOT_SEND_MAX_QUEUE_WAIT_MS=30000 +QQBOT_COMMAND_MIN_COOLDOWN_MS=5000 +QQBOT_RULE_MIN_COOLDOWN_MS=30000 +QQBOT_REPEATER_THRESHOLD=4 +QQBOT_REPEATER_MAX_TEXT_LENGTH=120 +QQBOT_REPEATER_MIN_INTERVAL_MS=600000 QQBOT_REPEATER_STATE_TTL_MS=600000 QQBOT_REPEATER_CONFIG_CACHE_TTL_MS=2000 NAPCAT_WEBUI_BASE_URL=http://127.0.0.1:6099 diff --git a/API.md b/API.md index a0e9c17..fad24af 100644 --- a/API.md +++ b/API.md @@ -59,7 +59,7 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管 | Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` | | WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD` | | Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_QUERY_SELECTOR` | -| QQBot | `QQBOT_ENABLED`、`QQBOT_REVERSE_WS_PATH`、`QQBOT_REVERSE_WS_TOKEN`、`QQBOT_EVENT_BUS` | +| QQBot | `QQBOT_ENABLED`、`QQBOT_REVERSE_WS_PATH`、`QQBOT_REVERSE_WS_TOKEN`、`QQBOT_EVENT_BUS`、`QQBOT_SEND_*`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*` | | NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` | | MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` | | BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` | @@ -318,6 +318,8 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器,后端会释放旧绑定和未共享的旧容器,避免同账号多实例互相挤下线。OneBot notice 只有机器人下线、登录失效、`KickedOffLine` 等账号级信号才会把账号标记为离线,普通群成员 kick 不属于账号离线信号。下线原因写入 `lastError` 前按 `last_error` 500 字符列宽截断;后续无错误的普通断连只更新离线状态,不清空该原因。账号列表会按近期缓存检查绑定 NapCat 容器的最新登录状态日志,日志检测默认 5 秒超时;`isOnline:false` 属于离线信号;如果账号连接时间或心跳晚于容器检测时间,以账号在线态为准;如果最新日志状态是在线,会清空容器旧离线错误;如果最新状态是离线或被踢下线,会同步回写账号离线状态并生成 `qqbot.account.offline` 站内信。托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像。 +外发消息不直接抢发:后端会按 `QQBOT_SEND_GLOBAL_INTERVAL_MS`、`QQBOT_SEND_TARGET_INTERVAL_MS` 和 `QQBOT_SEND_JITTER_MS` 预约发送窗口,默认全局 2500ms、同会话 8000ms、抖动 0-800ms;如果等待超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS`,本次发送会在下发前被拒绝。在线命令和自动回复规则会叠加运行时保底冷却,默认命令 5000ms、规则 30000ms;复读机默认连续 4 次相同普通文本才触发,同一会话默认 10 分钟内只复读一次,并限制普通文本长度,减少自动行为被风控识别的概率。 + ### Command / Rule / Permission | 方法 | 路径 | 说明 | diff --git a/README.md b/README.md index 7288d9b..f0ed31c 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ ci/ Jenkins Agent/Docker 辅助文件 | Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` | | WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD`、`WORDPRESS_*_TIMEOUT_MS` | | Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` | -| QQBot/NapCat | `QQBOT_ENABLED`、`QQBOT_REVERSE_WS_*`、`NAPCAT_*`、`QQBOT_NAPCAT_*`、`MQTT_*` | +| QQBot/NapCat | `QQBOT_ENABLED`、`QQBOT_REVERSE_WS_*`、`QQBOT_SEND_*`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*`、`NAPCAT_*`、`QQBOT_NAPCAT_*`、`MQTT_*` | | BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` | | FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_MARKET_CACHE_TTL_MS` | | FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` | @@ -132,6 +132,9 @@ 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 外发统一走发送排队:默认全局间隔 `2500ms`、同会话间隔 `8000ms`、排队抖动 `0-800ms`,超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS` 时拒绝本次发送,避免高频自动回复形成突发流量。 +- QQBot 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。 +- QQBot 复读机默认阈值为 4,同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。 - QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表日志检测带近期缓存和短超时,账号连接时间或心跳晚于容器检测时间时以账号在线态为准,最新日志为在线时清空容器旧离线错误。 - NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。 - BangDream 当前源码根目录是 `src/qqbot/plugins/bangDream`;不要恢复旧 `tsugu` 层级或旧大桶目录。 diff --git a/src/qqbot/command/qqbot-command.service.ts b/src/qqbot/command/qqbot-command.service.ts index 0efd525..92a1d36 100644 --- a/src/qqbot/command/qqbot-command.service.ts +++ b/src/qqbot/command/qqbot-command.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Not, Repository } from 'typeorm'; import { throwVbenError, ToolsService } from '@/common'; @@ -18,6 +19,7 @@ import type { QqbotCommandQueryDto, QqbotCommandUpdateDto, } from './qqbot-command.dto'; +import { isWithinCooldown } from '../qqbot-cooldown.policy'; import { QqbotCommandLog } from './qqbot-command-log.entity'; import { QqbotCommand } from './qqbot-command.entity'; @@ -31,6 +33,7 @@ export class QqbotCommandService { private readonly accountService: QqbotAccountService, private readonly pluginRegistry: QqbotPluginRegistryService, private readonly toolsService: ToolsService, + private readonly configService: ConfigService, ) {} async page(query: QqbotCommandQueryDto) { @@ -157,10 +160,11 @@ export class QqbotCommandService { } isInCooldown(command: QqbotCommand) { - if (!command.lastHitAt || !command.cooldownMs) return false; - return ( - Date.now() - new Date(command.lastHitAt).getTime() < command.cooldownMs - ); + return isWithinCooldown({ + cooldownMs: command.cooldownMs, + lastHitAt: command.lastHitAt, + minCooldownMs: this.getMinCooldownMs(), + }); } async logExecution(params: { @@ -217,7 +221,10 @@ export class QqbotCommandService { return { aliases: this.stringifyList(body.aliases), code, - cooldownMs: Number(body.cooldownMs ?? 1500), + cooldownMs: Math.max( + Number(body.cooldownMs ?? this.getMinCooldownMs()), + this.getMinCooldownMs(), + ), defaultParams: this.stringifyParams(body.defaultParams), enabled: body.enabled ?? true, errorTemplate: body.errorTemplate || null, @@ -324,4 +331,9 @@ export class QqbotCommandService { targetType: command.targetType, }; } + + private getMinCooldownMs() { + const value = Number(this.configService.get('QQBOT_COMMAND_MIN_COOLDOWN_MS')); + return Number.isInteger(value) && value > 0 ? value : 5000; + } } diff --git a/src/qqbot/plugins/repeater/qqbot-repeater.plugin.ts b/src/qqbot/plugins/repeater/qqbot-repeater.plugin.ts index 9585bc0..01a4471 100644 --- a/src/qqbot/plugins/repeater/qqbot-repeater.plugin.ts +++ b/src/qqbot/plugins/repeater/qqbot-repeater.plugin.ts @@ -102,7 +102,10 @@ export class QqbotRepeaterPluginService { const state = this.getNextState(key, text); if (!this.shouldRepeat(state, text)) return false; + const repeatedAt = Date.now(); state.repeatedText = text; + state.lastRepeatedAt = repeatedAt; + state.updatedAt = repeatedAt; try { await this.sendService.sendText({ channelId: message.channelId, @@ -157,6 +160,7 @@ export class QqbotRepeaterPluginService { : { count: 1, lastText: text, + lastRepeatedAt: current?.lastRepeatedAt || 0, repeatedText: '', updatedAt: now, }; @@ -166,11 +170,28 @@ export class QqbotRepeaterPluginService { } private shouldRepeat(state: QqbotRepeaterConversationState, text: string) { - return state.count >= this.getThreshold() && state.repeatedText !== text; + return ( + state.count >= this.getThreshold() && + state.repeatedText !== text && + Date.now() - (state.lastRepeatedAt || 0) >= this.getMinIntervalMs() + ); } private resetState(message: QqbotNormalizedMessage) { - this.states.delete(this.buildStateKey(message)); + const key = this.buildStateKey(message); + const current = this.states.get(key); + if (!current?.lastRepeatedAt) { + this.states.delete(key); + return; + } + + this.states.set(key, { + count: 0, + lastRepeatedAt: current.lastRepeatedAt, + lastText: '', + repeatedText: '', + updatedAt: Date.now(), + }); } private buildStateKey(message: QqbotNormalizedMessage) { @@ -179,14 +200,21 @@ export class QqbotRepeaterPluginService { private getThreshold() { const value = Number(this.configService.get('QQBOT_REPEATER_THRESHOLD')); - return Number.isInteger(value) && value > 1 ? value : 2; + return Number.isInteger(value) && value > 1 ? value : 4; } private getMaxTextLength() { const value = Number( this.configService.get('QQBOT_REPEATER_MAX_TEXT_LENGTH'), ); - return Number.isInteger(value) && value > 0 ? value : 300; + return Number.isInteger(value) && value > 0 ? value : 120; + } + + private getMinIntervalMs() { + const value = Number( + this.configService.get('QQBOT_REPEATER_MIN_INTERVAL_MS'), + ); + return Number.isInteger(value) && value > 0 ? value : 10 * 60 * 1000; } private getStateTtlMs() { diff --git a/src/qqbot/qqbot-cooldown.policy.ts b/src/qqbot/qqbot-cooldown.policy.ts new file mode 100644 index 0000000..3561098 --- /dev/null +++ b/src/qqbot/qqbot-cooldown.policy.ts @@ -0,0 +1,25 @@ +export function getEffectiveCooldownMs( + cooldownMs: number | null | undefined, + minCooldownMs: number, +) { + const cooldown = Number(cooldownMs || 0); + const minimum = Number(minCooldownMs || 0); + return Math.max( + Number.isFinite(cooldown) && cooldown > 0 ? cooldown : 0, + Number.isFinite(minimum) && minimum > 0 ? minimum : 0, + ); +} + +export function isWithinCooldown(params: { + cooldownMs: number | null | undefined; + lastHitAt: Date | null | undefined; + minCooldownMs: number; +}) { + if (!params.lastHitAt) return false; + const cooldownMs = getEffectiveCooldownMs( + params.cooldownMs, + params.minCooldownMs, + ); + if (!cooldownMs) return false; + return Date.now() - params.lastHitAt.getTime() < cooldownMs; +} diff --git a/src/qqbot/qqbot.types.ts b/src/qqbot/qqbot.types.ts index c1ee198..0e7936b 100644 --- a/src/qqbot/qqbot.types.ts +++ b/src/qqbot/qqbot.types.ts @@ -301,6 +301,7 @@ export type QqbotReverseActionSender = { export type QqbotRepeaterConversationState = { count: number; + lastRepeatedAt?: number; lastText: string; repeatedText: string; updatedAt: number; diff --git a/src/qqbot/rule/qqbot-rule.service.ts b/src/qqbot/rule/qqbot-rule.service.ts index aa83e5e..761a7df 100644 --- a/src/qqbot/rule/qqbot-rule.service.ts +++ b/src/qqbot/rule/qqbot-rule.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { throwVbenError, ToolsService } from '@/common'; @@ -18,6 +19,7 @@ import { QQBOT_DEFAULT_PAGE_NO, QQBOT_DEFAULT_PAGE_SIZE, } from '../qqbot.constants'; +import { isWithinCooldown } from '../qqbot-cooldown.policy'; @Injectable() export class QqbotRuleService { @@ -26,6 +28,7 @@ export class QqbotRuleService { private readonly ruleRepository: Repository, private readonly accountService: QqbotAccountService, private readonly toolsService: ToolsService, + private readonly configService: ConfigService, ) {} async page(query: QqbotRuleQueryDto) { @@ -145,8 +148,11 @@ export class QqbotRuleService { } isInCooldown(rule: QqbotRule) { - if (!rule.lastHitAt || !rule.cooldownMs) return false; - return Date.now() - new Date(rule.lastHitAt).getTime() < rule.cooldownMs; + return isWithinCooldown({ + cooldownMs: rule.cooldownMs, + lastHitAt: rule.lastHitAt, + minCooldownMs: this.getMinCooldownMs(), + }); } private assertRuleValid(matchType: QqbotRuleMatchType, keyword: string) { @@ -164,7 +170,10 @@ export class QqbotRuleService { private normalizeBody(body: Partial) { return { - cooldownMs: Number(body.cooldownMs ?? 1500), + cooldownMs: Math.max( + Number(body.cooldownMs ?? this.getMinCooldownMs()), + this.getMinCooldownMs(), + ), enabled: body.enabled ?? true, keyword: body.keyword || '', matchType: (body.matchType || 'keyword') as QqbotRuleMatchType, @@ -175,4 +184,9 @@ export class QqbotRuleService { targetType: (body.targetType || 'all') as QqbotRuleTargetType, } as Partial; } + + private getMinCooldownMs() { + const value = Number(this.configService.get('QQBOT_RULE_MIN_COOLDOWN_MS')); + return Number.isInteger(value) && value > 0 ? value : 30000; + } } diff --git a/src/qqbot/send/qqbot-rate-limit.service.ts b/src/qqbot/send/qqbot-rate-limit.service.ts index 8575b37..a496ad0 100644 --- a/src/qqbot/send/qqbot-rate-limit.service.ts +++ b/src/qqbot/send/qqbot-rate-limit.service.ts @@ -4,33 +4,142 @@ import { throwVbenError } from '@/common'; @Injectable() export class QqbotRateLimitService { - private readonly lastSentAt = new Map(); + private readonly globalReservedAt = new Map(); + private readonly targetReservedAt = new Map(); constructor(private readonly configService: ConfigService) {} - assertCanSend(selfId: string, targetId: string) { + async waitForSendSlot(selfId: string, targetId: string) { const now = Date.now(); - const globalKey = `${selfId}:global`; - const targetKey = `${selfId}:${targetId}`; - const minInterval = Math.ceil(1000 / this.getRatePerSecond()); - - this.assertInterval(globalKey, now, minInterval, 'QQBot 全局发送过快'); - this.assertInterval(targetKey, now, 1000, 'QQBot 单会话发送过快'); - - this.lastSentAt.set(globalKey, now); - this.lastSentAt.set(targetKey, now); + const slot = this.planSendSlot(selfId, targetId, now); + const waitMs = slot.nextAt - now; + if (waitMs > this.getMaxQueueWaitMs()) { + throwVbenError('QQBot 发送队列繁忙,请稍后再试'); + } + this.commitSendSlot(slot); + if (waitMs > 0) { + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + return { waitMs }; } - private assertInterval( + assertCanSend(selfId: string, targetId: string) { + const now = Date.now(); + const minInterval = this.getGlobalIntervalMs(); + const globalKey = `${selfId}:global`; + const targetKey = `${selfId}:${targetId}`; + const globalNextAt = this.getNextGlobalAvailableAt( + globalKey, + now, + minInterval, + now, + ); + const targetNextAt = this.getNextTargetAvailableAt( + targetKey, + this.getTargetIntervalMs(), + now, + ); + + if (globalNextAt > now) throwVbenError('QQBot 全局发送过快'); + if (targetNextAt > now) throwVbenError('QQBot 单会话发送过快'); + + this.commitSendSlot({ globalKey, nextAt: now, targetKey }); + } + + private planSendSlot(selfId: string, targetId: string, now: number) { + const globalKey = `${selfId}:global`; + const targetKey = `${selfId}:${targetId}`; + const targetAvailableAt = this.getNextTargetAvailableAt( + targetKey, + this.getTargetIntervalMs(), + now, + ); + const baseAt = Math.max(now, targetAvailableAt); + const jitterMs = baseAt > now ? this.getJitterMs() : 0; + const nextAt = this.getNextGlobalAvailableAt( + globalKey, + baseAt + jitterMs, + this.getGlobalIntervalMs(), + now, + ); + return { globalKey, nextAt, targetKey }; + } + + private commitSendSlot(slot: { + globalKey: string; + nextAt: number; + targetKey: string; + }) { + const globalIntervalMs = this.getGlobalIntervalMs(); + const reserved = this.getFreshGlobalReservations( + slot.globalKey, + globalIntervalMs, + Date.now(), + ); + reserved.push(slot.nextAt); + reserved.sort((first, second) => first - second); + this.globalReservedAt.set(slot.globalKey, reserved); + this.targetReservedAt.set(slot.targetKey, slot.nextAt); + } + + private getFreshGlobalReservations( key: string, + intervalMs: number, now: number, - minInterval: number, - msg: string, ) { - const last = this.lastSentAt.get(key) || 0; - if (now - last < minInterval) { - throwVbenError(msg); + return (this.globalReservedAt.get(key) || []).filter( + (reservedAt) => reservedAt >= now - intervalMs, + ); + } + + private getNextGlobalAvailableAt( + key: string, + earliestAt: number, + intervalMs: number, + now: number, + ) { + let candidateAt = earliestAt; + const reserved = this.getFreshGlobalReservations( + key, + intervalMs, + now, + ).sort((first, second) => first - second); + + for (const reservedAt of reserved) { + if (Math.abs(candidateAt - reservedAt) < intervalMs) { + candidateAt = reservedAt + intervalMs; + } } + + return candidateAt; + } + + private getNextTargetAvailableAt( + key: string, + intervalMs: number, + now: number, + ) { + const last = this.targetReservedAt.get(key); + return last === undefined ? now : last + intervalMs; + } + + private getGlobalIntervalMs() { + const configured = this.getPositiveInteger('QQBOT_SEND_GLOBAL_INTERVAL_MS'); + if (configured) return configured; + return Math.max(2500, Math.ceil(1000 / this.getRatePerSecond())); + } + + private getTargetIntervalMs() { + return this.getPositiveInteger('QQBOT_SEND_TARGET_INTERVAL_MS') || 8000; + } + + private getJitterMs() { + const max = this.getPositiveInteger('QQBOT_SEND_JITTER_MS') ?? 800; + return max > 0 ? Math.floor(Math.random() * (max + 1)) : 0; + } + + private getMaxQueueWaitMs() { + return this.getPositiveInteger('QQBOT_SEND_MAX_QUEUE_WAIT_MS') || 30000; } private getRatePerSecond() { @@ -39,4 +148,9 @@ export class QqbotRateLimitService { ); return Number.isFinite(value) && value > 0 ? value : 1; } + + private getPositiveInteger(key: string) { + const value = Number(this.configService.get(key)); + return Number.isInteger(value) && value >= 0 ? value : undefined; + } } diff --git a/src/qqbot/send/qqbot-send.service.ts b/src/qqbot/send/qqbot-send.service.ts index b83127d..9f302cc 100644 --- a/src/qqbot/send/qqbot-send.service.ts +++ b/src/qqbot/send/qqbot-send.service.ts @@ -100,7 +100,10 @@ export class QqbotSendService { throwVbenError('没有可用 QQBot 账号'); } - this.rateLimitService.assertCanSend(account.selfId, params.targetId); + await this.rateLimitService.waitForSendSlot( + account.selfId, + params.targetId, + ); const { action, actionParams } = this.buildAction(params); const storedMessageText = this.toolsService.toStoredMessageText( diff --git a/test/qqbot/plugins/repeater/qqbot-repeater.plugin.spec.ts b/test/qqbot/plugins/repeater/qqbot-repeater.plugin.spec.ts new file mode 100644 index 0000000..fb79620 --- /dev/null +++ b/test/qqbot/plugins/repeater/qqbot-repeater.plugin.spec.ts @@ -0,0 +1,121 @@ +import { ToolsService } from '@/common'; +import { QqbotRepeaterPluginService } from '@/qqbot/plugins/repeater/qqbot-repeater.plugin'; +import type { QqbotNormalizedMessage } from '@/qqbot/qqbot.types'; + +function createMessage(text: string): QqbotNormalizedMessage { + return { + eventTime: new Date(), + groupId: 'group-1', + messageId: `message-${text}-${Date.now()}`, + messageText: text, + messageType: 'group', + rawEvent: {}, + rawMessage: text, + selfId: 'bot-1', + targetId: 'group-1', + userId: 'user-1', + }; +} + +function createService(config: Record) { + const sendService = { + sendText: jest.fn().mockResolvedValue({ status: 'ok' }), + }; + const service = new QqbotRepeaterPluginService( + { + get: jest.fn((key: string) => config[key]), + } as any, + { + getBoundEventPluginKeys: jest.fn().mockResolvedValue(['repeater']), + } as any, + sendService as any, + new ToolsService(), + ); + return { sendService, service }; +} + +describe('QqbotRepeaterPluginService risk-control defaults', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-12T00:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('uses a conservative default threshold before repeating', async () => { + const { sendService, service } = createService({}); + + await service.handleMessage(createMessage('哈')); + await service.handleMessage(createMessage('哈')); + await service.handleMessage(createMessage('哈')); + + expect(sendService.sendText).not.toHaveBeenCalled(); + + await service.handleMessage(createMessage('哈')); + + expect(sendService.sendText).toHaveBeenCalledTimes(1); + }); + + it('applies a conversation interval after one repeated send', async () => { + const { sendService, service } = createService({ + QQBOT_REPEATER_CONFIG_CACHE_TTL_MS: 600000, + QQBOT_REPEATER_MIN_INTERVAL_MS: 600000, + QQBOT_REPEATER_THRESHOLD: 4, + }); + + for (let index = 0; index < 4; index += 1) { + await service.handleMessage(createMessage('哈')); + } + for (let index = 0; index < 4; index += 1) { + await service.handleMessage(createMessage('呀')); + } + + expect(sendService.sendText).toHaveBeenCalledTimes(1); + + jest.setSystemTime(new Date(Date.now() + 600000)); + await service.handleMessage(createMessage('呀')); + + expect(sendService.sendText).toHaveBeenCalledTimes(2); + }); + + it('applies the conversation interval even when repeated send fails', async () => { + const { sendService, service } = createService({ + QQBOT_REPEATER_CONFIG_CACHE_TTL_MS: 600000, + QQBOT_REPEATER_MIN_INTERVAL_MS: 600000, + QQBOT_REPEATER_THRESHOLD: 4, + }); + sendService.sendText.mockRejectedValueOnce(new Error('send failed')); + jest.spyOn((service as any).logger, 'warn').mockImplementation(); + + for (let index = 0; index < 4; index += 1) { + await service.handleMessage(createMessage('哈')); + } + for (let index = 0; index < 4; index += 1) { + await service.handleMessage(createMessage('呀')); + } + + expect(sendService.sendText).toHaveBeenCalledTimes(1); + }); + + it('keeps the conversation interval after a non-repeatable message', async () => { + const { sendService, service } = createService({ + QQBOT_REPEATER_CONFIG_CACHE_TTL_MS: 600000, + QQBOT_REPEATER_MIN_INTERVAL_MS: 600000, + QQBOT_REPEATER_THRESHOLD: 4, + }); + + for (let index = 0; index < 4; index += 1) { + await service.handleMessage(createMessage('哈')); + } + + await service.handleMessage(createMessage('!help')); + + for (let index = 0; index < 4; index += 1) { + await service.handleMessage(createMessage('呀')); + } + + expect(sendService.sendText).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/qqbot/qqbot-cooldown.policy.spec.ts b/test/qqbot/qqbot-cooldown.policy.spec.ts new file mode 100644 index 0000000..40745bf --- /dev/null +++ b/test/qqbot/qqbot-cooldown.policy.spec.ts @@ -0,0 +1,29 @@ +import { + getEffectiveCooldownMs, + isWithinCooldown, +} from '@/qqbot/qqbot-cooldown.policy'; + +describe('QQBot cooldown policy', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-12T00:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('uses the runtime floor when the database cooldown is too low', () => { + expect(getEffectiveCooldownMs(500, 30000)).toBe(30000); + }); + + it('keeps a hit in cooldown by the effective runtime floor', () => { + expect( + isWithinCooldown({ + cooldownMs: 500, + lastHitAt: new Date(Date.now() - 10000), + minCooldownMs: 30000, + }), + ).toBe(true); + }); +}); diff --git a/test/qqbot/rule/qqbot-rule-cooldown.service.spec.ts b/test/qqbot/rule/qqbot-rule-cooldown.service.spec.ts new file mode 100644 index 0000000..e6b97d4 --- /dev/null +++ b/test/qqbot/rule/qqbot-rule-cooldown.service.spec.ts @@ -0,0 +1,37 @@ +import { ToolsService } from '@/common'; +import { QqbotRuleService } from '@/qqbot/rule/qqbot-rule.service'; + +function createService(config: Record) { + return new (QqbotRuleService as any)( + {} as any, + {} as any, + new ToolsService(), + { + get: jest.fn((key: string) => config[key]), + } as any, + ) as QqbotRuleService; +} + +describe('QqbotRuleService cooldown floor', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-12T00:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('keeps a rule in cooldown when database cooldown is lower than the runtime floor', () => { + const service = createService({ + QQBOT_RULE_MIN_COOLDOWN_MS: 30000, + }); + + expect( + service.isInCooldown({ + cooldownMs: 500, + lastHitAt: new Date(Date.now() - 10000), + } as any), + ).toBe(true); + }); +}); diff --git a/test/qqbot/send/qqbot-rate-limit.service.spec.ts b/test/qqbot/send/qqbot-rate-limit.service.spec.ts new file mode 100644 index 0000000..4d84e78 --- /dev/null +++ b/test/qqbot/send/qqbot-rate-limit.service.spec.ts @@ -0,0 +1,115 @@ +import { QqbotRateLimitService } from '@/qqbot/send/qqbot-rate-limit.service'; + +function createService(config: Record) { + return new QqbotRateLimitService({ + get: jest.fn((key: string) => config[key]), + } as any); +} + +describe('QqbotRateLimitService', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-12T00:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('queues a repeated target send instead of rejecting it immediately', async () => { + const service = createService({ + QQBOT_SEND_GLOBAL_INTERVAL_MS: 1000, + QQBOT_SEND_JITTER_MS: 0, + QQBOT_SEND_MAX_QUEUE_WAIT_MS: 10000, + QQBOT_SEND_TARGET_INTERVAL_MS: 3000, + }); + + await expect( + (service as any).waitForSendSlot('bot-1', 'group-1'), + ).resolves.toEqual({ waitMs: 0 }); + + const secondSend = (service as any).waitForSendSlot('bot-1', 'group-1'); + + jest.advanceTimersByTime(2999); + await Promise.resolve(); + await expect(Promise.race([secondSend, Promise.resolve('pending')])) + .resolves.toBe('pending'); + + jest.advanceTimersByTime(1); + await Promise.resolve(); + await expect(secondSend).resolves.toEqual({ waitMs: 3000 }); + }); + + it('rejects when the queued send would wait beyond the configured budget', async () => { + const service = createService({ + QQBOT_SEND_GLOBAL_INTERVAL_MS: 1000, + QQBOT_SEND_JITTER_MS: 0, + QQBOT_SEND_MAX_QUEUE_WAIT_MS: 1000, + QQBOT_SEND_TARGET_INTERVAL_MS: 3000, + }); + + await (service as any).waitForSendSlot('bot-1', 'group-1'); + + await expect( + (service as any).waitForSendSlot('bot-1', 'group-1'), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + msg: 'QQBot 发送队列繁忙,请稍后再试', + }), + }); + }); + + it('does not reserve a slot for a rejected queued send', async () => { + const service = createService({ + QQBOT_SEND_GLOBAL_INTERVAL_MS: 1000, + QQBOT_SEND_JITTER_MS: 0, + QQBOT_SEND_MAX_QUEUE_WAIT_MS: 1000, + QQBOT_SEND_TARGET_INTERVAL_MS: 3000, + }); + + await (service as any).waitForSendSlot('bot-1', 'group-1'); + + await expect( + (service as any).waitForSendSlot('bot-1', 'group-1'), + ).rejects.toBeDefined(); + + jest.advanceTimersByTime(3000); + + await expect( + (service as any).waitForSendSlot('bot-1', 'group-1'), + ).resolves.toEqual({ waitMs: 0 }); + }); + + it('does not let one target queue block another target global slot', async () => { + const service = createService({ + QQBOT_SEND_GLOBAL_INTERVAL_MS: 1000, + QQBOT_SEND_JITTER_MS: 0, + QQBOT_SEND_MAX_QUEUE_WAIT_MS: 10000, + QQBOT_SEND_TARGET_INTERVAL_MS: 3000, + }); + + await (service as any).waitForSendSlot('bot-1', 'group-1'); + + const sameTargetSend = (service as any).waitForSendSlot( + 'bot-1', + 'group-1', + ); + const otherTargetSend = (service as any).waitForSendSlot( + 'bot-1', + 'group-2', + ); + + jest.advanceTimersByTime(999); + await Promise.resolve(); + await expect(Promise.race([otherTargetSend, Promise.resolve('pending')])) + .resolves.toBe('pending'); + + jest.advanceTimersByTime(1); + await Promise.resolve(); + await expect(otherTargetSend).resolves.toEqual({ waitMs: 1000 }); + + jest.advanceTimersByTime(2000); + await Promise.resolve(); + await expect(sameTargetSend).resolves.toEqual({ waitMs: 3000 }); + }); +}); diff --git a/test/qqbot/send/qqbot-send.service.spec.ts b/test/qqbot/send/qqbot-send.service.spec.ts index e992a5c..e80e5ae 100644 --- a/test/qqbot/send/qqbot-send.service.spec.ts +++ b/test/qqbot/send/qqbot-send.service.spec.ts @@ -31,7 +31,7 @@ describe('QqbotSendService', () => { busService as any, messageService as any, { get: jest.fn(() => reverseWsService) } as any, - { assertCanSend: jest.fn() } as any, + { waitForSendSlot: jest.fn().mockResolvedValue({ waitMs: 0 }) } as any, new ToolsService(), ); (service as any).getReverseWsService = jest