feat: add per-group daily message limits (dailyLimit + dailyUserLimit) (#484)
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = '';
|
||||
}
|
||||
|
||||
@@ -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> = {
|
||||
@@ -853,6 +855,16 @@ This code expires in 1 hour.`;
|
||||
log.info(`Group message filtered: ${gatingResult.reason}`);
|
||||
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;
|
||||
|
||||
@@ -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({
|
||||
@@ -231,8 +241,8 @@ export class SlackAdapter implements ChannelAdapter {
|
||||
if (!isGroupUserAllowed(this.config.groups, [channelId], userId)) {
|
||||
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') {
|
||||
@@ -243,6 +253,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(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user