import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { ChannelStore } from "../core/channel-store.js"; import type { IdentityRegistry } from "../core/identity-registry.js"; import { parseDiscordChannelId } from "./before-model-resolve.js"; import { isDormant, wakeFromDormant, isCurrentSpeaker, hasSpeakers, setSpeakerList, getInitializingChannels, type SpeakerEntry } from "../turn-manager.js"; import { sendAndDelete, sendModeratorMessage, userIdFromBotToken } from "../core/moderator-discord.js"; import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js"; import type { InterruptFn } from "./agent-end.js"; type Deps = { api: OpenClawPluginApi; channelStore: ChannelStore; identityRegistry: IdentityRegistry; moderatorBotToken: string | undefined; scheduleIdentifier: string; interruptTailMatch: InterruptFn; }; /** * Process-level dedup for concluded-discussion auto-replies. * Multiple agent VM contexts all fire message_received for the same incoming message; * only the first should send the "This discussion is closed" reply. * Keyed on channelId:messageId; evicted after 500 entries. */ const _CONCLUDED_REPLY_DEDUP_KEY = "_dirigentConcludedReplyDedup"; if (!(globalThis as Record)[_CONCLUDED_REPLY_DEDUP_KEY]) { (globalThis as Record)[_CONCLUDED_REPLY_DEDUP_KEY] = new Set(); } const concludedReplyDedup: Set = (globalThis as Record)[_CONCLUDED_REPLY_DEDUP_KEY] as Set; export function registerMessageReceivedHook(deps: Deps): void { const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, interruptTailMatch } = deps; // Derive the moderator bot's own Discord user ID so we can skip self-messages // from waking dormant channels (idle reminders must not re-trigger the cycle). const moderatorBotUserId = moderatorBotToken ? userIdFromBotToken(moderatorBotToken) : undefined; api.on("message_received", async (event, ctx) => { try { const e = event as Record; const c = ctx as Record; // Extract Discord channel ID from ctx or event metadata let channelId: string | undefined; // ctx.channelId may be bare "1234567890" or "channel:1234567890" if (typeof c.channelId === "string") { const bare = c.channelId.match(/^(\d+)$/)?.[1] ?? c.channelId.match(/:(\d+)$/)?.[1]; if (bare) channelId = bare; } // fallback: sessionKey (per-session api instances) if (!channelId && typeof c.sessionKey === "string") { channelId = parseDiscordChannelId(c.sessionKey); } // fallback: metadata.to / originatingTo = "channel:1234567890" if (!channelId) { const metadata = e.metadata as Record | undefined; const to = String(metadata?.to ?? metadata?.originatingTo ?? ""); const toMatch = to.match(/:(\d+)$/); if (toMatch) channelId = toMatch[1]; } // fallback: conversation_info if (!channelId) { const metadata = e.metadata as Record | undefined; const convInfo = metadata?.conversation_info as Record | undefined; const raw = String(convInfo?.channel_id ?? metadata?.channelId ?? ""); if (/^\d+$/.test(raw)) channelId = raw; } if (!channelId) return; const mode = channelStore.getMode(channelId); // dead: suppress routing entirely (OpenClaw handles no-route automatically, // but we handle archived auto-reply here) if (mode === "report") return; // archived: auto-reply via moderator (deduped — only one agent instance should reply) if (mode === "discussion") { const rec = channelStore.getRecord(channelId); if (rec.discussion?.concluded && moderatorBotToken) { const metadata = e.metadata as Record | undefined; const convInfo = metadata?.conversation_info as Record | undefined; const incomingMsgId = String( convInfo?.message_id ?? metadata?.message_id ?? metadata?.messageId ?? e.id ?? "", ); const dedupKey = `${channelId}:${incomingMsgId}`; if (!concludedReplyDedup.has(dedupKey)) { concludedReplyDedup.add(dedupKey); if (concludedReplyDedup.size > 500) { const oldest = concludedReplyDedup.values().next().value; if (oldest) concludedReplyDedup.delete(oldest); } await sendModeratorMessage( moderatorBotToken, channelId, "This discussion is closed and no longer active.", api.logger, ).catch(() => undefined); } return; } } if (mode === "none" || mode === "work") return; // chat / discussion (active): initialize speaker list on first message if needed const initializingChannels = getInitializingChannels(); if (!hasSpeakers(channelId) && moderatorBotToken) { // Guard against concurrent initialization from multiple VM contexts if (initializingChannels.has(channelId)) { api.logger.info(`dirigent: message_received init in progress, skipping channel=${channelId}`); return; } initializingChannels.add(channelId); try { const agentIds = await fetchVisibleChannelBotAccountIds(api, channelId, identityRegistry); const speakers: SpeakerEntry[] = agentIds .map((aid) => { const entry = identityRegistry.findByAgentId(aid); return entry ? { agentId: aid, discordUserId: entry.discordUserId } : null; }) .filter((s): s is SpeakerEntry => s !== null); if (speakers.length > 0) { setSpeakerList(channelId, speakers); const first = speakers[0]; api.logger.info(`dirigent: initialized speaker list channel=${channelId} speakers=${speakers.map(s => s.agentId).join(",")}`); await sendAndDelete(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger); return; } } finally { initializingChannels.delete(channelId); } } // chat / discussion (active): check if this is an external message // that should interrupt an in-progress tail-match or wake dormant const senderId = String( (e.metadata as Record)?.senderId ?? (e.metadata as Record)?.sender_id ?? e.from ?? "", ); // Identify the sender: is it the current speaker's Discord account? const currentSpeakerIsThisSender = (() => { if (!senderId) return false; const entry = identityRegistry.findByDiscordUserId(senderId); if (!entry) return false; return isCurrentSpeaker(channelId!, entry.agentId); })(); if (!currentSpeakerIsThisSender) { // Non-current-speaker posted — interrupt any tail-match in progress interruptTailMatch(channelId); api.logger.info(`dirigent: message_received interrupt tail-match channel=${channelId} senderId=${senderId}`); // Wake from dormant if needed — but ignore the moderator bot's own messages // (e.g. idle reminder) to prevent it from immediately re-waking the channel. if (isDormant(channelId) && moderatorBotToken && senderId !== moderatorBotUserId) { const first = wakeFromDormant(channelId); if (first) { const msg = `<@${first.discordUserId}>${scheduleIdentifier}`; await sendAndDelete(moderatorBotToken, channelId, msg, api.logger); api.logger.info(`dirigent: woke dormant channel=${channelId} first speaker=${first.agentId}`); } } } } catch (err) { api.logger.warn(`dirigent: message_received hook error: ${String(err)}`); } }); }