feat: 缓解QQBot风控触发

This commit is contained in:
sunlei 2026-06-12 13:21:39 +08:00
parent 2d273994f6
commit 4d8e8c3bbc
15 changed files with 546 additions and 35 deletions

View File

@ -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

4
API.md
View File

@ -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
| 方法 | 路径 | 说明 |

View File

@ -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` 层级或旧大桶目录。

View File

@ -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;
}
}

View File

@ -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() {

View File

@ -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;
}

View File

@ -301,6 +301,7 @@ export type QqbotReverseActionSender = {
export type QqbotRepeaterConversationState = {
count: number;
lastRepeatedAt?: number;
lastText: string;
repeatedText: string;
updatedAt: number;

View File

@ -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<QqbotRule>,
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<QqbotRuleBodyDto>) {
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<QqbotRule>;
}
private getMinCooldownMs() {
const value = Number(this.configService.get('QQBOT_RULE_MIN_COOLDOWN_MS'));
return Number.isInteger(value) && value > 0 ? value : 30000;
}
}

View File

@ -4,33 +4,142 @@ import { throwVbenError } from '@/common';
@Injectable()
export class QqbotRateLimitService {
private readonly lastSentAt = new Map<string, number>();
private readonly globalReservedAt = new Map<string, number[]>();
private readonly targetReservedAt = new Map<string, number>();
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;
}
}

View File

@ -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(

View File

@ -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<string, number | string | undefined>) {
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);
});
});

View File

@ -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);
});
});

View File

@ -0,0 +1,37 @@
import { ToolsService } from '@/common';
import { QqbotRuleService } from '@/qqbot/rule/qqbot-rule.service';
function createService(config: Record<string, number | string | undefined>) {
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);
});
});

View File

@ -0,0 +1,115 @@
import { QqbotRateLimitService } from '@/qqbot/send/qqbot-rate-limit.service';
function createService(config: Record<string, number | string | undefined>) {
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 });
});
});

View File

@ -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