From e977acae88ae32f6044271f885ed121ecb948ccd Mon Sep 17 00:00:00 2001 From: Shellishack <40737228+Shellishack@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:10:33 +0800 Subject: [PATCH] feat(rate-limiting): implement per-user rate limiting for assistant messages --- packages/app/api/lib/route-message.ts | 38 ++++++++++++++++++- packages/app/api/routes/tenant.ts | 4 ++ packages/app/shared/index.ts | 7 ++++ .../app/web/app/(dashboard)/settings/page.tsx | 25 ++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/app/api/lib/route-message.ts b/packages/app/api/lib/route-message.ts index 96ce7e2..ce50af6 100644 --- a/packages/app/api/lib/route-message.ts +++ b/packages/app/api/lib/route-message.ts @@ -19,6 +19,33 @@ import type { AgentLlmConfig } from './clawscale-agent.js'; import { parseCommand, resolveTarget, resolveAddRemoveArg, formatCommandHelp } from './slash-commands.js'; import type { AiBackendType, AiBackendProviderConfig } from '../../shared/index.js'; +// ── Per-user rate limiter (in-memory) ──────────────────────────────────────── + +interface RateBucket { + count: number; + resetAt: number; +} + +/** Key: `${tenantId}:${endUserId}` */ +const rateBuckets = new Map(); + +function isRateLimited( + tenantId: string, + endUserId: string, + limit: { maxMessages: number; windowSeconds: number }, +): boolean { + if (limit.maxMessages <= 0) return false; // 0 = unlimited + const key = `${tenantId}:${endUserId}`; + const now = Date.now(); + const bucket = rateBuckets.get(key); + if (!bucket || now >= bucket.resetAt) { + rateBuckets.set(key, { count: 1, resetAt: now + limit.windowSeconds * 1000 }); + return false; + } + bucket.count++; + return bucket.count > limit.maxMessages; +} + export interface Attachment { url: string; filename: string; @@ -68,7 +95,7 @@ export async function routeInboundMessage(input: InboundMessage): Promise { + // Rate limit check + if (clawscaleRateLimit && isRateLimited(tenantId, endUser!.id, clawscaleRateLimit)) { + const mins = Math.ceil(clawscaleRateLimit.windowSeconds / 60); + return reply( + `⏳ You've reached the message limit (${clawscaleRateLimit.maxMessages} per ${mins === 1 ? 'minute' : `${mins} minutes`}). Please wait a moment before sending another message.`, + ); + } + // If attachments are present but multimodal is not enabled, nudge the admin if (attachments?.length && !clawscaleLlm?.multimodal) { return reply( diff --git a/packages/app/api/routes/tenant.ts b/packages/app/api/routes/tenant.ts index ee945b6..0172edf 100644 --- a/packages/app/api/routes/tenant.ts +++ b/packages/app/api/routes/tenant.ts @@ -16,6 +16,10 @@ const updateSettingsSchema = z.object({ name: z.string().min(1).max(80).optional(), answerStyle: z.string().max(500).optional(), isActive: z.boolean().optional(), + rateLimit: z.object({ + maxMessages: z.number().int().min(0).max(10000), + windowSeconds: z.number().int().min(1).max(86400), + }).nullable().optional(), llm: z.object({ model: z.string().min(1).max(100), apiKey: z.string().max(500).optional(), diff --git a/packages/app/shared/index.ts b/packages/app/shared/index.ts index 77e19c9..71329b3 100644 --- a/packages/app/shared/index.ts +++ b/packages/app/shared/index.ts @@ -19,6 +19,13 @@ export interface ClawScaleAgentSettings { answerStyle?: string; /** Whether the orchestrator responds at all (default: true) */ isActive?: boolean; + /** Per-user rate limiting for the assistant */ + rateLimit?: { + /** Maximum messages allowed per window (0 = unlimited) */ + maxMessages: number; + /** Window duration in seconds (default: 60) */ + windowSeconds: number; + }; /** LLM configuration for the ClawScale agent */ llm?: { /** LangChain model string, e.g. "openai:gpt-5.4-mini", "anthropic:claude-haiku-4-5-20251001" */ diff --git a/packages/app/web/app/(dashboard)/settings/page.tsx b/packages/app/web/app/(dashboard)/settings/page.tsx index 6957abf..2ed4616 100644 --- a/packages/app/web/app/(dashboard)/settings/page.tsx +++ b/packages/app/web/app/(dashboard)/settings/page.tsx @@ -23,6 +23,9 @@ export default function Settings() { const [clawscaleApiKey, setClawscaleApiKey] = useState(''); const [apiKeySet, setApiKeySet] = useState(false); const [clawscaleMultimodal, setClawscaleMultimodal] = useState(false); + const [rateLimitEnabled, setRateLimitEnabled] = useState(false); + const [rateLimitMax, setRateLimitMax] = useState(20); + const [rateLimitWindow, setRateLimitWindow] = useState(60); useEffect(() => { api.get>('/api/tenant').then((res) => { @@ -34,6 +37,8 @@ export default function Settings() { setClawscaleModel(s.clawscale?.llm?.model ?? 'openai:gpt-5.4-mini'); setApiKeySet(!!s.clawscale?.llm?.apiKey && s.clawscale.llm.apiKey !== ''); setClawscaleMultimodal(s.clawscale?.llm?.multimodal ?? false); + const rl = s.clawscale?.rateLimit; + if (rl) { setRateLimitEnabled(true); setRateLimitMax(rl.maxMessages); setRateLimitWindow(rl.windowSeconds); } } setLoading(false); }); @@ -52,6 +57,7 @@ export default function Settings() { ...(clawscaleApiKey ? { apiKey: clawscaleApiKey } : {}), multimodal: clawscaleMultimodal, }, + rateLimit: rateLimitEnabled ? { maxMessages: rateLimitMax, windowSeconds: rateLimitWindow } : null, }, }, }); @@ -108,6 +114,25 @@ export default function Settings() { Allow the assistant to process images, files, and audio sent by users. Requires a vision-capable model (e.g. GPT-4o, Claude Sonnet). + + {rateLimitEnabled && ( +
+
+ + setRateLimitMax(Number(e.target.value))} disabled={!isAdmin} /> +
+
+ + setRateLimitWindow(Number(e.target.value))} disabled={!isAdmin} /> +
+
+ )}