From 025fd38d5fdd2c7aff16273a381e3e9225059042 Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 4 Mar 2026 12:55:49 -0800 Subject: [PATCH] feat: add per-group daily message limits (dailyLimit + dailyUserLimit) (#484) --- src/channels/discord.ts | 13 ++- src/channels/group-mode.test.ts | 144 ++++++++++++++++++++++++++++++- src/channels/group-mode.ts | 147 ++++++++++++++++++++++++++++++++ src/channels/signal.ts | 12 +++ src/channels/slack.ts | 25 +++++- src/channels/telegram.ts | 15 +++- src/channels/whatsapp/index.ts | 12 +++ src/channels/whatsapp/types.ts | 3 + src/config/types.ts | 4 + src/main.ts | 5 ++ 10 files changed, 373 insertions(+), 7 deletions(-) diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 77d5121..2bac090 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -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; // 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({ diff --git a/src/channels/group-mode.test.ts b/src/channels/group-mode.test.ts index 15d2ba6..e1700b4 100644 --- a/src/channels/group-mode.test.ts +++ b/src/channels/group-mode.test.ts @@ -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 + }); + }); }); diff --git a/src/channels/group-mode.ts b/src/channels/group-mode.ts index fa5f895..7cb9fe8 100644 --- a/src/channels/group-mode.ts +++ b/src/channels/group-mode.ts @@ -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; +} + +/** keyed by "channel:groupId" */ +const counters = new Map(); + +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 = ''; +} diff --git a/src/channels/signal.ts b/src/channels/signal.ts index dd6c406..b6c143d 100644 --- a/src/channels/signal.ts +++ b/src/channels/signal.ts @@ -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; // Per-group settings, "*" for defaults + agentName?: string; // For scoping daily limit counters in multi-agent mode } type SignalRpcResponse = { @@ -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; diff --git a/src/channels/slack.ts b/src/channels/slack.ts index ddabca6..0d366a3 100644 --- a/src/channels/slack.ts +++ b/src/channels/slack.ts @@ -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; // 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( diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index c7e8729..52be183 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -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; // 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 }; diff --git a/src/channels/whatsapp/index.ts b/src/channels/whatsapp/index.ts index ce08d42..4c71cdf 100644 --- a/src/channels/whatsapp/index.ts +++ b/src/channels/whatsapp/index.ts @@ -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({ diff --git a/src/channels/whatsapp/types.ts b/src/channels/whatsapp/types.ts index 19fc474..e5dc2ca 100644 --- a/src/channels/whatsapp/types.ts +++ b/src/channels/whatsapp/types.ts @@ -53,6 +53,9 @@ export interface WhatsAppConfig { /** Per-group settings (JID or "*" for defaults) */ groups?: Record; + + /** For scoping daily limit counters in multi-agent mode */ + agentName?: string; } /** diff --git a/src/config/types.ts b/src/config/types.ts index 64cfb6a..c2f1d21 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -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). */ diff --git a/src/main.ts b/src/main.ts index 21aa84a..32b37bc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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, })); }