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_AUTO_REGISTER_ACCOUNT=true
|
||||||
QQBOT_API_TIMEOUT_MS=10000
|
QQBOT_API_TIMEOUT_MS=10000
|
||||||
QQBOT_SEND_RATE_PER_SECOND=1
|
QQBOT_SEND_RATE_PER_SECOND=1
|
||||||
QQBOT_REPEATER_THRESHOLD=2
|
QQBOT_SEND_GLOBAL_INTERVAL_MS=2500
|
||||||
QQBOT_REPEATER_MAX_TEXT_LENGTH=300
|
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_STATE_TTL_MS=600000
|
||||||
QQBOT_REPEATER_CONFIG_CACHE_TTL_MS=2000
|
QQBOT_REPEATER_CONFIG_CACHE_TTL_MS=2000
|
||||||
NAPCAT_WEBUI_BASE_URL=http://127.0.0.1:6099
|
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` |
|
| 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 | `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` |
|
| 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_*` |
|
| NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` |
|
||||||
| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` |
|
| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` |
|
||||||
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
|
| 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` 默认镜像。
|
同一 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
|
### Command / Rule / Permission
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|
|||||||
@ -60,7 +60,7 @@ ci/ Jenkins Agent/Docker 辅助文件
|
|||||||
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
|
| 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` |
|
| 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_*` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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。
|
- 系统日志由 pino 输出,Loki 查询统一通过后端 `/system/logs/*` 代理,前端不直连 Loki。
|
||||||
- 日志级站内信只承接运行期事件:接口 5xx、QQBot 下线 notice、NapCat 容器最新离线日志会自动聚合通知 `super` 角色;服务端强制 `super` 访问,Admin 不再暴露人工新增/编辑入口;长路径接口错误会压缩 `dedupeKey/title` 到表字段长度内,避免通知入库失败。
|
- 日志级站内信只承接运行期事件:接口 5xx、QQBot 下线 notice、NapCat 容器最新离线日志会自动聚合通知 `super` 角色;服务端强制 `super` 访问,Admin 不再暴露人工新增/编辑入口;长路径接口错误会压缩 `dedupeKey/title` 到表字段长度内,避免通知入库失败。
|
||||||
- QQBot 扫码登录通过 SSE `/qqbot/account/scan/events` 暴露进度,耗时链路不应阻塞普通 HTTP 响应。
|
- QQBot 扫码登录通过 SSE `/qqbot/account/scan/events` 暴露进度,耗时链路不应阻塞普通 HTTP 响应。
|
||||||
|
- QQBot 外发统一走发送排队:默认全局间隔 `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 字符截断,后续无错误的普通断连不能清空该原因;账号列表日志检测带近期缓存和短超时,账号连接时间或心跳晚于容器检测时间时以账号在线态为准,最新日志为在线时清空容器旧离线错误。
|
- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表日志检测带近期缓存和短超时,账号连接时间或心跳晚于容器检测时间时以账号在线态为准,最新日志为在线时清空容器旧离线错误。
|
||||||
- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。
|
- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。
|
||||||
- BangDream 当前源码根目录是 `src/qqbot/plugins/bangDream`;不要恢复旧 `tsugu` 层级或旧大桶目录。
|
- BangDream 当前源码根目录是 `src/qqbot/plugins/bangDream`;不要恢复旧 `tsugu` 层级或旧大桶目录。
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Not, Repository } from 'typeorm';
|
import { Not, Repository } from 'typeorm';
|
||||||
import { throwVbenError, ToolsService } from '@/common';
|
import { throwVbenError, ToolsService } from '@/common';
|
||||||
@ -18,6 +19,7 @@ import type {
|
|||||||
QqbotCommandQueryDto,
|
QqbotCommandQueryDto,
|
||||||
QqbotCommandUpdateDto,
|
QqbotCommandUpdateDto,
|
||||||
} from './qqbot-command.dto';
|
} from './qqbot-command.dto';
|
||||||
|
import { isWithinCooldown } from '../qqbot-cooldown.policy';
|
||||||
import { QqbotCommandLog } from './qqbot-command-log.entity';
|
import { QqbotCommandLog } from './qqbot-command-log.entity';
|
||||||
import { QqbotCommand } from './qqbot-command.entity';
|
import { QqbotCommand } from './qqbot-command.entity';
|
||||||
|
|
||||||
@ -31,6 +33,7 @@ export class QqbotCommandService {
|
|||||||
private readonly accountService: QqbotAccountService,
|
private readonly accountService: QqbotAccountService,
|
||||||
private readonly pluginRegistry: QqbotPluginRegistryService,
|
private readonly pluginRegistry: QqbotPluginRegistryService,
|
||||||
private readonly toolsService: ToolsService,
|
private readonly toolsService: ToolsService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async page(query: QqbotCommandQueryDto) {
|
async page(query: QqbotCommandQueryDto) {
|
||||||
@ -157,10 +160,11 @@ export class QqbotCommandService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isInCooldown(command: QqbotCommand) {
|
isInCooldown(command: QqbotCommand) {
|
||||||
if (!command.lastHitAt || !command.cooldownMs) return false;
|
return isWithinCooldown({
|
||||||
return (
|
cooldownMs: command.cooldownMs,
|
||||||
Date.now() - new Date(command.lastHitAt).getTime() < command.cooldownMs
|
lastHitAt: command.lastHitAt,
|
||||||
);
|
minCooldownMs: this.getMinCooldownMs(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async logExecution(params: {
|
async logExecution(params: {
|
||||||
@ -217,7 +221,10 @@ export class QqbotCommandService {
|
|||||||
return {
|
return {
|
||||||
aliases: this.stringifyList(body.aliases),
|
aliases: this.stringifyList(body.aliases),
|
||||||
code,
|
code,
|
||||||
cooldownMs: Number(body.cooldownMs ?? 1500),
|
cooldownMs: Math.max(
|
||||||
|
Number(body.cooldownMs ?? this.getMinCooldownMs()),
|
||||||
|
this.getMinCooldownMs(),
|
||||||
|
),
|
||||||
defaultParams: this.stringifyParams(body.defaultParams),
|
defaultParams: this.stringifyParams(body.defaultParams),
|
||||||
enabled: body.enabled ?? true,
|
enabled: body.enabled ?? true,
|
||||||
errorTemplate: body.errorTemplate || null,
|
errorTemplate: body.errorTemplate || null,
|
||||||
@ -324,4 +331,9 @@ export class QqbotCommandService {
|
|||||||
targetType: command.targetType,
|
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);
|
const state = this.getNextState(key, text);
|
||||||
if (!this.shouldRepeat(state, text)) return false;
|
if (!this.shouldRepeat(state, text)) return false;
|
||||||
|
|
||||||
|
const repeatedAt = Date.now();
|
||||||
state.repeatedText = text;
|
state.repeatedText = text;
|
||||||
|
state.lastRepeatedAt = repeatedAt;
|
||||||
|
state.updatedAt = repeatedAt;
|
||||||
try {
|
try {
|
||||||
await this.sendService.sendText({
|
await this.sendService.sendText({
|
||||||
channelId: message.channelId,
|
channelId: message.channelId,
|
||||||
@ -157,6 +160,7 @@ export class QqbotRepeaterPluginService {
|
|||||||
: {
|
: {
|
||||||
count: 1,
|
count: 1,
|
||||||
lastText: text,
|
lastText: text,
|
||||||
|
lastRepeatedAt: current?.lastRepeatedAt || 0,
|
||||||
repeatedText: '',
|
repeatedText: '',
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@ -166,11 +170,28 @@ export class QqbotRepeaterPluginService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private shouldRepeat(state: QqbotRepeaterConversationState, text: string) {
|
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) {
|
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) {
|
private buildStateKey(message: QqbotNormalizedMessage) {
|
||||||
@ -179,14 +200,21 @@ export class QqbotRepeaterPluginService {
|
|||||||
|
|
||||||
private getThreshold() {
|
private getThreshold() {
|
||||||
const value = Number(this.configService.get('QQBOT_REPEATER_THRESHOLD'));
|
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() {
|
private getMaxTextLength() {
|
||||||
const value = Number(
|
const value = Number(
|
||||||
this.configService.get('QQBOT_REPEATER_MAX_TEXT_LENGTH'),
|
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() {
|
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 = {
|
export type QqbotRepeaterConversationState = {
|
||||||
count: number;
|
count: number;
|
||||||
|
lastRepeatedAt?: number;
|
||||||
lastText: string;
|
lastText: string;
|
||||||
repeatedText: string;
|
repeatedText: string;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { throwVbenError, ToolsService } from '@/common';
|
import { throwVbenError, ToolsService } from '@/common';
|
||||||
@ -18,6 +19,7 @@ import {
|
|||||||
QQBOT_DEFAULT_PAGE_NO,
|
QQBOT_DEFAULT_PAGE_NO,
|
||||||
QQBOT_DEFAULT_PAGE_SIZE,
|
QQBOT_DEFAULT_PAGE_SIZE,
|
||||||
} from '../qqbot.constants';
|
} from '../qqbot.constants';
|
||||||
|
import { isWithinCooldown } from '../qqbot-cooldown.policy';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class QqbotRuleService {
|
export class QqbotRuleService {
|
||||||
@ -26,6 +28,7 @@ export class QqbotRuleService {
|
|||||||
private readonly ruleRepository: Repository<QqbotRule>,
|
private readonly ruleRepository: Repository<QqbotRule>,
|
||||||
private readonly accountService: QqbotAccountService,
|
private readonly accountService: QqbotAccountService,
|
||||||
private readonly toolsService: ToolsService,
|
private readonly toolsService: ToolsService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async page(query: QqbotRuleQueryDto) {
|
async page(query: QqbotRuleQueryDto) {
|
||||||
@ -145,8 +148,11 @@ export class QqbotRuleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isInCooldown(rule: QqbotRule) {
|
isInCooldown(rule: QqbotRule) {
|
||||||
if (!rule.lastHitAt || !rule.cooldownMs) return false;
|
return isWithinCooldown({
|
||||||
return Date.now() - new Date(rule.lastHitAt).getTime() < rule.cooldownMs;
|
cooldownMs: rule.cooldownMs,
|
||||||
|
lastHitAt: rule.lastHitAt,
|
||||||
|
minCooldownMs: this.getMinCooldownMs(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertRuleValid(matchType: QqbotRuleMatchType, keyword: string) {
|
private assertRuleValid(matchType: QqbotRuleMatchType, keyword: string) {
|
||||||
@ -164,7 +170,10 @@ export class QqbotRuleService {
|
|||||||
|
|
||||||
private normalizeBody(body: Partial<QqbotRuleBodyDto>) {
|
private normalizeBody(body: Partial<QqbotRuleBodyDto>) {
|
||||||
return {
|
return {
|
||||||
cooldownMs: Number(body.cooldownMs ?? 1500),
|
cooldownMs: Math.max(
|
||||||
|
Number(body.cooldownMs ?? this.getMinCooldownMs()),
|
||||||
|
this.getMinCooldownMs(),
|
||||||
|
),
|
||||||
enabled: body.enabled ?? true,
|
enabled: body.enabled ?? true,
|
||||||
keyword: body.keyword || '',
|
keyword: body.keyword || '',
|
||||||
matchType: (body.matchType || 'keyword') as QqbotRuleMatchType,
|
matchType: (body.matchType || 'keyword') as QqbotRuleMatchType,
|
||||||
@ -175,4 +184,9 @@ export class QqbotRuleService {
|
|||||||
targetType: (body.targetType || 'all') as QqbotRuleTargetType,
|
targetType: (body.targetType || 'all') as QqbotRuleTargetType,
|
||||||
} as Partial<QqbotRule>;
|
} 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()
|
@Injectable()
|
||||||
export class QqbotRateLimitService {
|
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) {}
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
assertCanSend(selfId: string, targetId: string) {
|
async waitForSendSlot(selfId: string, targetId: string) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const globalKey = `${selfId}:global`;
|
const slot = this.planSendSlot(selfId, targetId, now);
|
||||||
const targetKey = `${selfId}:${targetId}`;
|
const waitMs = slot.nextAt - now;
|
||||||
const minInterval = Math.ceil(1000 / this.getRatePerSecond());
|
if (waitMs > this.getMaxQueueWaitMs()) {
|
||||||
|
throwVbenError('QQBot 发送队列繁忙,请稍后再试');
|
||||||
this.assertInterval(globalKey, now, minInterval, 'QQBot 全局发送过快');
|
}
|
||||||
this.assertInterval(targetKey, now, 1000, 'QQBot 单会话发送过快');
|
this.commitSendSlot(slot);
|
||||||
|
if (waitMs > 0) {
|
||||||
this.lastSentAt.set(globalKey, now);
|
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||||
this.lastSentAt.set(targetKey, now);
|
}
|
||||||
|
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,
|
key: string,
|
||||||
|
intervalMs: number,
|
||||||
now: number,
|
now: number,
|
||||||
minInterval: number,
|
|
||||||
msg: string,
|
|
||||||
) {
|
) {
|
||||||
const last = this.lastSentAt.get(key) || 0;
|
return (this.globalReservedAt.get(key) || []).filter(
|
||||||
if (now - last < minInterval) {
|
(reservedAt) => reservedAt >= now - intervalMs,
|
||||||
throwVbenError(msg);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
private getRatePerSecond() {
|
||||||
@ -39,4 +148,9 @@ export class QqbotRateLimitService {
|
|||||||
);
|
);
|
||||||
return Number.isFinite(value) && value > 0 ? value : 1;
|
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 账号');
|
throwVbenError('没有可用 QQBot 账号');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.rateLimitService.assertCanSend(account.selfId, params.targetId);
|
await this.rateLimitService.waitForSendSlot(
|
||||||
|
account.selfId,
|
||||||
|
params.targetId,
|
||||||
|
);
|
||||||
|
|
||||||
const { action, actionParams } = this.buildAction(params);
|
const { action, actionParams } = this.buildAction(params);
|
||||||
const storedMessageText = this.toolsService.toStoredMessageText(
|
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,
|
busService as any,
|
||||||
messageService as any,
|
messageService as any,
|
||||||
{ get: jest.fn(() => reverseWsService) } as any,
|
{ get: jest.fn(() => reverseWsService) } as any,
|
||||||
{ assertCanSend: jest.fn() } as any,
|
{ waitForSendSlot: jest.fn().mockResolvedValue({ waitMs: 0 }) } as any,
|
||||||
new ToolsService(),
|
new ToolsService(),
|
||||||
);
|
);
|
||||||
(service as any).getReverseWsService = jest
|
(service as any).getReverseWsService = jest
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user