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)}`));