feat: add per-group daily message limits (dailyLimit + dailyUserLimit) (#484)

This commit is contained in:
Cameron
2026-03-04 12:55:49 -08:00
committed by GitHub
parent a9cab72426
commit 025fd38d5f
10 changed files with 373 additions and 7 deletions

View File

@@ -11,7 +11,7 @@ import type { DmPolicy } from '../pairing/types.js';
import { isUserAllowed, upsertPairingRequest } from '../pairing/store.js';
import { buildAttachmentPath, downloadToFile } from './attachments.js';
import { HELP_TEXT } from '../core/commands.js';
import { isGroupAllowed, isGroupUserAllowed, resolveGroupMode, resolveReceiveBotMessages, type GroupModeConfig } from './group-mode.js';
import { isGroupAllowed, isGroupUserAllowed, resolveGroupMode, resolveReceiveBotMessages, resolveDailyLimits, checkDailyLimit, type GroupModeConfig } from './group-mode.js';
import { basename } from 'node:path';
import { createLogger } from '../logger.js';
@@ -30,6 +30,7 @@ export interface DiscordConfig {
attachmentsDir?: string;
attachmentsMaxBytes?: number;
groups?: Record<string, GroupModeConfig>; // Per-guild/channel settings
agentName?: string; // For scoping daily limit counters in multi-agent mode
}
export function shouldProcessDiscordBotMessage(params: {
@@ -295,6 +296,16 @@ Ask the bot owner to approve with:
return; // Mention required but not mentioned -- silent drop
}
isListeningMode = mode === 'listen' && !wasMentioned;
// Daily rate limit check (after all other gating so we only count real triggers)
const limits = resolveDailyLimits(this.config.groups, keys);
const counterScope = limits.matchedKey ?? chatId;
const counterKey = `${this.config.agentName ?? ''}:discord:${counterScope}`;
const limitResult = checkDailyLimit(counterKey, userId, limits);
if (!limitResult.allowed) {
log.info(`Daily limit reached for ${counterKey} (${limitResult.reason})`);
return;
}
}
await this.onMessage({

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { isGroupAllowed, isGroupUserAllowed, resolveGroupAllowedUsers, resolveGroupMode, resolveReceiveBotMessages, type GroupsConfig } from './group-mode.js';
import { beforeEach, describe, expect, it } from 'vitest';
import { isGroupAllowed, isGroupUserAllowed, resolveGroupAllowedUsers, resolveGroupMode, resolveReceiveBotMessages, resolveDailyLimits, checkDailyLimit, resetDailyLimitCounters, type GroupsConfig } from './group-mode.js';
describe('group-mode helpers', () => {
describe('isGroupAllowed', () => {
@@ -217,4 +217,144 @@ describe('group-mode helpers', () => {
expect(isGroupUserAllowed(groups, ['other-group'], 'guest')).toBe(false);
});
});
describe('resolveDailyLimits', () => {
it('returns empty when groups config is missing', () => {
expect(resolveDailyLimits(undefined, ['group-1'])).toEqual({});
});
it('returns empty when no daily limits configured', () => {
const groups: GroupsConfig = { 'group-1': { mode: 'open' } };
expect(resolveDailyLimits(groups, ['group-1'])).toEqual({});
});
it('resolves dailyLimit from specific key', () => {
const groups: GroupsConfig = {
'group-1': { mode: 'open', dailyLimit: 50 },
};
expect(resolveDailyLimits(groups, ['group-1'])).toEqual({ dailyLimit: 50, dailyUserLimit: undefined, matchedKey: 'group-1' });
});
it('resolves dailyUserLimit from specific key', () => {
const groups: GroupsConfig = {
'group-1': { mode: 'open', dailyUserLimit: 10 },
};
expect(resolveDailyLimits(groups, ['group-1'])).toEqual({ dailyLimit: undefined, dailyUserLimit: 10, matchedKey: 'group-1' });
});
it('resolves both limits together', () => {
const groups: GroupsConfig = {
'group-1': { mode: 'open', dailyLimit: 100, dailyUserLimit: 20 },
};
expect(resolveDailyLimits(groups, ['group-1'])).toEqual({ dailyLimit: 100, dailyUserLimit: 20, matchedKey: 'group-1' });
});
it('uses wildcard as fallback', () => {
const groups: GroupsConfig = {
'*': { mode: 'open', dailyLimit: 30 },
};
expect(resolveDailyLimits(groups, ['group-1'])).toEqual({ dailyLimit: 30, dailyUserLimit: undefined, matchedKey: '*' });
});
it('prefers specific key over wildcard', () => {
const groups: GroupsConfig = {
'*': { mode: 'open', dailyLimit: 100 },
'group-1': { mode: 'open', dailyLimit: 10 },
};
expect(resolveDailyLimits(groups, ['group-1'])).toEqual({ dailyLimit: 10, dailyUserLimit: undefined, matchedKey: 'group-1' });
});
it('uses first matching key in priority order', () => {
const groups: GroupsConfig = {
'chat-1': { mode: 'open', dailyLimit: 5 },
'server-1': { mode: 'open', dailyLimit: 50 },
};
expect(resolveDailyLimits(groups, ['chat-1', 'server-1'])).toEqual({ dailyLimit: 5, dailyUserLimit: undefined, matchedKey: 'chat-1' });
expect(resolveDailyLimits(groups, ['chat-2', 'server-1'])).toEqual({ dailyLimit: 50, dailyUserLimit: undefined, matchedKey: 'server-1' });
});
it('inherits undefined fields from wildcard', () => {
const groups: GroupsConfig = {
'*': { mode: 'open', dailyUserLimit: 10 },
'channel-123': { mode: 'open', dailyLimit: 50 },
};
// channel-123 sets dailyLimit, wildcard provides dailyUserLimit
expect(resolveDailyLimits(groups, ['channel-123'])).toEqual({
dailyLimit: 50,
dailyUserLimit: 10,
matchedKey: 'channel-123',
});
});
it('specific key overrides wildcard for the same field', () => {
const groups: GroupsConfig = {
'*': { mode: 'open', dailyLimit: 100, dailyUserLimit: 20 },
'group-1': { mode: 'open', dailyLimit: 10 },
};
// group-1 overrides dailyLimit, inherits dailyUserLimit from wildcard
expect(resolveDailyLimits(groups, ['group-1'])).toEqual({
dailyLimit: 10,
dailyUserLimit: 20,
matchedKey: 'group-1',
});
});
});
describe('checkDailyLimit', () => {
beforeEach(() => {
resetDailyLimitCounters();
});
it('allows when no limits configured', () => {
const result = checkDailyLimit('test:group', 'user-1', {});
expect(result).toEqual({ allowed: true });
});
it('enforces dailyLimit (group-wide total)', () => {
const limits = { dailyLimit: 3 };
expect(checkDailyLimit('test:group', 'user-1', limits).allowed).toBe(true);
expect(checkDailyLimit('test:group', 'user-2', limits).allowed).toBe(true);
expect(checkDailyLimit('test:group', 'user-3', limits).allowed).toBe(true);
// 4th message exceeds group-wide limit regardless of user
const result = checkDailyLimit('test:group', 'user-4', limits);
expect(result).toEqual({ allowed: false, reason: 'daily-limit' });
});
it('enforces dailyUserLimit (per-user)', () => {
const limits = { dailyUserLimit: 2 };
expect(checkDailyLimit('test:group', 'user-1', limits).allowed).toBe(true);
expect(checkDailyLimit('test:group', 'user-1', limits).allowed).toBe(true);
// user-1 is blocked
const result = checkDailyLimit('test:group', 'user-1', limits);
expect(result).toEqual({ allowed: false, reason: 'daily-user-limit' });
// user-2 is still allowed
expect(checkDailyLimit('test:group', 'user-2', limits).allowed).toBe(true);
});
it('checks group limit before user limit', () => {
const limits = { dailyLimit: 2, dailyUserLimit: 5 };
expect(checkDailyLimit('test:group', 'user-1', limits).allowed).toBe(true);
expect(checkDailyLimit('test:group', 'user-2', limits).allowed).toBe(true);
// Group limit hit -- reason should be daily-limit, not daily-user-limit
const result = checkDailyLimit('test:group', 'user-3', limits);
expect(result).toEqual({ allowed: false, reason: 'daily-limit' });
});
it('isolates counters between different groups', () => {
const limits = { dailyLimit: 1 };
expect(checkDailyLimit('discord:group-a', 'user-1', limits).allowed).toBe(true);
expect(checkDailyLimit('discord:group-b', 'user-1', limits).allowed).toBe(true);
// group-a is full, group-b is full, but they're independent
expect(checkDailyLimit('discord:group-a', 'user-1', limits).allowed).toBe(false);
expect(checkDailyLimit('discord:group-b', 'user-1', limits).allowed).toBe(false);
});
it('does not increment counters when denied', () => {
const limits = { dailyLimit: 2 };
expect(checkDailyLimit('test:group', 'user-1', limits).allowed).toBe(true); // count=1
expect(checkDailyLimit('test:group', 'user-1', limits).allowed).toBe(true); // count=2
expect(checkDailyLimit('test:group', 'user-1', limits).allowed).toBe(false); // denied, count stays 2
expect(checkDailyLimit('test:group', 'user-1', limits).allowed).toBe(false); // still denied, count stays 2
});
});
});

View File

@@ -10,6 +10,10 @@ export interface GroupModeConfig {
allowedUsers?: string[];
/** Process messages from other bots instead of dropping them. Default: false. */
receiveBotMessages?: boolean;
/** Maximum total bot triggers per day in this group. Omit for unlimited. */
dailyLimit?: number;
/** Maximum bot triggers per user per day in this group. Omit for unlimited. */
dailyUserLimit?: number;
/**
* @deprecated Use mode: "mention-only" (true) or "open" (false).
*/
@@ -122,3 +126,146 @@ export function resolveGroupMode(
}
return fallback;
}
export interface ResolvedDailyLimits {
dailyLimit?: number;
dailyUserLimit?: number;
/** The config key that provided the limits (e.g. channelId, guildId, or "*"). */
matchedKey?: string;
}
/**
* Resolve the effective daily limit config for a group/channel.
*
* Priority for each field independently:
* 1. First matching key in provided order
* 2. Wildcard "*"
* 3. undefined (no limit)
*
* Fields are merged: a specific key can set `dailyLimit` while wildcard
* provides `dailyUserLimit` (or vice versa).
*
* Returns `matchedKey` (the most specific key that contributed any limit)
* so callers can scope counters to the config level.
*/
export function resolveDailyLimits(
groups: GroupsConfig | undefined,
keys: string[],
): ResolvedDailyLimits {
if (!groups) return {};
const wildcard = groups['*'];
// Find the first specific key that has any limit
let matched: { config: GroupModeConfig; key: string } | undefined;
for (const key of keys) {
const config = groups[key];
if (config && (config.dailyLimit !== undefined || config.dailyUserLimit !== undefined)) {
matched = { config, key };
break;
}
}
if (!matched) {
// No specific key -- use wildcard only
if (wildcard && (wildcard.dailyLimit !== undefined || wildcard.dailyUserLimit !== undefined)) {
return { dailyLimit: wildcard.dailyLimit, dailyUserLimit: wildcard.dailyUserLimit, matchedKey: '*' };
}
return {};
}
// Merge: specific key takes priority, wildcard fills in undefined fields
return {
dailyLimit: matched.config.dailyLimit ?? wildcard?.dailyLimit,
dailyUserLimit: matched.config.dailyUserLimit ?? wildcard?.dailyUserLimit,
matchedKey: matched.key,
};
}
// ---------------------------------------------------------------------------
// In-memory daily rate limit counters
// ---------------------------------------------------------------------------
interface DailyCounter {
date: string;
total: number;
users: Map<string, number>;
}
/** keyed by "channel:groupId" */
const counters = new Map<string, DailyCounter>();
function today(): string {
return new Date().toISOString().slice(0, 10);
}
let lastEvictionDate = '';
function getCounter(counterKey: string): DailyCounter {
const d = today();
// Evict stale entries once per day (on first access after midnight)
if (d !== lastEvictionDate) {
for (const [key, entry] of counters) {
if (entry.date !== d) counters.delete(key);
}
lastEvictionDate = d;
}
let counter = counters.get(counterKey);
if (!counter || counter.date !== d) {
counter = { date: d, total: 0, users: new Map() };
counters.set(counterKey, counter);
}
return counter;
}
export interface DailyLimitResult {
allowed: boolean;
reason?: 'daily-limit' | 'daily-user-limit';
}
/**
* Check and increment daily rate limit counters for a group message.
*
* Returns whether the message is allowed. Increments counters only when allowed.
*
* @param counterKey - Unique key for the group, typically "channel:chatId"
* @param userId - Sender's user ID (for per-user limits)
* @param limits - Resolved daily limits from config
*/
export function checkDailyLimit(
counterKey: string,
userId: string,
limits: { dailyLimit?: number; dailyUserLimit?: number },
): DailyLimitResult {
if (limits.dailyLimit === undefined && limits.dailyUserLimit === undefined) {
return { allowed: true };
}
const counter = getCounter(counterKey);
// Check group-wide limit first
if (limits.dailyLimit !== undefined && counter.total >= limits.dailyLimit) {
return { allowed: false, reason: 'daily-limit' };
}
// Check per-user limit
if (limits.dailyUserLimit !== undefined) {
const userCount = counter.users.get(userId) ?? 0;
if (userCount >= limits.dailyUserLimit) {
return { allowed: false, reason: 'daily-user-limit' };
}
}
// Both checks passed -- increment
counter.total++;
counter.users.set(userId, (counter.users.get(userId) ?? 0) + 1);
return { allowed: true };
}
/** Reset all counters. Exported for testing. */
export function resetDailyLimitCounters(): void {
counters.clear();
lastEvictionDate = '';
}

View File

@@ -8,6 +8,7 @@
import type { ChannelAdapter } from './types.js';
import type { InboundAttachment, InboundMessage, OutboundFile, OutboundMessage } from '../core/types.js';
import { applySignalGroupGating } from './signal/group-gating.js';
import { resolveDailyLimits, checkDailyLimit } from './group-mode.js';
import type { DmPolicy } from '../pairing/types.js';
import {
isUserAllowed,
@@ -43,6 +44,7 @@ export interface SignalConfig {
// Group gating
mentionPatterns?: string[]; // Regex patterns for mention detection (e.g., ["@bot"])
groups?: Record<string, SignalGroupConfig>; // Per-group settings, "*" for defaults
agentName?: string; // For scoping daily limit counters in multi-agent mode
}
type SignalRpcResponse<T> = {
@@ -854,6 +856,16 @@ This code expires in 1 hour.`;
return;
}
// Daily rate limit check
const groupKeys = [groupInfo.groupId, `group:${groupInfo.groupId}`];
const limits = resolveDailyLimits(this.config.groups, groupKeys);
const counterKey = `${this.config.agentName ?? ''}:signal:${limits.matchedKey ?? groupInfo.groupId}`;
const limitResult = checkDailyLimit(counterKey, source, limits);
if (!limitResult.allowed) {
log.info(`Daily limit reached for ${counterKey} (${limitResult.reason})`);
return;
}
wasMentioned = gatingResult.wasMentioned;
isListeningMode = gatingResult.mode === 'listen' && !wasMentioned;
if (wasMentioned) {

View File

@@ -11,7 +11,7 @@ import { basename } from 'node:path';
import { buildAttachmentPath, downloadToFile } from './attachments.js';
import { parseCommand, HELP_TEXT } from '../core/commands.js';
import { markdownToSlackMrkdwn } from './slack-format.js';
import { isGroupAllowed, isGroupUserAllowed, resolveGroupMode, type GroupMode, type GroupModeConfig } from './group-mode.js';
import { isGroupAllowed, isGroupUserAllowed, resolveGroupMode, resolveDailyLimits, checkDailyLimit, type GroupMode, type GroupModeConfig } from './group-mode.js';
import { createLogger } from '../logger.js';
@@ -28,6 +28,7 @@ export interface SlackConfig {
attachmentsDir?: string;
attachmentsMaxBytes?: number;
groups?: Record<string, GroupModeConfig>; // Per-channel settings
agentName?: string; // For scoping daily limit counters in multi-agent mode
}
export class SlackAdapter implements ChannelAdapter {
@@ -153,6 +154,15 @@ export class SlackAdapter implements ChannelAdapter {
// The app_mention handler will process actual @mentions.
return;
}
// Daily rate limit check
const limits = resolveDailyLimits(this.config.groups, [channelId]);
const counterKey = `${this.config.agentName ?? ''}:slack:${limits.matchedKey ?? channelId}`;
const limitResult = checkDailyLimit(counterKey, userId || '', limits);
if (!limitResult.allowed) {
log.info(`Daily limit reached for ${counterKey} (${limitResult.reason})`);
return;
}
}
await this.onMessage({
@@ -232,7 +242,7 @@ export class SlackAdapter implements ChannelAdapter {
return; // User not in group allowedUsers -- silent drop
}
// Handle slash commands
// Handle slash commands (before rate limiting -- commands should always work)
const parsed = parseCommand(text);
if (parsed) {
if (parsed.command === 'help' || parsed.command === 'start') {
@@ -244,6 +254,15 @@ export class SlackAdapter implements ChannelAdapter {
return; // Don't pass commands to agent
}
// Daily rate limit check (after commands so /help, /reset etc. always work)
const mentionLimits = resolveDailyLimits(this.config.groups, [channelId]);
const mentionCounterKey = `${this.config.agentName ?? ''}:slack:${mentionLimits.matchedKey ?? channelId}`;
const mentionLimitResult = checkDailyLimit(mentionCounterKey, userId, mentionLimits);
if (!mentionLimitResult.allowed) {
log.info(`Daily limit reached for ${mentionCounterKey} (${mentionLimitResult.reason})`);
return;
}
if (this.onMessage) {
const attachments = await this.collectAttachments(
(event as { files?: SlackFile[] }).files,

View File

@@ -18,7 +18,7 @@ import { isGroupApproved, approveGroup } from '../pairing/group-store.js';
import { basename } from 'node:path';
import { buildAttachmentPath, downloadToFile } from './attachments.js';
import { applyTelegramGroupGating } from './telegram-group-gating.js';
import type { GroupModeConfig } from './group-mode.js';
import { resolveDailyLimits, checkDailyLimit, type GroupModeConfig } from './group-mode.js';
import { createLogger } from '../logger.js';
@@ -32,6 +32,7 @@ export interface TelegramConfig {
attachmentsMaxBytes?: number;
mentionPatterns?: string[]; // Regex patterns for mention detection
groups?: Record<string, GroupModeConfig>; // Per-group settings
agentName?: string; // For scoping daily limit counters in multi-agent mode
}
export class TelegramAdapter implements ChannelAdapter {
@@ -92,6 +93,18 @@ export class TelegramAdapter implements ChannelAdapter {
log.info(`Group message filtered: ${gatingResult.reason}`);
return null;
}
// Daily rate limit check (after all other gating so we only count real triggers)
const chatIdStr = String(ctx.chat.id);
const senderId = ctx.from?.id ? String(ctx.from.id) : '';
const limits = resolveDailyLimits(this.config.groups, [chatIdStr]);
const counterKey = `${this.config.agentName ?? ''}:telegram:${limits.matchedKey ?? chatIdStr}`;
const limitResult = checkDailyLimit(counterKey, senderId, limits);
if (!limitResult.allowed) {
log.info(`Daily limit reached for ${counterKey} (${limitResult.reason})`);
return null;
}
const wasMentioned = gatingResult.wasMentioned ?? false;
const isListeningMode = gatingResult.mode === 'listen' && !wasMentioned;
return { isGroup, groupName, wasMentioned, isListeningMode };

View File

@@ -41,6 +41,7 @@ import {
formatPairingMessage,
} from "./inbound/access-control.js";
import { applyGroupGating } from "./inbound/group-gating.js";
import { resolveDailyLimits, checkDailyLimit } from "../group-mode.js";
// Outbound message handling
import {
@@ -820,6 +821,17 @@ export class WhatsAppAdapter implements ChannelAdapter {
return; // Don't pass commands to agent
}
// Daily rate limit check (after commands so /help, /reset etc. always work)
if (isGroup) {
const limits = resolveDailyLimits(this.config.groups, [remoteJid]);
const counterKey = `${this.config.agentName ?? ''}:whatsapp:${limits.matchedKey ?? remoteJid}`;
const limitResult = checkDailyLimit(counterKey, userId, limits);
if (!limitResult.allowed) {
log.info(`Daily limit reached for ${counterKey} (${limitResult.reason})`);
continue;
}
}
// Debounce and forward to bot core (unless history)
if (!isHistory) {
await this.debouncer.enqueue({

View File

@@ -53,6 +53,9 @@ export interface WhatsAppConfig {
/** Per-group settings (JID or "*" for defaults) */
groups?: Record<string, GroupModeConfig>;
/** For scoping daily limit counters in multi-agent mode */
agentName?: string;
}
/**

View File

@@ -277,6 +277,10 @@ export interface GroupConfig {
allowedUsers?: string[];
/** Process messages from other bots instead of dropping them. Default: false. */
receiveBotMessages?: boolean;
/** Maximum total bot triggers per day in this group. Omit for unlimited. */
dailyLimit?: number;
/** Maximum bot triggers per user per day in this group. Omit for unlimited. */
dailyUserLimit?: number;
/**
* @deprecated Use mode: "mention-only" (true) or "open" (false).
*/

View File

@@ -374,6 +374,7 @@ function createChannelsForAgent(
attachmentsMaxBytes,
groups: agentConfig.channels.telegram!.groups,
mentionPatterns: agentConfig.channels.telegram!.mentionPatterns,
agentName: agentConfig.name,
}));
}
@@ -405,6 +406,7 @@ function createChannelsForAgent(
attachmentsDir,
attachmentsMaxBytes,
groups: agentConfig.channels.slack.groups,
agentName: agentConfig.name,
}));
}
@@ -425,6 +427,7 @@ function createChannelsForAgent(
attachmentsMaxBytes,
groups: agentConfig.channels.whatsapp.groups,
mentionPatterns: agentConfig.channels.whatsapp.mentionPatterns,
agentName: agentConfig.name,
}));
}
@@ -448,6 +451,7 @@ function createChannelsForAgent(
attachmentsMaxBytes,
groups: agentConfig.channels.signal.groups,
mentionPatterns: agentConfig.channels.signal.mentionPatterns,
agentName: agentConfig.name,
}));
}
@@ -462,6 +466,7 @@ function createChannelsForAgent(
attachmentsDir,
attachmentsMaxBytes,
groups: agentConfig.channels.discord.groups,
agentName: agentConfig.name,
}));
}