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 { isUserAllowed, upsertPairingRequest } from '../pairing/store.js';
|
||||||
import { buildAttachmentPath, downloadToFile } from './attachments.js';
|
import { buildAttachmentPath, downloadToFile } from './attachments.js';
|
||||||
import { HELP_TEXT } from '../core/commands.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 { basename } from 'node:path';
|
||||||
|
|
||||||
import { createLogger } from '../logger.js';
|
import { createLogger } from '../logger.js';
|
||||||
@@ -30,6 +30,7 @@ export interface DiscordConfig {
|
|||||||
attachmentsDir?: string;
|
attachmentsDir?: string;
|
||||||
attachmentsMaxBytes?: number;
|
attachmentsMaxBytes?: number;
|
||||||
groups?: Record<string, GroupModeConfig>; // Per-guild/channel settings
|
groups?: Record<string, GroupModeConfig>; // Per-guild/channel settings
|
||||||
|
agentName?: string; // For scoping daily limit counters in multi-agent mode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldProcessDiscordBotMessage(params: {
|
export function shouldProcessDiscordBotMessage(params: {
|
||||||
@@ -295,6 +296,16 @@ Ask the bot owner to approve with:
|
|||||||
return; // Mention required but not mentioned -- silent drop
|
return; // Mention required but not mentioned -- silent drop
|
||||||
}
|
}
|
||||||
isListeningMode = mode === 'listen' && !wasMentioned;
|
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({
|
await this.onMessage({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
import { isGroupAllowed, isGroupUserAllowed, resolveGroupAllowedUsers, resolveGroupMode, resolveReceiveBotMessages, type GroupsConfig } from './group-mode.js';
|
import { isGroupAllowed, isGroupUserAllowed, resolveGroupAllowedUsers, resolveGroupMode, resolveReceiveBotMessages, resolveDailyLimits, checkDailyLimit, resetDailyLimitCounters, type GroupsConfig } from './group-mode.js';
|
||||||
|
|
||||||
describe('group-mode helpers', () => {
|
describe('group-mode helpers', () => {
|
||||||
describe('isGroupAllowed', () => {
|
describe('isGroupAllowed', () => {
|
||||||
@@ -217,4 +217,144 @@ describe('group-mode helpers', () => {
|
|||||||
expect(isGroupUserAllowed(groups, ['other-group'], 'guest')).toBe(false);
|
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[];
|
allowedUsers?: string[];
|
||||||
/** Process messages from other bots instead of dropping them. Default: false. */
|
/** Process messages from other bots instead of dropping them. Default: false. */
|
||||||
receiveBotMessages?: boolean;
|
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).
|
* @deprecated Use mode: "mention-only" (true) or "open" (false).
|
||||||
*/
|
*/
|
||||||
@@ -122,3 +126,146 @@ export function resolveGroupMode(
|
|||||||
}
|
}
|
||||||
return fallback;
|
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 { ChannelAdapter } from './types.js';
|
||||||
import type { InboundAttachment, InboundMessage, OutboundFile, OutboundMessage } from '../core/types.js';
|
import type { InboundAttachment, InboundMessage, OutboundFile, OutboundMessage } from '../core/types.js';
|
||||||
import { applySignalGroupGating } from './signal/group-gating.js';
|
import { applySignalGroupGating } from './signal/group-gating.js';
|
||||||
|
import { resolveDailyLimits, checkDailyLimit } from './group-mode.js';
|
||||||
import type { DmPolicy } from '../pairing/types.js';
|
import type { DmPolicy } from '../pairing/types.js';
|
||||||
import {
|
import {
|
||||||
isUserAllowed,
|
isUserAllowed,
|
||||||
@@ -43,6 +44,7 @@ export interface SignalConfig {
|
|||||||
// Group gating
|
// Group gating
|
||||||
mentionPatterns?: string[]; // Regex patterns for mention detection (e.g., ["@bot"])
|
mentionPatterns?: string[]; // Regex patterns for mention detection (e.g., ["@bot"])
|
||||||
groups?: Record<string, SignalGroupConfig>; // Per-group settings, "*" for defaults
|
groups?: Record<string, SignalGroupConfig>; // Per-group settings, "*" for defaults
|
||||||
|
agentName?: string; // For scoping daily limit counters in multi-agent mode
|
||||||
}
|
}
|
||||||
|
|
||||||
type SignalRpcResponse<T> = {
|
type SignalRpcResponse<T> = {
|
||||||
@@ -854,6 +856,16 @@ This code expires in 1 hour.`;
|
|||||||
return;
|
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;
|
wasMentioned = gatingResult.wasMentioned;
|
||||||
isListeningMode = gatingResult.mode === 'listen' && !wasMentioned;
|
isListeningMode = gatingResult.mode === 'listen' && !wasMentioned;
|
||||||
if (wasMentioned) {
|
if (wasMentioned) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { basename } from 'node:path';
|
|||||||
import { buildAttachmentPath, downloadToFile } from './attachments.js';
|
import { buildAttachmentPath, downloadToFile } from './attachments.js';
|
||||||
import { parseCommand, HELP_TEXT } from '../core/commands.js';
|
import { parseCommand, HELP_TEXT } from '../core/commands.js';
|
||||||
import { markdownToSlackMrkdwn } from './slack-format.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';
|
import { createLogger } from '../logger.js';
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ export interface SlackConfig {
|
|||||||
attachmentsDir?: string;
|
attachmentsDir?: string;
|
||||||
attachmentsMaxBytes?: number;
|
attachmentsMaxBytes?: number;
|
||||||
groups?: Record<string, GroupModeConfig>; // Per-channel settings
|
groups?: Record<string, GroupModeConfig>; // Per-channel settings
|
||||||
|
agentName?: string; // For scoping daily limit counters in multi-agent mode
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SlackAdapter implements ChannelAdapter {
|
export class SlackAdapter implements ChannelAdapter {
|
||||||
@@ -153,6 +154,15 @@ export class SlackAdapter implements ChannelAdapter {
|
|||||||
// The app_mention handler will process actual @mentions.
|
// The app_mention handler will process actual @mentions.
|
||||||
return;
|
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({
|
await this.onMessage({
|
||||||
@@ -232,7 +242,7 @@ export class SlackAdapter implements ChannelAdapter {
|
|||||||
return; // User not in group allowedUsers -- silent drop
|
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);
|
const parsed = parseCommand(text);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
if (parsed.command === 'help' || parsed.command === 'start') {
|
if (parsed.command === 'help' || parsed.command === 'start') {
|
||||||
@@ -244,6 +254,15 @@ export class SlackAdapter implements ChannelAdapter {
|
|||||||
return; // Don't pass commands to agent
|
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) {
|
if (this.onMessage) {
|
||||||
const attachments = await this.collectAttachments(
|
const attachments = await this.collectAttachments(
|
||||||
(event as { files?: SlackFile[] }).files,
|
(event as { files?: SlackFile[] }).files,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { isGroupApproved, approveGroup } from '../pairing/group-store.js';
|
|||||||
import { basename } from 'node:path';
|
import { basename } from 'node:path';
|
||||||
import { buildAttachmentPath, downloadToFile } from './attachments.js';
|
import { buildAttachmentPath, downloadToFile } from './attachments.js';
|
||||||
import { applyTelegramGroupGating } from './telegram-group-gating.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';
|
import { createLogger } from '../logger.js';
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ export interface TelegramConfig {
|
|||||||
attachmentsMaxBytes?: number;
|
attachmentsMaxBytes?: number;
|
||||||
mentionPatterns?: string[]; // Regex patterns for mention detection
|
mentionPatterns?: string[]; // Regex patterns for mention detection
|
||||||
groups?: Record<string, GroupModeConfig>; // Per-group settings
|
groups?: Record<string, GroupModeConfig>; // Per-group settings
|
||||||
|
agentName?: string; // For scoping daily limit counters in multi-agent mode
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TelegramAdapter implements ChannelAdapter {
|
export class TelegramAdapter implements ChannelAdapter {
|
||||||
@@ -92,6 +93,18 @@ export class TelegramAdapter implements ChannelAdapter {
|
|||||||
log.info(`Group message filtered: ${gatingResult.reason}`);
|
log.info(`Group message filtered: ${gatingResult.reason}`);
|
||||||
return null;
|
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 wasMentioned = gatingResult.wasMentioned ?? false;
|
||||||
const isListeningMode = gatingResult.mode === 'listen' && !wasMentioned;
|
const isListeningMode = gatingResult.mode === 'listen' && !wasMentioned;
|
||||||
return { isGroup, groupName, wasMentioned, isListeningMode };
|
return { isGroup, groupName, wasMentioned, isListeningMode };
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
formatPairingMessage,
|
formatPairingMessage,
|
||||||
} from "./inbound/access-control.js";
|
} from "./inbound/access-control.js";
|
||||||
import { applyGroupGating } from "./inbound/group-gating.js";
|
import { applyGroupGating } from "./inbound/group-gating.js";
|
||||||
|
import { resolveDailyLimits, checkDailyLimit } from "../group-mode.js";
|
||||||
|
|
||||||
// Outbound message handling
|
// Outbound message handling
|
||||||
import {
|
import {
|
||||||
@@ -820,6 +821,17 @@ export class WhatsAppAdapter implements ChannelAdapter {
|
|||||||
return; // Don't pass commands to agent
|
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)
|
// Debounce and forward to bot core (unless history)
|
||||||
if (!isHistory) {
|
if (!isHistory) {
|
||||||
await this.debouncer.enqueue({
|
await this.debouncer.enqueue({
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ export interface WhatsAppConfig {
|
|||||||
|
|
||||||
/** Per-group settings (JID or "*" for defaults) */
|
/** Per-group settings (JID or "*" for defaults) */
|
||||||
groups?: Record<string, GroupModeConfig>;
|
groups?: Record<string, GroupModeConfig>;
|
||||||
|
|
||||||
|
/** For scoping daily limit counters in multi-agent mode */
|
||||||
|
agentName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -277,6 +277,10 @@ export interface GroupConfig {
|
|||||||
allowedUsers?: string[];
|
allowedUsers?: string[];
|
||||||
/** Process messages from other bots instead of dropping them. Default: false. */
|
/** Process messages from other bots instead of dropping them. Default: false. */
|
||||||
receiveBotMessages?: boolean;
|
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).
|
* @deprecated Use mode: "mention-only" (true) or "open" (false).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -374,6 +374,7 @@ function createChannelsForAgent(
|
|||||||
attachmentsMaxBytes,
|
attachmentsMaxBytes,
|
||||||
groups: agentConfig.channels.telegram!.groups,
|
groups: agentConfig.channels.telegram!.groups,
|
||||||
mentionPatterns: agentConfig.channels.telegram!.mentionPatterns,
|
mentionPatterns: agentConfig.channels.telegram!.mentionPatterns,
|
||||||
|
agentName: agentConfig.name,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,6 +406,7 @@ function createChannelsForAgent(
|
|||||||
attachmentsDir,
|
attachmentsDir,
|
||||||
attachmentsMaxBytes,
|
attachmentsMaxBytes,
|
||||||
groups: agentConfig.channels.slack.groups,
|
groups: agentConfig.channels.slack.groups,
|
||||||
|
agentName: agentConfig.name,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,6 +427,7 @@ function createChannelsForAgent(
|
|||||||
attachmentsMaxBytes,
|
attachmentsMaxBytes,
|
||||||
groups: agentConfig.channels.whatsapp.groups,
|
groups: agentConfig.channels.whatsapp.groups,
|
||||||
mentionPatterns: agentConfig.channels.whatsapp.mentionPatterns,
|
mentionPatterns: agentConfig.channels.whatsapp.mentionPatterns,
|
||||||
|
agentName: agentConfig.name,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,6 +451,7 @@ function createChannelsForAgent(
|
|||||||
attachmentsMaxBytes,
|
attachmentsMaxBytes,
|
||||||
groups: agentConfig.channels.signal.groups,
|
groups: agentConfig.channels.signal.groups,
|
||||||
mentionPatterns: agentConfig.channels.signal.mentionPatterns,
|
mentionPatterns: agentConfig.channels.signal.mentionPatterns,
|
||||||
|
agentName: agentConfig.name,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,6 +466,7 @@ function createChannelsForAgent(
|
|||||||
attachmentsDir,
|
attachmentsDir,
|
||||||
attachmentsMaxBytes,
|
attachmentsMaxBytes,
|
||||||
groups: agentConfig.channels.discord.groups,
|
groups: agentConfig.channels.discord.groups,
|
||||||
|
agentName: agentConfig.name,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user