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 } 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; }; export function registerMessageReceivedHook(deps: Deps): void { const { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, interruptTailMatch } = deps; api.on("message_received", async (event, ctx) => { try { const e = event as Record; const c = ctx as Record; // Extract Discord channel ID from session key or event metadata let channelId: string | undefined; if (typeof c.sessionKey === "string") { channelId = parseDiscordChannelId(c.sessionKey); } if (!channelId) { // Try from event metadata (conversation_info channel_id field) 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 if (mode === "discussion") { const rec = channelStore.getRecord(channelId); if (rec.discussion?.concluded && moderatorBotToken) { 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 if (isDormant(channelId) && moderatorBotToken) { 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)}`); } }); }