From e57fcdf265d922dfc81a1d9253d852beeb55be8f Mon Sep 17 00:00:00 2001 From: Sol Date: Wed, 28 Jan 2026 17:55:27 -0800 Subject: [PATCH 1/3] feat: add channel delay for multi-bot coordination Add new config options under messages.inbound: - channelDelayMs: delay before processing any message - channelDelayMsByChannel: per-channel delay overrides - skipIfPeerRepliedMs: skip response if another bot replied within window This allows multiple bots in the same channel to coordinate by: 1. Waiting before processing (channelDelayMs) 2. Checking if a peer bot already responded 3. Skipping response if peer already handled it --- src/auto-reply/inbound-debounce.ts | 19 +++++++++ src/config/types.messages.ts | 13 +++++++ src/discord/monitor/message-handler.ts | 53 ++++++++++++++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/src/auto-reply/inbound-debounce.ts b/src/auto-reply/inbound-debounce.ts index 2b923b879..d9d62ff40 100644 --- a/src/auto-reply/inbound-debounce.ts +++ b/src/auto-reply/inbound-debounce.ts @@ -29,6 +29,25 @@ export function resolveInboundDebounceMs(params: { return override ?? byChannel ?? base ?? 0; } +export function resolveChannelDelayMs(params: { + cfg: MoltbotConfig; + channel: string; + overrideMs?: number; +}): number { + const inbound = params.cfg.messages?.inbound; + const override = resolveMs(params.overrideMs); + const byChannel = resolveChannelOverride({ + byChannel: inbound?.channelDelayMsByChannel, + channel: params.channel, + }); + const base = resolveMs(inbound?.channelDelayMs); + return override ?? byChannel ?? base ?? 0; +} + +export function resolveSkipIfPeerRepliedMs(params: { cfg: MoltbotConfig }): number { + return resolveMs(params.cfg.messages?.inbound?.skipIfPeerRepliedMs) ?? 0; +} + type DebounceBuffer = { items: T[]; timeout: ReturnType | null; diff --git a/src/config/types.messages.ts b/src/config/types.messages.ts index 37ef4e942..2eb31cc8e 100644 --- a/src/config/types.messages.ts +++ b/src/config/types.messages.ts @@ -25,6 +25,19 @@ export type InboundDebounceByProvider = Record; export type InboundDebounceConfig = { debounceMs?: number; byChannel?: InboundDebounceByProvider; + /** + * Delay (ms) before processing any inbound message. + * Useful for multi-bot coordination: gives other bots time to respond first. + * The bot can then check if a peer already replied before responding. + */ + channelDelayMs?: number; + /** Per-channel delay overrides (ms). */ + channelDelayMsByChannel?: InboundDebounceByProvider; + /** + * Skip responding if another bot replied within this window (ms) after the triggering message. + * Only effective when channelDelayMs is set. Default: 0 (disabled). + */ + skipIfPeerRepliedMs?: number; }; export type BroadcastStrategy = "parallel" | "sequential"; diff --git a/src/discord/monitor/message-handler.ts b/src/discord/monitor/message-handler.ts index a7434aed0..69954e485 100644 --- a/src/discord/monitor/message-handler.ts +++ b/src/discord/monitor/message-handler.ts @@ -4,6 +4,8 @@ import { hasControlCommand } from "../../auto-reply/command-detection.js"; import { createInboundDebouncer, resolveInboundDebounceMs, + resolveChannelDelayMs, + resolveSkipIfPeerRepliedMs, } from "../../auto-reply/inbound-debounce.js"; import type { HistoryEntry } from "../../auto-reply/reply/history.js"; import type { ReplyToMode } from "../../config/config.js"; @@ -41,6 +43,8 @@ export function createDiscordMessageHandler(params: { const groupPolicy = params.discordConfig?.groupPolicy ?? "open"; const ackReactionScope = params.cfg.messages?.ackReactionScope ?? "group-mentions"; const debounceMs = resolveInboundDebounceMs({ cfg: params.cfg, channel: "discord" }); + const channelDelayMs = resolveChannelDelayMs({ cfg: params.cfg, channel: "discord" }); + const skipIfPeerRepliedMs = resolveSkipIfPeerRepliedMs({ cfg: params.cfg }); const debouncer = createInboundDebouncer<{ data: DiscordMessageEvent; client: Client }>({ debounceMs, @@ -121,8 +125,57 @@ export function createDiscordMessageHandler(params: { }, }); + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + + const checkPeerReplied = async (data: DiscordMessageEvent, client: Client): Promise => { + if (skipIfPeerRepliedMs <= 0) return false; + const channelId = data.message?.channelId; + const triggerMessageId = data.message?.id; + const triggerTimestamp = data.message?.timestamp; + if (!channelId || !triggerMessageId || !triggerTimestamp) return false; + + try { + // Fetch recent messages after the trigger + const messages = (await client.rest.get( + `/channels/${channelId}/messages?after=${triggerMessageId}&limit=10`, + )) as + | Array<{ id: string; author?: { bot?: boolean; id?: string }; timestamp: string }> + | undefined; + if (!Array.isArray(messages)) return false; + + const triggerTime = new Date(triggerTimestamp).getTime(); + const windowEnd = triggerTime + skipIfPeerRepliedMs; + + // Check if any bot (not us) replied within the window + for (const msg of messages) { + if (!msg.author?.bot) continue; + // Skip our own bot's messages + if (params.botUserId && msg.author?.id === params.botUserId) continue; + const msgTime = new Date(msg.timestamp).getTime(); + if (msgTime <= windowEnd) { + params.runtime.log?.( + `Skipping response: peer bot replied within ${skipIfPeerRepliedMs}ms window`, + ); + return true; + } + } + return false; + } catch { + // If we can't check, proceed anyway + return false; + } + }; + return async (data, client) => { try { + // Apply channel delay for multi-bot coordination + if (channelDelayMs > 0) { + await sleep(channelDelayMs); + // Check if a peer bot already replied + if (await checkPeerReplied(data, client)) { + return; // Skip responding + } + } await debouncer.enqueue({ data, client }); } catch (err) { params.runtime.error?.(danger(`handler failed: ${String(err)}`)); From b99ad70b463dfe3c71d888ca676dffb57e4cf992 Mon Sep 17 00:00:00 2001 From: Sol Date: Wed, 28 Jan 2026 19:29:47 -0800 Subject: [PATCH 2/3] fix: add schema validation for channel delay config options --- src/config/zod-schema.core.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 4a8c80bcc..93a3f5441 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -336,6 +336,9 @@ export const InboundDebounceSchema = z .object({ debounceMs: z.number().int().nonnegative().optional(), byChannel: DebounceMsBySurfaceSchema, + channelDelayMs: z.number().int().nonnegative().optional(), + channelDelayMsByChannel: DebounceMsBySurfaceSchema, + skipIfPeerRepliedMs: z.number().int().nonnegative().optional(), }) .strict() .optional(); From 65446b0c48fa05a13cf407017d2fcfcc4abc11b7 Mon Sep 17 00:00:00 2001 From: Sol Date: Thu, 29 Jan 2026 13:23:48 -0800 Subject: [PATCH 3/3] fix: resolve channel delay per-message for Discord channel ID support The channelDelayMs was being resolved once at handler creation with 'discord' as the key, but configs use Discord channel IDs (e.g., '1465655303121928265'). This caused the peer reply check to never run since channelDelayMs was always 0. Now channelDelayMs is resolved per-message using the actual Discord channel ID from the incoming message, enabling per-channel delay configuration to work correctly. --- src/discord/monitor/message-handler.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/discord/monitor/message-handler.ts b/src/discord/monitor/message-handler.ts index 69954e485..89c5046dd 100644 --- a/src/discord/monitor/message-handler.ts +++ b/src/discord/monitor/message-handler.ts @@ -43,7 +43,7 @@ export function createDiscordMessageHandler(params: { const groupPolicy = params.discordConfig?.groupPolicy ?? "open"; const ackReactionScope = params.cfg.messages?.ackReactionScope ?? "group-mentions"; const debounceMs = resolveInboundDebounceMs({ cfg: params.cfg, channel: "discord" }); - const channelDelayMs = resolveChannelDelayMs({ cfg: params.cfg, channel: "discord" }); + // Note: channelDelayMs is resolved per-message below to support per-channel overrides const skipIfPeerRepliedMs = resolveSkipIfPeerRepliedMs({ cfg: params.cfg }); const debouncer = createInboundDebouncer<{ data: DiscordMessageEvent; client: Client }>({ @@ -168,6 +168,10 @@ export function createDiscordMessageHandler(params: { return async (data, client) => { try { + // Resolve channel delay per-message to support per-channel overrides via Discord channel ID + const discordChannelId = data.message?.channelId ?? "discord"; + const channelDelayMs = resolveChannelDelayMs({ cfg: params.cfg, channel: discordChannelId }); + // Apply channel delay for multi-bot coordination if (channelDelayMs > 0) { await sleep(channelDelayMs);