feat(chat): add slow mode for group text panels

moonrailgun/admin-next
moonrailgun 1 month ago
parent dabeedc94b
commit d2a292879a

@ -20,6 +20,31 @@ The primary runtime flow is:
Do not bypass this flow by coupling core UI to plugin internals, accessing data
around service actions, or relying on client-side authorization.
## Distributed deployment assumptions
Tailchat is deployed as a distributed system and may run multiple gateway and
service instances. Treat this as a design constraint for every backend feature,
even when local development uses a single process.
- Do not keep correctness-critical shared state only in process memory. Store it
in MongoDB, Redis, or another configured shared service; local memory may be
used only as a disposable optimization.
- Assume concurrent requests for the same user or resource can reach different
instances. Use database atomic operations, transactions, Redis Lua scripts,
locks, idempotency keys, or another appropriate distributed coordination
mechanism when correctness depends on ordering or exclusivity.
- Do not rely on sticky sessions, a particular service instance, local timers,
or in-process event delivery for cross-request correctness.
- Namespace shared keys by deployment and domain, set bounded TTLs where
appropriate, and define cleanup behavior for configuration changes and
deleted resources.
- For time-window behavior, account for clock skew between nodes. Prefer a
shared authoritative clock, such as Redis server time, when the decision and
countdown must agree across instances.
- Decide and document whether a shared-dependency failure should fail open or
fail closed. Add focused concurrency and failure-path tests for distributed
state rather than validating only the single-instance happy path.
## Repository map
- `client/web`: browser application, routes, browser integration, and plugin host.

@ -14,7 +14,22 @@ export type CommonRequestResult<T> =
result: true;
} & T);
class RequestError extends Error {}
export class RequestError extends Error {
code?: number;
type?: string;
data?: unknown;
constructor(
message: string,
options?: { code?: number; type?: string; data?: unknown }
) {
super(message);
this.name = 'RequestError';
this.code = options?.code;
this.type = options?.type;
this.data = options?.data;
}
}
export type RequestConfig = AxiosRequestConfig;
@ -80,7 +95,11 @@ function createRequest() {
// }
}
throw new RequestError(errorMsg ?? err.message);
throw new RequestError(errorMsg ?? err.message, {
code,
type: responseData.type,
data: responseData.data,
});
}
);

@ -41,6 +41,15 @@ export interface SharedEventMap {
*/
sendMessage: (payload: SendMessagePayload) => void;
/**
*
*/
slowModeLimited: (payload: {
converseId: string;
retryAfterMs: number;
resetAt: string;
}) => void;
/**
*
*

@ -3,6 +3,7 @@
"k1096add": "Create permanent invite code",
"k10c018fe": "Teamwork",
"k1141d649": "Version update",
"k119bbcab": "Rate limited",
"k11cef91e": "There are some problems with the page",
"k123852c": "Group privacy control to prevent malicious harassment of users through groups.",
"k1252f904": "Gateway",
@ -46,6 +47,7 @@
"k250e392c": "System is busy, please try again later",
"k263bff41": "Edit invite link",
"k267cc491": "Me",
"k26badc7d": "Slow mode · Retry in {{countdown}}",
"k29498d11": "Can be used <2>{{num}}</2> times",
"k2a1422d2": "Configuration",
"k2a8031e": "Homepage",
@ -100,6 +102,8 @@
"k41aea19d": "Only supports http paths",
"k4231ab36": "Performance statistics",
"k42a98418": "File Service",
"k448ba80f": "Slow mode",
"k4495b5ab": "messages",
"k4539164b": "The OTP code is 6 digits",
"k45e2f71f": "Temporary users cannot verify email address, please claim account first",
"k4603baea": "Create Group Panel",
@ -119,6 +123,7 @@
"k50504f9e": "Upload picture to converse",
"k50d471b2": "Reset",
"k511aea70": "Permissions",
"k511f685b": "Enable slow mode",
"k51243586": "Add friend",
"k517db7e5": "Text Channel",
"k51db56bf": "Temporary Meeting",
@ -135,6 +140,7 @@
"k5bb71ad7": "Installed",
"k5be1a5b0": "Delete role group",
"k5bec387": "Unable to get group information",
"k5c07ac": "Limit member message frequency",
"k5ce4e16d": "Clear",
"k5d1e4cd8": "Custom styles are not supported",
"k5d2a6631": "Allow to manage channels",
@ -162,6 +168,7 @@
"k685a1e78": "Copied to clipboard",
"k6910ad02": "Send OTP code",
"k6b5e7ffe": "The current panel has been opened in a separate window",
"k6b64b97": "Every",
"k6b82e672": "The plugin is installed successfully, and it will take effect after refreshing the page",
"k6c29eece": "Allows members to send message in text channels",
"k6c75d61f": "Modify group avatar success",
@ -190,6 +197,7 @@
"k77d3028d": "Unknown Panel",
"k77ee6a43": "Member Manage",
"k78102887": "Member Count",
"k78875066": "Quotas are tracked separately for each member; system messages and bots are exempt",
"k78e52ed0": "Accept",
"k79304fa9": "Nickname changed successfully",
"k795c9a6e": "5 uses",
@ -287,6 +295,7 @@
"kadeb7a89": "Not supported documentation links",
"kae072a10": "Not found this account",
"kae0a0c12": "Search user",
"kaea1020b": "Once the limit is reached, the countdown starts from the earliest message",
"kaeec12de": "Manual Install",
"kaefc1e64": "Password reset successful, now back to login page",
"kaf403ef0": "Light Mode",
@ -315,6 +324,7 @@
"kb8ec7062": "Email verification passed",
"kb93f5858": "Allow members to modify group basic information",
"kb96b79c5": "Allow management of invitation links",
"kbb8a81a9": "Slow mode · Up to {{maxMessages}} messages every {{interval}} · {{remaining}} remaining",
"kbc76781d": "No permission to send messages, please contact the group owner",
"kbcacf812": "Are you sure to clear the inbox?",
"kbcb00ae5": "There is no further description for this plugin",
@ -383,6 +393,7 @@
"kdce55773": "The plugin is uninstalled successfully, and it will take effect after refreshing the page",
"kdd4c838c": "Jump to Group",
"kdd6c18f8": "Service exception",
"kde6b193f": "send up to",
"kdef84ee6": "Plugin",
"kdf6e53ca": "Converse does not have permission",
"kdf89681f": "Add friend nickname",
@ -396,6 +407,7 @@
"ke17b2c87": "Do not upload pictures that violate local laws and regulations",
"ke187440d": "Panel type cannot be empty",
"ke2431c67": "Plugin render function does not exist",
"ke2c48bc": "{{minutes}} min",
"ke3a77a77": "Unlimited",
"ke3d797fd": "Drop files to send into current converse",
"ke59ffe49": "Muted, there are {{remain}} left",

@ -3,6 +3,7 @@
"k1096add": "创建永久邀请码",
"k10c018fe": "工作协同",
"k1141d649": "更新版本",
"k119bbcab": "正在限速中",
"k11cef91e": "页面出现了一些问题",
"k123852c": "群组隐私控制,防止通过群组恶意骚扰用户。",
"k1252f904": "服务网关",
@ -46,6 +47,7 @@
"k250e392c": "系统忙, 请稍后再试",
"k263bff41": "编辑邀请链接",
"k267cc491": "我",
"k26badc7d": "慢速模式 · 还需等待 {{countdown}}",
"k29498d11": "可使用 <2>{{num}}</2> 次",
"k2a1422d2": "配置",
"k2a8031e": "个人主页",
@ -100,6 +102,8 @@
"k41aea19d": "只支持http路径",
"k4231ab36": "性能统计",
"k42a98418": "文件服务",
"k448ba80f": "慢速模式",
"k4495b5ab": "条消息",
"k4539164b": "校验码为6位",
"k45e2f71f": "临时用户无法认证邮箱, 请先认领账号",
"k4603baea": "创建群组面板",
@ -119,6 +123,7 @@
"k50504f9e": "上传图片到会话",
"k50d471b2": "重置",
"k511aea70": "权限",
"k511f685b": "开启慢速模式",
"k51243586": "添加好友",
"k517db7e5": "文字频道",
"k51db56bf": "临时会议",
@ -135,6 +140,7 @@
"k5bb71ad7": "已安装",
"k5be1a5b0": "删除身份组",
"k5bec387": "无法获取到群组信息",
"k5c07ac": "限制成员的发送频率",
"k5ce4e16d": "清除",
"k5d1e4cd8": "不支持自定义样式",
"k5d2a6631": "允许管理频道",
@ -162,6 +168,7 @@
"k685a1e78": "已复制到剪切板",
"k6910ad02": "发送校验码",
"k6b5e7ffe": "当前面板已在独立窗口打开",
"k6b64b97": "每",
"k6b82e672": "插件安装成功, 刷新页面后生效",
"k6c29eece": "允许成员在文字频道发送消息",
"k6c75d61f": "修改群组头像成功",
@ -190,6 +197,7 @@
"k77d3028d": "未知的面板",
"k77ee6a43": "成员管理",
"k78102887": "成员数",
"k78875066": "每位成员独立计数,系统消息和机器人不受限制",
"k78e52ed0": "接受",
"k79304fa9": "修改昵称成功",
"k795c9a6e": "5次使用",
@ -287,6 +295,7 @@
"kadeb7a89": "不支持渲染的文档链接",
"kae072a10": "没有找到该用户",
"kae0a0c12": "搜索用户",
"kaea1020b": "达到上限后,将从最早一条消息的发送时间开始倒计时",
"kaeec12de": "手动安装",
"kaefc1e64": "密码重置成功,现在回到登录页",
"kaf403ef0": "亮色模式",
@ -315,6 +324,7 @@
"kb8ec7062": "邮箱验证通过",
"kb93f5858": "允许成员修改群组基本信息",
"kb96b79c5": "允许管理邀请链接",
"kbb8a81a9": "慢速模式 · 每 {{interval}} 最多 {{maxMessages}} 条 · 还可发送 {{remaining}} 条",
"kbc76781d": "没有发送消息的权限, 请联系群组所有者",
"kbcacf812": "确认清空收件箱么?",
"kbcb00ae5": "该插件没有更多描述",
@ -383,6 +393,7 @@
"kdce55773": "插件卸载成功, 刷新页面后生效",
"kdd4c838c": "跳转到群组",
"kdd6c18f8": "服务异常",
"kde6b193f": "内最多发送",
"kdef84ee6": "插件",
"kdf6e53ca": "会话没有权限",
"kdf89681f": "添加好友昵称",
@ -396,6 +407,7 @@
"ke17b2c87": "请勿上传违反当地法律法规的图片",
"ke187440d": "面板类型不能为空",
"ke2431c67": "插件渲染函数不存在",
"ke2c48bc": "{{minutes}} 分钟",
"ke3a77a77": "无限制",
"ke3d797fd": "拖放文件以发送到当前会话",
"ke59ffe49": "禁言中, 还剩 {{remain}}",

@ -132,6 +132,8 @@ export {
export type { FriendRequest } from './model/friend';
export {
GroupPanelType,
GROUP_PANEL_SLOW_MODE_INTERVALS,
GROUP_PANEL_SLOW_MODE_MAX_MESSAGES,
createGroup,
createGroupInviteCode,
getAllGroupInviteCode,
@ -143,6 +145,8 @@ export {
createGroupPanel,
modifyGroupPanel,
deleteGroupPanel,
getGroupPanelSlowMode,
isGroupPanelSlowMode,
createGroupRole,
deleteGroupRole,
updateGroupRoleName,
@ -156,6 +160,7 @@ export type {
GroupInvite,
GroupMember,
GroupPanelFeature,
GroupPanelSlowMode,
} from './model/group';
export type {
BasicInboxItem,
@ -165,6 +170,7 @@ export type {
} from './model/inbox';
export {
sendMessage,
getSlowModeStatus,
recallMessage,
deleteMessage,
addReaction,
@ -174,6 +180,7 @@ export type {
ChatMessageReaction,
ChatMessage,
SendMessagePayloadMeta,
SlowModeStatus,
} from './model/message';
export type { PluginManifest } from './model/plugin';
export type { UserBaseInfo, UserLoginInfo, UserSettings } from './model/user';

@ -1,15 +1,34 @@
import { request } from '../api/request';
import {
GroupPanelType,
getGroupPanelSlowMode,
isGroupPanelSlowMode,
GROUP_PANEL_SLOW_MODE_INTERVALS,
GROUP_PANEL_SLOW_MODE_MAX_MESSAGES,
} from 'tailchat-types';
import type {
GroupPanel,
GroupRole,
GroupInfo as IGroupInfo,
GroupBasicInfo,
GroupInvite,
GroupPanelSlowMode,
} from 'tailchat-types';
export { GroupPanelType };
export type { GroupPanel, GroupRole, GroupBasicInfo, GroupInvite };
export {
GroupPanelType,
getGroupPanelSlowMode,
isGroupPanelSlowMode,
GROUP_PANEL_SLOW_MODE_INTERVALS,
GROUP_PANEL_SLOW_MODE_MAX_MESSAGES,
};
export type {
GroupPanel,
GroupRole,
GroupBasicInfo,
GroupInvite,
GroupPanelSlowMode,
};
export const groupConfigNames = [
// 隐藏群组成员标识位

@ -40,6 +40,17 @@ export interface SendMessagePayload extends SimpleMessagePayload {
meta?: SendMessagePayloadMeta;
}
export type SlowModeStatus =
| { enabled: false }
| {
enabled: true;
bypassed: boolean;
intervalSeconds: number;
maxMessages: number;
remaining: number;
resetAt?: string;
};
/**
*
* @param converseId ID
@ -71,6 +82,18 @@ export async function sendMessage(
return data;
}
export async function getSlowModeStatus(
groupId: string,
converseId: string
): Promise<SlowModeStatus> {
const { data } = await request.post('/api/chat/message/getSlowModeStatus', {
groupId,
converseId,
});
return data;
}
/**
*
* @param messageId ID

@ -25,6 +25,7 @@ import { MessageHelper } from '../../utils/message-helper';
import { ChatConverseType } from '../../model/converse';
import { sharedEvent } from '../../event';
import { useUpdateRef } from '../../hooks/useUpdateRef';
import { RequestError } from '../../api/request';
const genLocalMessageId = () => _uniqueId('localMessage_');
@ -64,7 +65,7 @@ function useHandleSendMessage() {
})
);
sendMessage(payload)
return sendMessage(payload)
.then((message) => {
dispatch(
chatActions.deleteMessageById({
@ -82,15 +83,37 @@ function useHandleSendMessage() {
sharedEvent.emit('sendMessage', payload);
})
.catch((err) => {
showErrorToasts(err);
dispatch(
chatActions.updateMessageInfo({
messageId: localMessageId,
message: {
sendFailed: true,
},
})
);
const slowModeLimited =
err instanceof RequestError && err.type === 'SLOW_MODE_LIMITED';
if (slowModeLimited) {
const data = err.data as
| { retryAfterMs?: number; resetAt?: string }
| undefined;
sharedEvent.emit('slowModeLimited', {
converseId: payload.converseId,
retryAfterMs: data?.retryAfterMs ?? 0,
resetAt:
data?.resetAt ??
new Date(Date.now() + (data?.retryAfterMs ?? 0)).toISOString(),
});
dispatch(
chatActions.deleteMessageById({
converseId: payload.converseId,
messageId: localMessageId,
})
);
} else {
showErrorToasts(err);
dispatch(
chatActions.updateMessageInfo({
messageId: localMessageId,
message: {
sendFailed: true,
},
})
);
}
throw err;
});
});

@ -2,6 +2,7 @@ import {
getMessageTextDecorators,
pluginChatInputButtons,
} from '@/plugin/common';
import { Tooltip } from 'antd';
import { isEnterHotkey } from '@/utils/hot-key';
import React, { useRef, useState } from 'react';
import { ChatInputAddon } from './Addon';
@ -12,19 +13,43 @@ import { ChatInputBoxInput } from './input';
import {
getCachedUserInfo,
isValidStr,
SendMessagePayloadMeta,
t,
useEvent,
useSharedEventHandler,
} from 'tailchat-shared';
import type {
GroupPanelSlowMode,
SendMessagePayloadMeta,
} from 'tailchat-shared';
import { ChatInputEmotion } from './Emotion';
import _uniq from 'lodash/uniq';
import { ChatDropArea } from './ChatDropArea';
import { Icon } from 'tailchat-design';
import { usePasteHandler } from './usePasteHandler';
import { useSlowModeStatus } from './useSlowModeStatus';
interface ChatInputBoxProps {
converseId: string;
groupId?: string;
slowMode?: GroupPanelSlowMode;
onSendMsg: (msg: string, meta?: SendMessagePayloadMeta) => Promise<void>;
}
function formatCountdown(durationMs: number): string {
const totalSeconds = Math.max(Math.ceil(durationMs / 1000), 0);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const parts = [minutes, seconds].map((part) => String(part).padStart(2, '0'));
return hours > 0 ? [hours, ...parts].join(':') : parts.join(':');
}
function formatInterval(intervalSeconds: number): string {
return t('{{minutes}} 分钟', {
minutes: intervalSeconds / 60,
});
}
/**
*
*/
@ -34,17 +59,39 @@ export const ChatInputBox: React.FC<ChatInputBoxProps> = React.memo((props) => {
const [mentions, setMentions] = useState<string[]>([]);
const { disabled } = useChatInputMentionsContext();
const { runPasteHandlers, pasteHandlerContainer } = usePasteHandler();
const {
status: slowModeStatus,
blocked: slowModeBlocked,
remainingMs,
} = useSlowModeStatus({
converseId: props.converseId,
groupId: props.groupId,
slowMode: props.slowMode,
});
const sendBlocked = Boolean(disabled) || slowModeBlocked;
const slowModeCountdownText = t('慢速模式 · 还需等待 {{countdown}}', {
countdown: formatCountdown(remainingMs),
});
const sendMessage = useEvent(
async (msg: string, meta?: SendMessagePayloadMeta) => {
await props.onSendMsg(msg, meta);
setMessage('');
inputRef.current?.focus();
if (sendBlocked) {
return;
}
try {
await props.onSendMsg(msg, meta);
setMessage('');
} catch {
// 保留草稿,让用户可以在限制解除或网络恢复后直接重试
} finally {
inputRef.current?.focus();
}
}
);
const handleSendMsg = useEvent(() => {
sendMessage(message, {
void sendMessage(message, {
mentions: _uniq(mentions), // 发送前去重
});
});
@ -85,10 +132,14 @@ export const ChatInputBox: React.FC<ChatInputBoxProps> = React.memo((props) => {
// If not match any paste handler or not paste without any input, fallback to image paste checker
const image = helper.hasImage();
if (image) {
if (slowModeBlocked) {
e.preventDefault();
return;
}
// 上传图片
e.preventDefault();
uploadMessageImage(image).then(({ url, width, height }) => {
props.onSendMsg(
void sendMessage(
getMessageTextDecorators().image(url, { width, height })
);
});
@ -116,11 +167,36 @@ export const ChatInputBox: React.FC<ChatInputBoxProps> = React.memo((props) => {
value={{
message,
setMessage,
sendMsg: props.onSendMsg,
sendMsg: sendMessage,
appendMsg,
}}
>
<div className="px-4 py-2">
{slowModeStatus.enabled && !slowModeStatus.bypassed && (
<Tooltip title={slowModeBlocked ? slowModeCountdownText : undefined}>
<span
className={`mb-1.5 inline-flex items-center gap-1.5 text-xs ${
slowModeBlocked
? 'cursor-help text-amber-600 dark:text-amber-300'
: 'text-gray-500 dark:text-gray-300'
}`}
aria-label={slowModeBlocked ? slowModeCountdownText : undefined}
tabIndex={slowModeBlocked ? 0 : undefined}
>
<Icon icon="mdi:timer-sand" className="flex-shrink-0 text-sm" />
{slowModeBlocked
? t('正在限速中')
: t(
'慢速模式 · 每 {{interval}} 最多 {{maxMessages}} 条 · 还可发送 {{remaining}} 条',
{
interval: formatInterval(slowModeStatus.intervalSeconds),
maxMessages: slowModeStatus.maxMessages,
remaining: slowModeStatus.remaining,
}
)}
</span>
</Tooltip>
)}
<div className="bg-white dark:bg-gray-600 flex rounded-md items-center relative">
{/* This w-0 is magic to ensure show mention and long text */}
<div className="flex-1 w-0">
@ -140,19 +216,29 @@ export const ChatInputBox: React.FC<ChatInputBoxProps> = React.memo((props) => {
{!disabled && (
<div className="px-2 flex space-x-1">
{pluginChatInputButtons.map((item, i) =>
React.cloneElement(item.render(), {
key: `plugin-chatinput-btn#${i}`,
})
)}
{!slowModeBlocked &&
pluginChatInputButtons.map((item, i) =>
React.cloneElement(item.render(), {
key: `plugin-chatinput-btn#${i}`,
})
)}
<ChatInputEmotion />
{message ? (
<Icon
icon="mdi:send-circle-outline"
className="text-2xl cursor-pointer"
onClick={handleSendMsg}
className={`text-2xl ${
slowModeBlocked
? 'cursor-not-allowed text-gray-400'
: 'cursor-pointer'
}`}
onClick={slowModeBlocked ? undefined : handleSendMsg}
/>
) : slowModeBlocked ? (
<Icon
icon="mdi:timer-sand"
className="text-2xl text-gray-400"
/>
) : (
<ChatInputAddon />
@ -162,7 +248,7 @@ export const ChatInputBox: React.FC<ChatInputBoxProps> = React.memo((props) => {
</div>
</div>
{!disabled && <ChatDropArea />}
{!sendBlocked && <ChatDropArea />}
</ChatInputActionContext.Provider>
);
});

@ -0,0 +1,117 @@
import { useCallback, useEffect, useState } from 'react';
import {
getSlowModeStatus,
useInterval,
useSharedEventHandler,
} from 'tailchat-shared';
import type { GroupPanelSlowMode, SlowModeStatus } from 'tailchat-shared';
interface UseSlowModeStatusParams {
groupId?: string;
converseId: string;
slowMode?: GroupPanelSlowMode;
}
function createFallbackStatus(slowMode?: GroupPanelSlowMode): SlowModeStatus {
if (!slowMode) {
return { enabled: false };
}
return {
enabled: true,
bypassed: false,
...slowMode,
remaining: slowMode.maxMessages,
};
}
export function useSlowModeStatus({
groupId,
converseId,
slowMode,
}: UseSlowModeStatusParams) {
const [status, setStatus] = useState<SlowModeStatus>(() =>
createFallbackStatus(slowMode)
);
const [now, setNow] = useState(Date.now());
const refresh = useCallback(async () => {
if (!groupId || !slowMode) {
setStatus({ enabled: false });
return;
}
try {
setStatus(await getSlowModeStatus(groupId, converseId));
setNow(Date.now());
} catch {
setStatus((current) =>
current.enabled ? current : createFallbackStatus(slowMode)
);
}
}, [converseId, groupId, slowMode?.intervalSeconds, slowMode?.maxMessages]);
useEffect(() => {
setStatus(createFallbackStatus(slowMode));
void refresh();
}, [refresh]);
useSharedEventHandler('sendMessage', (payload) => {
if (payload.converseId === converseId) {
void refresh();
}
});
useSharedEventHandler('slowModeLimited', (payload) => {
if (payload.converseId !== converseId || !slowMode) {
return;
}
setStatus({
enabled: true,
bypassed: false,
...slowMode,
remaining: 0,
resetAt: payload.resetAt,
});
setNow(Date.now());
});
const resetAt =
status.enabled && status.resetAt
? new Date(status.resetAt).valueOf()
: undefined;
const remainingMs = Math.max((resetAt ?? now) - now, 0);
const blocked =
status.enabled &&
!status.bypassed &&
status.remaining === 0 &&
remainingMs > 0;
const hasPendingReset =
status.enabled && !status.bypassed && resetAt !== undefined;
useInterval(() => setNow(Date.now()), hasPendingReset ? 1000 : undefined);
useEffect(() => {
if (status.enabled && resetAt !== undefined && now >= resetAt) {
if (status.remaining === 0) {
setStatus((current) =>
current.enabled
? {
...current,
remaining: 1,
resetAt: undefined,
}
: current
);
}
void refresh();
}
}, [now, refresh, resetAt, status]);
return {
status,
blocked,
remainingMs,
};
}

@ -5,6 +5,7 @@ import {
ConverseMessageProvider,
useConverseMessageContext,
} from 'tailchat-shared';
import type { GroupPanelSlowMode } from 'tailchat-shared';
import { ErrorView } from '../ErrorView';
import { ChatBoxPlaceholder } from './ChatBoxPlaceholder';
import { ChatInputBox } from './ChatInputBox';
@ -24,6 +25,7 @@ type ChatBoxProps =
converseTitle?: React.ReactNode;
isGroup: true;
groupId: string;
slowMode?: GroupPanelSlowMode;
};
const ChatBoxInner: React.FC<ChatBoxProps> = React.memo((props) => {
const { converseId, converseTitle } = props;
@ -59,6 +61,9 @@ const ChatBoxInner: React.FC<ChatBoxProps> = React.memo((props) => {
<ChatReply />
<ChatInputBox
converseId={props.converseId}
groupId={props.groupId}
slowMode={props.isGroup ? props.slowMode : undefined}
onSendMsg={async (msg, meta) => {
const content = preprocessMessage(msg);
await sendMessage({

@ -18,6 +18,7 @@ import {
useGroupInfo,
GroupPanelType,
useHasGroupPanelPermission,
getGroupPanelSlowMode,
} from 'tailchat-shared';
import { useFriendNicknameMap } from 'tailchat-shared';
import { MembersPanel } from './MembersPanel';
@ -170,6 +171,7 @@ export const TextPanel: React.FC<TextPanelProps> = React.memo(
converseTitle={panelInfo.name}
isGroup={true}
groupId={groupId}
slowMode={getGroupPanelSlowMode(panelInfo.meta)}
/>
</ChatInputMentionsContextProvider>
</GroupPanelContainer>

@ -32,7 +32,7 @@ export const ModalCreateGroupPanel: React.FC<{
const { fields, schema } = useGroupPanelFields(props.groupId, currentValues);
return (
<ModalWrapper title={t('创建群组面板')} style={{ maxWidth: 440 }}>
<ModalWrapper title={t('创建群组面板')} style={{ maxWidth: 560 }}>
<WebMetaForm
schema={schema}
fields={fields}

@ -0,0 +1,99 @@
import React from 'react';
import { Select, Switch } from 'antd';
import {
GROUP_PANEL_SLOW_MODE_INTERVALS,
GROUP_PANEL_SLOW_MODE_MAX_MESSAGES,
isGroupPanelSlowMode,
t,
} from 'tailchat-shared';
import type { GroupPanelSlowMode } from 'tailchat-shared';
import { Icon } from 'tailchat-design';
import type { FastifyFormFieldProps } from 'tailchat-design';
const DEFAULT_SLOW_MODE: GroupPanelSlowMode = {
intervalSeconds: 60,
maxMessages: 1,
};
function formatInterval(intervalSeconds: number): string {
return t('{{minutes}} 分钟', {
minutes: intervalSeconds / 60,
});
}
export const SlowModeSettings: React.FC<FastifyFormFieldProps> = React.memo(
(props) => {
const value = isGroupPanelSlowMode(props.value)
? props.value
: DEFAULT_SLOW_MODE;
const enabled = isGroupPanelSlowMode(props.value);
const updateValue = (patch: Partial<GroupPanelSlowMode>) => {
props.onChange({
...value,
...patch,
});
};
return (
<div className="rounded-lg border border-gray-200 px-4 py-3 dark:border-gray-600">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 gap-3">
<Icon
icon="mdi:timer-sand"
className="mt-0.5 flex-shrink-0 text-xl text-gray-500 dark:text-gray-300"
/>
<div>
<div className="font-medium text-gray-900 dark:text-gray-100">
{t('限制成员的发送频率')}
</div>
<div className="mt-0.5 text-xs leading-5 text-gray-500 dark:text-gray-300">
{t('每位成员独立计数,系统消息和机器人不受限制')}
</div>
</div>
</div>
<Switch
checked={enabled}
aria-label={t('开启慢速模式')}
onChange={(checked) =>
props.onChange(checked ? DEFAULT_SLOW_MODE : undefined)
}
/>
</div>
{enabled && (
<div className="mt-4 border-t border-gray-100 pt-4 dark:border-gray-600">
<div className="flex flex-wrap items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
<span>{t('每')}</span>
<Select<number>
value={value.intervalSeconds}
style={{ width: 116 }}
options={GROUP_PANEL_SLOW_MODE_INTERVALS.map((seconds) => ({
label: formatInterval(seconds),
value: seconds,
}))}
onChange={(intervalSeconds) => updateValue({ intervalSeconds })}
/>
<span>{t('内最多发送')}</span>
<Select<number>
value={value.maxMessages}
style={{ width: 88 }}
options={GROUP_PANEL_SLOW_MODE_MAX_MESSAGES.map((count) => ({
label: count,
value: count,
}))}
onChange={(maxMessages) => updateValue({ maxMessages })}
/>
<span>{t('条消息')}</span>
</div>
<div className="mt-2 text-xs leading-5 text-gray-500 dark:text-gray-300">
{t('达到上限后,将从最早一条消息的发送时间开始倒计时')}
</div>
</div>
)}
</div>
);
}
);
SlowModeSettings.displayName = 'SlowModeSettings';

@ -32,6 +32,34 @@ describe('buildDataFromValues', () => {
meta: {},
},
],
[
{
name: 'name',
type: 0,
slowMode: { intervalSeconds: 60, maxMessages: 5 },
},
{
name: 'name',
type: 0,
pluginPanelName: undefined,
provider: undefined,
meta: { slowMode: { intervalSeconds: 60, maxMessages: 5 } },
},
],
[
{
name: 'name',
type: 1,
slowMode: { intervalSeconds: 60, maxMessages: 5 },
},
{
name: 'name',
type: 1,
pluginPanelName: undefined,
provider: undefined,
meta: {},
},
],
[
{ name: 'name', type: 'fooPluginPanel' },
{

@ -1,6 +1,7 @@
import { findPluginPanelInfoByName } from '@/utils/plugin-helper';
import { GroupPanel, GroupPanelType } from 'tailchat-shared';
import type { GroupPanelValues } from './types';
import _omit from 'lodash/omit';
/**
*
@ -29,7 +30,7 @@ export function buildDataFromValues(values: GroupPanelValues) {
type: panelType,
provider,
pluginPanelName,
meta,
meta: panelType === GroupPanelType.TEXT ? meta : _omit(meta, ['slowMode']),
permissionMap,
fallbackPermissions,
};

@ -1,7 +1,8 @@
import type { GroupPanelType } from 'tailchat-shared';
import type { GroupPanelSlowMode, GroupPanelType } from 'tailchat-shared';
export interface GroupPanelValues {
name: string;
type: string | GroupPanelType.TEXT | GroupPanelType.GROUP;
slowMode?: GroupPanelSlowMode;
[key: string]: unknown;
}

@ -12,11 +12,13 @@ import {
MetaFormFieldMeta,
metaFormFieldSchema,
} from 'tailchat-design';
import type { FastifyFormFieldProps } from 'tailchat-design';
import type { GroupPanelValues } from './types';
import _groupBy from 'lodash/groupBy';
import _mapValues from 'lodash/mapValues';
import { pluginGroupPanel } from '@/plugin/common';
import { findPluginPanelInfoByName } from '@/utils/plugin-helper';
import { SlowModeSettings } from './SlowModeSettings';
const baseFields: MetaFormFieldMeta[] = [
{ type: 'text', name: 'name', label: t('面板名') },
@ -55,9 +57,25 @@ export function useGroupPanelFields(
) {
const ret = useMemo(() => {
let fields = baseFields;
let schema = baseSchema;
let schema: Record<string, any> = baseSchema;
if (typeof currentValues.type === 'string') {
if (currentValues.type === GroupPanelType.TEXT) {
fields = [
...baseFields,
{
type: 'custom',
name: 'slowMode',
label: t('慢速模式'),
render: (props: FastifyFormFieldProps) => (
<SlowModeSettings {...props} />
),
},
];
schema = {
...baseSchema,
slowMode: metaFormFieldSchema.mixed(),
};
} else if (typeof currentValues.type === 'string') {
// 如果当前选择的面板类型为插件类型
// 需要从插件信息中获取额外的字段
const panelInfo = findPluginPanelInfoByName(currentValues.type);

@ -45,6 +45,52 @@ export enum GroupPanelType {
PLUGIN = 2,
}
export const GROUP_PANEL_SLOW_MODE_INTERVALS = [
60, 300, 900, 1800, 3600,
] as const;
export const GROUP_PANEL_SLOW_MODE_MAX_MESSAGES = [1, 3, 5, 10] as const;
export interface GroupPanelSlowMode {
/** 统计窗口,单位为秒 */
intervalSeconds: number;
/** 统计窗口内允许发送的消息数 */
maxMessages: number;
}
export interface GroupPanelMeta {
slowMode?: GroupPanelSlowMode;
[key: string]: any;
}
export function isGroupPanelSlowMode(
value: unknown
): value is GroupPanelSlowMode {
if (typeof value !== 'object' || value === null) {
return false;
}
const slowMode = value as GroupPanelSlowMode;
return (
(GROUP_PANEL_SLOW_MODE_INTERVALS as readonly number[]).indexOf(
slowMode.intervalSeconds
) !== -1 &&
(GROUP_PANEL_SLOW_MODE_MAX_MESSAGES as readonly number[]).indexOf(
slowMode.maxMessages
) !== -1
);
}
export function getGroupPanelSlowMode(
meta: unknown
): GroupPanelSlowMode | undefined {
if (typeof meta !== 'object' || meta === null) {
return undefined;
}
const slowMode = (meta as GroupPanelMeta).slowMode;
return isGroupPanelSlowMode(slowMode) ? slowMode : undefined;
}
export interface GroupPanel {
id: string; // 在群组中唯一, 可以用任意方式进行生成。这里使用ObjectId, 但不是ObjectId类型
name: string; // 用于显示的名称
@ -64,7 +110,7 @@ export interface GroupPanel {
/**
*
*/
meta?: Record<string, any>;
meta?: GroupPanelMeta;
/**
*

@ -39,11 +39,13 @@
"k986040de": "No group found",
"k9891f980": "{{nickname}} joined the group through the public community",
"k996e99cb": "This group is not a public group and cannot be joined directly",
"k9d5171f": "Slow mode: retry in {{seconds}} seconds",
"k9e276b5": "The administrator forbids the function of adding friends",
"ka3eb52f8": "Call ended, duration: {{num}} minutes",
"ka5b432c0": "Too frequent requests can share the same OTP within 10 minutes",
"ka6ee6909": "No session information found",
"ka8b712f7": "Email already exists!",
"ka93b99e": "Invalid slow mode settings",
"kb143afe": "This data is not allowed to be modified",
"kb32d3d62": "Anonymous",
"kb5971793": "Username or email is empty",

@ -39,11 +39,13 @@
"k986040de": "没有找到群组",
"k9891f980": "{{nickname}} 通过公共社区加入群组",
"k996e99cb": "该群组并非公开群组, 无法直接加入",
"k9d5171f": "慢速模式限制:请在 {{seconds}} 秒后重试",
"k9e276b5": "管理员禁止添加好友功能",
"ka3eb52f8": "通话已结束, 时长: {{num}}分钟",
"ka5b432c0": "过于频繁的请求10 分钟内可以共用同一OTP",
"ka6ee6909": "没有找到会话信息",
"ka8b712f7": "邮箱已存在!",
"ka93b99e": "慢速模式设置无效",
"kb143afe": "该数据不允许修改",
"kb32d3d62": "匿名用户",
"kb5971793": "用户名或邮箱为空",

@ -812,6 +812,27 @@
}
}
},
"/chat.message/getSlowModeStatus": {
"post": {
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"converseId": {
"type": "string"
},
"groupId": {
"type": "string"
}
}
}
}
}
}
}
},
"/chat.message/recallMessage": {
"post": {
"requestBody": {

@ -39,8 +39,16 @@ export type {
GroupStruct,
GroupRoleStruct,
GroupPanelStruct,
GroupPanelMeta,
GroupPanelSlowMode,
} from './structs/group';
export {
GroupPanelType,
GROUP_PANEL_SLOW_MODE_INTERVALS,
GROUP_PANEL_SLOW_MODE_MAX_MESSAGES,
getGroupPanelSlowMode,
isGroupPanelSlowMode,
} from './structs/group';
export { GroupPanelType } from './structs/group';
export { userType } from './structs/user';
export type { UserStruct, UserType, UserStructWithToken } from './structs/user';

@ -201,8 +201,12 @@ export abstract class TcService extends Service {
*
*/
getActionList() {
return Object.entries(this._actions).map(
([name, schema]: [string, ServiceActionSchema]) => {
return Object.entries(this._actions)
.filter(
([, schema]: [string, ServiceActionSchema]) =>
schema.visibility == null || schema.visibility === 'published'
)
.map(([name, schema]: [string, ServiceActionSchema]) => {
return {
name,
params: _.mapValues(schema.params, (type) => {
@ -213,8 +217,7 @@ export abstract class TcService extends Service {
}
}),
};
}
);
});
}
registerMixin(mixin: Partial<ServiceSchema>): void {

@ -55,6 +55,12 @@ export class ServiceUnavailableError extends TcError {
}
}
export class RateLimitError extends TcError {
constructor(message?: string, type?: string, data?: unknown) {
super(message ?? 'Too many requests', 429, type ?? 'RATE_LIMITED', data);
}
}
export class NotFoundError extends TcError {
constructor(data?: unknown) {
super('Not found', 404, 'NOT_FOUND', data);

@ -4,8 +4,51 @@ export enum GroupPanelType {
PLUGIN = 2,
}
// TODO
export type GroupPanelMeta = {};
export const GROUP_PANEL_SLOW_MODE_INTERVALS = [
60, 300, 900, 1800, 3600,
] as const;
export const GROUP_PANEL_SLOW_MODE_MAX_MESSAGES = [1, 3, 5, 10] as const;
export interface GroupPanelSlowMode {
/** 统计窗口,单位为秒 */
intervalSeconds: number;
/** 统计窗口内允许发送的消息数 */
maxMessages: number;
}
export interface GroupPanelMeta {
slowMode?: GroupPanelSlowMode;
[key: string]: any;
}
export function isGroupPanelSlowMode(
value: unknown
): value is GroupPanelSlowMode {
if (typeof value !== 'object' || value === null) {
return false;
}
const slowMode = value as GroupPanelSlowMode;
return (
(GROUP_PANEL_SLOW_MODE_INTERVALS as readonly number[]).indexOf(
slowMode.intervalSeconds
) !== -1 &&
(GROUP_PANEL_SLOW_MODE_MAX_MESSAGES as readonly number[]).indexOf(
slowMode.maxMessages
) !== -1
);
}
export function getGroupPanelSlowMode(
meta: unknown
): GroupPanelSlowMode | undefined {
if (typeof meta !== 'object' || meta === null) {
return undefined;
}
const slowMode = (meta as GroupPanelMeta).slowMode;
return isGroupPanelSlowMode(slowMode) ? slowMode : undefined;
}
interface GroupMemberStruct {
roles?: string[]; // 角色

@ -15,19 +15,44 @@ import {
PERMISSION,
NotFoundError,
SYSTEM_USERID,
RateLimitError,
getGroupPanelSlowMode,
} from 'tailchat-server-sdk';
import type { Group } from '../../../models/group/group';
import { isValidStr } from '../../../lib/utils';
import _ from 'lodash';
import RedisSlowModeCounter, { SlowModeRedisClient } from './slowModeCounter';
interface MessageService
extends TcService,
TcDbService<MessageDocument, MessageModel> {}
class MessageService extends TcService {
private slowModeCounter?: RedisSlowModeCounter;
get serviceName(): string {
return 'chat.message';
}
private getSlowModeCounter(): RedisSlowModeCounter {
if (this.slowModeCounter) {
return this.slowModeCounter;
}
const cacher = this.broker.cacher as unknown as
| { client?: SlowModeRedisClient; prefix?: string }
| undefined;
const redis = cacher?.client;
if (!redis || typeof redis.eval !== 'function') {
throw new Error('Slow mode requires the Redis cacher');
}
const keyPrefix = cacher?.prefix
? `${cacher.prefix}slow-mode:v1`
: undefined;
this.slowModeCounter = new RedisSlowModeCounter(redis, keyPrefix);
return this.slowModeCounter;
}
onInit(): void {
this.registerLocalDb(require('../../../models/chat/message').default);
@ -54,6 +79,20 @@ class MessageService extends TcService {
meta: { type: 'any', optional: true },
},
});
this.registerAction('getSlowModeStatus', this.getSlowModeStatus, {
params: {
converseId: 'string',
groupId: 'string',
},
});
this.registerAction('resetSlowModeCounters', this.resetSlowModeCounters, {
visibility: 'protected',
disableSocket: true,
params: {
groupId: 'string',
converseIds: { type: 'array', items: 'string' },
},
});
this.registerAction('recallMessage', this.recallMessage, {
params: {
messageId: 'string',
@ -198,7 +237,18 @@ class MessageService extends TcService {
/**
*
*/
await this.checkConversePermission(ctx, converseId, groupId); // 鉴权是否能获取到会话内容
const { bypassSlowMode } = await this.checkConversePermission(
ctx,
converseId,
groupId
); // 鉴权是否能获取到会话内容
let slowModeReservation:
| {
entryId: string;
intervalSeconds: number;
maxMessages: number;
}
| undefined;
if (isGroupMessage) {
// 是群组消息, 鉴权是否禁言
const groupInfo = await call(ctx).getGroupInfo(groupId);
@ -209,16 +259,72 @@ class MessageService extends TcService {
throw new Error(t('您因为被禁言无法发送消息'));
}
}
const panelInfo = groupInfo.panels.find(
(panel) => String(panel.id) === converseId
);
if (!panelInfo) {
throw new DataNotFoundError(t('没有找到会话信息'));
}
const slowMode = getGroupPanelSlowMode(panelInfo.meta);
if (slowMode && !bypassSlowMode) {
const result = await this.getSlowModeCounter().consume({
converseId,
userId,
...slowMode,
});
if (!result.accepted) {
const resetAt = result.resetAt ?? new Date();
const retryAfterMs =
result.retryAfterMs ?? Math.max(resetAt.valueOf() - Date.now(), 0);
throw new RateLimitError(
t('慢速模式限制:请在 {{seconds}} 秒后重试', {
seconds: Math.max(Math.ceil(retryAfterMs / 1000), 1),
}),
'SLOW_MODE_LIMITED',
{
retryAfterMs,
resetAt: resetAt.toISOString(),
}
);
}
if (result.entryId) {
slowModeReservation = {
entryId: result.entryId,
...slowMode,
};
}
}
}
const message = await this.adapter.insert({
converseId: new Types.ObjectId(converseId),
groupId:
typeof groupId === 'string' ? new Types.ObjectId(groupId) : undefined,
author: new Types.ObjectId(userId),
content,
meta,
});
let message: MessageDocument;
try {
message = await this.adapter.insert({
converseId: new Types.ObjectId(converseId),
groupId:
typeof groupId === 'string' ? new Types.ObjectId(groupId) : undefined,
author: new Types.ObjectId(userId),
content,
meta,
});
} catch (err) {
if (slowModeReservation) {
await this.getSlowModeCounter()
.release({
converseId,
userId,
...slowModeReservation,
})
.catch((releaseError) => {
this.logger.warn(
'Failed to release slow mode counter',
releaseError
);
});
}
throw err;
}
const json = await this.transformDocuments(ctx, {}, message);
@ -271,6 +377,81 @@ class MessageService extends TcService {
return json;
}
async getSlowModeStatus(
ctx: TcContext<{ converseId: string; groupId: string }>
) {
const { converseId, groupId } = ctx.params;
const { t, userId } = ctx.meta;
const { bypassSlowMode } = await this.checkConversePermission(
ctx,
converseId,
groupId
);
const groupInfo = await call(ctx).getGroupInfo(groupId);
const panelInfo = groupInfo.panels.find(
(panel) => String(panel.id) === converseId
);
if (!panelInfo) {
throw new DataNotFoundError(t('没有找到会话信息'));
}
const slowMode = getGroupPanelSlowMode(panelInfo.meta);
if (!slowMode) {
return { enabled: false };
}
if (bypassSlowMode) {
return {
enabled: true,
bypassed: true,
...slowMode,
remaining: slowMode.maxMessages,
};
}
const status = await this.getSlowModeCounter().getStatus({
converseId,
userId,
...slowMode,
});
return {
enabled: true,
bypassed: false,
...slowMode,
remaining: status.remaining,
resetAt: status.resetAt?.toISOString(),
};
}
async resetSlowModeCounters(
ctx: TcContext<{ groupId: string; converseIds: string[] }>
) {
const { groupId, converseIds } = ctx.params;
const { t, userId } = ctx.meta;
const [hasPermission] = await call(ctx).checkUserPermissions(
groupId,
userId,
[PERMISSION.core.managePanel]
);
if (!hasPermission) {
throw new NoPermissionError(t('没有操作权限'));
}
const groupInfo = await call(ctx).getGroupInfo(groupId);
const panelIds = new Set(groupInfo.panels.map((panel) => String(panel.id)));
if (converseIds.some((converseId) => !panelIds.has(converseId))) {
throw new DataNotFoundError(t('没有找到会话信息'));
}
const deletedCount = await this.getSlowModeCounter().deleteByConverseIds(
converseIds
);
return { deletedCount };
}
/**
*
*/
@ -578,17 +759,17 @@ class MessageService extends TcService {
ctx: TcContext,
converseId: string,
groupId?: string
) {
): Promise<{ bypassSlowMode: boolean }> {
const userId = ctx.meta.userId;
const t = ctx.meta.t;
if (userId === SYSTEM_USERID) {
return;
return { bypassSlowMode: true };
}
const userInfo = await call(ctx).getUserInfo(userId); // TODO: 可以通过在默认的meta信息中追加用户类型来减少一次请求来优化
if (userInfo.type === 'pluginBot') {
// 如果是插件机器人则拥有所有权限(开放平台机器人需要添加到群组才有会话权限)
return;
// 插件机器人可以不加入群组直接发送插件消息,但仍受频道慢速模式限制
return { bypassSlowMode: false };
}
// 鉴权是否能获取到会话内容
@ -618,6 +799,8 @@ class MessageService extends TcService {
throw new NoPermissionError(t('没有当前会话权限'));
}
}
return { bypassSlowMode: false };
}
}

@ -0,0 +1,216 @@
import { randomBytes } from 'crypto';
const DEFAULT_KEY_PREFIX = 'tailchat:slow-mode:v1';
const CONSUME_SCRIPT = `
local now = tonumber(ARGV[1])
if not now then
local redisTime = redis.call('TIME')
now = redisTime[1] * 1000 + math.floor(redisTime[2] / 1000)
end
local windowMs = tonumber(ARGV[2])
local maxMessages = tonumber(ARGV[3])
local entryId = ARGV[4]
local cutoff = now - windowMs
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', cutoff)
local count = redis.call('ZCARD', KEYS[1])
if count >= maxMessages then
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
local resetAt = tonumber(oldest[2]) + windowMs
redis.call('PEXPIRE', KEYS[1], windowMs)
return { 0, 0, resetAt, math.max(resetAt - now, 0) }
end
redis.call('ZADD', KEYS[1], now, entryId)
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
local resetAt = tonumber(oldest[2]) + windowMs
redis.call('PEXPIRE', KEYS[1], windowMs)
return { 1, maxMessages - count - 1, resetAt, math.max(resetAt - now, 0) }
`;
const STATUS_SCRIPT = `
local now = tonumber(ARGV[1])
if not now then
local redisTime = redis.call('TIME')
now = redisTime[1] * 1000 + math.floor(redisTime[2] / 1000)
end
local windowMs = tonumber(ARGV[2])
local maxMessages = tonumber(ARGV[3])
local cutoff = now - windowMs
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', cutoff)
local count = redis.call('ZCARD', KEYS[1])
if count == 0 then
redis.call('DEL', KEYS[1])
return { maxMessages, 0 }
end
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
redis.call('PEXPIRE', KEYS[1], windowMs)
return { math.max(maxMessages - count, 0), tonumber(oldest[2]) + windowMs }
`;
const RELEASE_SCRIPT = `
local removed = redis.call('ZREM', KEYS[1], ARGV[1])
if redis.call('ZCARD', KEYS[1]) == 0 then
redis.call('DEL', KEYS[1])
end
return removed
`;
export interface SlowModeConsumeResult {
accepted: boolean;
remaining: number;
resetAt?: Date;
retryAfterMs?: number;
entryId?: string;
}
export interface SlowModeRedisClient {
eval(
script: string,
numberOfKeys: number,
...args: Array<string | number>
): Promise<unknown>;
scan(
cursor: string,
...args: Array<string | number>
): Promise<[string, string[]]>;
del(...keys: string[]): Promise<number>;
}
interface SlowModeParams {
converseId: string;
userId: string;
intervalSeconds: number;
maxMessages: number;
now?: Date;
}
function encodeKeyPart(value: string): string {
return Buffer.from(value).toString('hex');
}
function escapeRedisPattern(value: string): string {
return value.replace(/[\\*?\[\]]/g, '\\$&');
}
function parseScriptResult(result: unknown, expectedLength: number): number[] {
if (!Array.isArray(result) || result.length !== expectedLength) {
throw new Error('Invalid Redis slow mode script result');
}
const values = result.map(Number);
if (values.some((value) => !Number.isFinite(value))) {
throw new Error('Invalid Redis slow mode script value');
}
return values;
}
export class RedisSlowModeCounter {
constructor(
private readonly redis: SlowModeRedisClient,
private readonly keyPrefix = DEFAULT_KEY_PREFIX
) {}
private getKey(params: SlowModeParams): string {
return [
this.keyPrefix,
encodeKeyPart(params.converseId),
encodeKeyPart(params.userId),
params.intervalSeconds,
params.maxMessages,
].join(':');
}
async consume(params: SlowModeParams): Promise<SlowModeConsumeResult> {
const entryId = randomBytes(16).toString('hex');
const result = await this.redis.eval(
CONSUME_SCRIPT,
1,
this.getKey(params),
params.now?.valueOf().toString() ?? '',
params.intervalSeconds * 1000,
params.maxMessages,
entryId
);
const [accepted, remaining, resetAt, retryAfterMs] = parseScriptResult(
result,
4
);
return {
accepted: accepted === 1,
remaining,
resetAt: resetAt > 0 ? new Date(resetAt) : undefined,
retryAfterMs,
entryId: accepted === 1 ? entryId : undefined,
};
}
async getStatus(
params: SlowModeParams
): Promise<Omit<SlowModeConsumeResult, 'accepted' | 'entryId'>> {
const result = await this.redis.eval(
STATUS_SCRIPT,
1,
this.getKey(params),
params.now?.valueOf().toString() ?? '',
params.intervalSeconds * 1000,
params.maxMessages
);
const [remaining, resetAt] = parseScriptResult(result, 2);
return {
remaining,
resetAt: resetAt > 0 ? new Date(resetAt) : undefined,
};
}
async release(params: SlowModeParams & { entryId: string }): Promise<void> {
await this.redis.eval(
RELEASE_SCRIPT,
1,
this.getKey(params),
params.entryId
);
}
async deleteByConverseIds(converseIds: string[]): Promise<number> {
let deletedCount = 0;
for (const converseId of converseIds) {
let cursor = '0';
const pattern = `${escapeRedisPattern(this.keyPrefix)}:${encodeKeyPart(
converseId
)}:*`;
do {
const [nextCursor, keys] = await this.redis.scan(
cursor,
'MATCH',
pattern,
'COUNT',
100
);
cursor = nextCursor;
if (keys.length > 0) {
deletedCount += await this.redis.del(...keys);
}
} while (cursor !== '0');
}
return deletedCount;
}
}
export default RedisSlowModeCounter;

@ -23,6 +23,8 @@ import {
config,
SYSTEM_USERID,
db,
getGroupPanelSlowMode,
isGroupPanelSlowMode,
} from 'tailchat-server-sdk';
import moment from 'moment';
@ -231,6 +233,21 @@ class GroupService extends TcService {
});
}
private validateSlowMode(
type: number,
meta: object | undefined,
t: TcContext['meta']['t']
) {
const slowMode = (meta as { slowMode?: unknown } | undefined)?.slowMode;
if (slowMode === undefined) {
return;
}
if (type !== GroupPanelType.TEXT || !isGroupPanelSlowMode(slowMode)) {
throw new EntityError(t('慢速模式设置无效'));
}
}
/**
* id
*
@ -295,6 +312,8 @@ class GroupService extends TcService {
const userId = ctx.meta.userId;
const t = ctx.meta.t;
panels.forEach((panel) => this.validateSlowMode(panel.type, panel.meta, t));
if (
config.feature.disableCreateGroup === true &&
userId !== SYSTEM_USERID
@ -772,6 +791,8 @@ class GroupService extends TcService {
throw new NoPermissionError(t('没有操作权限'));
}
this.validateSlowMode(type, meta, t);
const panelId = String(new Types.ObjectId());
const group = await this.adapter.model
@ -853,6 +874,28 @@ class GroupService extends TcService {
throw new NoPermissionError(t('没有操作权限'));
}
this.validateSlowMode(type, meta, t);
const previousGroup = await this.adapter.model
.findOne(
{
_id: new Types.ObjectId(groupId),
'panels.id': panelId,
},
{
panels: 1,
}
)
.lean()
.exec();
const previousPanel = previousGroup?.panels.find(
(panel) => String(panel.id) === panelId
);
const slowModeChanged = !_.isEqual(
getGroupPanelSlowMode(previousPanel?.meta),
getGroupPanelSlowMode(meta)
);
const res = await this.adapter.model
.updateOne(
{
@ -880,6 +923,13 @@ class GroupService extends TcService {
throw new Error(t('没有找到该面板'));
}
if (slowModeChanged) {
await ctx.call('chat.message.resetSlowModeCounters', {
groupId,
converseIds: [panelId],
});
}
const group = await this.adapter.model.findById(String(groupId));
const json = await this.notifyGroupInfoUpdate(ctx, group);

@ -3,6 +3,7 @@ import MessageService from '../../../services/core/chat/message.service';
import type { MessageDocument } from '../../../models/chat/message';
import { Types } from 'mongoose';
import _ from 'lodash';
import { SYSTEM_USERID } from 'tailchat-server-sdk';
function createTestMessage(converseId: Types.ObjectId, content = 'bar') {
return {
@ -18,6 +19,47 @@ describe('Test "chat.message" service', () => {
const { broker, service, insertTestData } =
createTestServiceBroker<MessageService>(MessageService);
describe('Test slow mode bypass policy', () => {
test('plugin bots keep converse access but do not bypass slow mode', async () => {
const userId = String(new Types.ObjectId());
const ctx = {
meta: {
userId,
t: (key: string) => key,
},
call: jest.fn().mockResolvedValue({ type: 'pluginBot' }),
};
const result = await (service as any).checkConversePermission(
ctx,
String(new Types.ObjectId()),
String(new Types.ObjectId())
);
expect(result).toEqual({ bypassSlowMode: false });
expect(ctx.call).toHaveBeenCalledWith('user.getUserInfo', { userId });
});
test('system messages still bypass slow mode', async () => {
const ctx = {
meta: {
userId: SYSTEM_USERID,
t: (key: string) => key,
},
call: jest.fn(),
};
const result = await (service as any).checkConversePermission(
ctx,
String(new Types.ObjectId()),
String(new Types.ObjectId())
);
expect(result).toEqual({ bypassSlowMode: true });
expect(ctx.call).not.toHaveBeenCalled();
});
});
describe('Test "chat.message.fetchConverseMessage"', () => {
test('single message', async () => {
const converseId = new Types.ObjectId();

@ -0,0 +1,211 @@
import RedisClient from 'ioredis';
import { Types } from 'mongoose';
import RedisSlowModeCounter from '../../../services/core/chat/slowModeCounter';
describe('RedisSlowModeCounter', () => {
const converseIds: string[] = [];
let redis: RedisClient.Redis;
let counter: RedisSlowModeCounter;
const createIds = () => {
const converseId = String(new Types.ObjectId());
converseIds.push(converseId);
return {
converseId,
userId: String(new Types.ObjectId()),
};
};
beforeAll(async () => {
if (!process.env.REDIS_URL) {
throw new Error('REDIS_URL is required to test RedisSlowModeCounter');
}
redis = new RedisClient(process.env.REDIS_URL);
await redis.ping();
counter = new RedisSlowModeCounter(redis);
});
afterAll(async () => {
await redis.quit();
});
afterEach(async () => {
await counter.deleteByConverseIds(converseIds);
converseIds.length = 0;
});
test('limits messages in a rolling window and opens the oldest slot', async () => {
const ids = createIds();
const start = new Date('2026-08-20T00:00:00.000Z');
for (let index = 0; index < 5; index += 1) {
const result = await counter.consume({
...ids,
intervalSeconds: 60,
maxMessages: 5,
now: new Date(start.valueOf() + index * 1000),
});
expect(result.accepted).toBe(true);
expect(result.remaining).toBe(4 - index);
}
const limited = await counter.consume({
...ids,
intervalSeconds: 60,
maxMessages: 5,
now: new Date(start.valueOf() + 5000),
});
expect(limited).toMatchObject({
accepted: false,
remaining: 0,
resetAt: new Date(start.valueOf() + 60000),
});
const released = await counter.consume({
...ids,
intervalSeconds: 60,
maxMessages: 5,
now: new Date(start.valueOf() + 60001),
});
expect(released.accepted).toBe(true);
});
test('releases a reserved slot when message persistence fails', async () => {
const ids = createIds();
const now = new Date('2026-08-20T00:00:00.000Z');
const reserved = await counter.consume({
...ids,
intervalSeconds: 60,
maxMessages: 1,
now,
});
expect(reserved.accepted).toBe(true);
expect(reserved.entryId).toBeDefined();
await counter.release({
...ids,
intervalSeconds: 60,
maxMessages: 1,
entryId: reserved.entryId!,
});
const retried = await counter.consume({
...ids,
intervalSeconds: 60,
maxMessages: 1,
now,
});
expect(retried.accepted).toBe(true);
});
test('keeps quotas isolated by user and channel', async () => {
const first = createIds();
const secondUser = {
...first,
userId: String(new Types.ObjectId()),
};
const secondChannel = createIds();
const now = new Date('2026-08-20T00:00:00.000Z');
await counter.consume({
...first,
intervalSeconds: 60,
maxMessages: 1,
now,
});
const [sameQuota, otherUser, otherChannel] = await Promise.all([
counter.consume({
...first,
intervalSeconds: 60,
maxMessages: 1,
now,
}),
counter.consume({
...secondUser,
intervalSeconds: 60,
maxMessages: 1,
now,
}),
counter.consume({
...secondChannel,
intervalSeconds: 60,
maxMessages: 1,
now,
}),
]);
expect(sameQuota.accepted).toBe(false);
expect(otherUser.accepted).toBe(true);
expect(otherChannel.accepted).toBe(true);
});
test('allows exactly the configured count under concurrent sends', async () => {
const ids = createIds();
const now = new Date('2026-08-20T00:00:00.000Z');
const results = await Promise.all(
Array.from({ length: 20 }, () =>
counter.consume({
...ids,
intervalSeconds: 60,
maxMessages: 5,
now,
})
)
);
expect(results.filter((result) => result.accepted)).toHaveLength(5);
expect(results.filter((result) => !result.accepted)).toHaveLength(15);
});
test('resets the quota when the policy changes', async () => {
const ids = createIds();
const now = new Date('2026-08-20T00:00:00.000Z');
await counter.consume({
...ids,
intervalSeconds: 60,
maxMessages: 1,
now,
});
const result = await counter.consume({
...ids,
intervalSeconds: 60,
maxMessages: 5,
now,
});
expect(result).toMatchObject({
accepted: true,
remaining: 4,
});
expect(await counter.deleteByConverseIds([ids.converseId])).toBe(2);
const reset = await counter.consume({
...ids,
intervalSeconds: 60,
maxMessages: 1,
now,
});
expect(reset.accepted).toBe(true);
});
test('uses Redis server time when application time is omitted', async () => {
const ids = createIds();
const before = Date.now();
const result = await counter.consume({
...ids,
intervalSeconds: 60,
maxMessages: 1,
});
const after = Date.now();
expect(result.accepted).toBe(true);
expect(result.retryAfterMs).toBeGreaterThan(59000);
expect(result.retryAfterMs).toBeLessThanOrEqual(60000);
expect(result.resetAt!.valueOf()).toBeGreaterThanOrEqual(before + 60000);
expect(result.resetAt!.valueOf()).toBeLessThanOrEqual(after + 60000);
});
});
Loading…
Cancel
Save