feat: 缓解QQBot风控触发
This commit is contained in:
parent
2d273994f6
commit
4d8e8c3bbc
11
.env.example
11
.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
|
||||
|
||||
4
API.md
4
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
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|
||||
@ -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` 层级或旧大桶目录。
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
25
src/qqbot/qqbot-cooldown.policy.ts
Normal file
25
src/qqbot/qqbot-cooldown.policy.ts
Normal 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;
|
||||
}
|
||||
@ -301,6 +301,7 @@ export type QqbotReverseActionSender = {
|
||||
|
||||
export type QqbotRepeaterConversationState = {
|
||||
count: number;
|
||||
lastRepeatedAt?: number;
|
||||
lastText: string;
|
||||
repeatedText: string;
|
||||
updatedAt: number;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
121
test/qqbot/plugins/repeater/qqbot-repeater.plugin.spec.ts
Normal file
121
test/qqbot/plugins/repeater/qqbot-repeater.plugin.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
29
test/qqbot/qqbot-cooldown.policy.spec.ts
Normal file
29
test/qqbot/qqbot-cooldown.policy.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
37
test/qqbot/rule/qqbot-rule-cooldown.service.spec.ts
Normal file
37
test/qqbot/rule/qqbot-rule-cooldown.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
115
test/qqbot/send/qqbot-rate-limit.service.spec.ts
Normal file
115
test/qqbot/send/qqbot-rate-limit.service.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user