import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { ChannelStore } from "../core/channel-store.js"; import type { IdentityRegistry } from "../core/identity-registry.js"; import { isCurrentSpeaker, setAnchor, hasSpeakers, isDormant, setSpeakerList, markTurnStarted, incrementBlockedPending, getInitializingChannels, type SpeakerEntry } from "../turn-manager.js"; import { getLatestMessageId, sendAndDelete } from "../core/moderator-discord.js"; import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js"; /** Extract Discord channel ID from sessionKey like "agent:home-developer:discord:channel:1234567890". */ export function parseDiscordChannelId(sessionKey: string): string | undefined { const m = sessionKey.match(/:discord:channel:(\d+)$/); return m?.[1]; } type Deps = { api: OpenClawPluginApi; channelStore: ChannelStore; identityRegistry: IdentityRegistry; moderatorBotToken: string | undefined; noReplyModel: string; noReplyProvider: string; scheduleIdentifier: string; }; /** * Process-level deduplication for before_model_resolve events. * Uses a WeakSet keyed on the event object — works when OpenClaw passes * the same event reference to all stacked handlers (hot-reload scenario). * Stored on globalThis so it persists across module reloads. */ const _BMR_DEDUP_KEY = "_dirigentProcessedBMREvents"; if (!(globalThis as Record)[_BMR_DEDUP_KEY]) { (globalThis as Record)[_BMR_DEDUP_KEY] = new WeakSet(); } const processedBeforeModelResolveEvents: WeakSet = (globalThis as Record)[_BMR_DEDUP_KEY] as WeakSet; export function registerBeforeModelResolveHook(deps: Deps): void { const { api, channelStore, identityRegistry, moderatorBotToken, noReplyModel, noReplyProvider, scheduleIdentifier } = deps; const NO_REPLY = { model: noReplyModel, provider: noReplyProvider, noReply: true } as const; /** Shared init lock — see turn-manager.ts getInitializingChannels(). */ const initializingChannels = getInitializingChannels(); api.on("before_model_resolve", async (event, ctx) => { // Deduplicate: if another handler instance already processed this event // object, skip. Prevents double-counting from hot-reload stacked handlers. const eventObj = event as object; if (processedBeforeModelResolveEvents.has(eventObj)) return; processedBeforeModelResolveEvents.add(eventObj); const sessionKey = ctx.sessionKey; if (!sessionKey) return; // Only handle Discord group channel sessions const channelId = parseDiscordChannelId(sessionKey); if (!channelId) return; const mode = channelStore.getMode(channelId); // dead mode: suppress all responses if (mode === "report" || mode === "dead" as string) return NO_REPLY; // disabled modes: let agents respond freely if (mode === "none" || mode === "work") return; // discussion / chat: check turn const agentId = ctx.agentId; if (!agentId) return; // If speaker list not yet loaded, initialize it now if (!hasSpeakers(channelId)) { // Only one concurrent initializer per channel (Node.js single-threaded: this is safe) if (initializingChannels.has(channelId)) { api.logger.info(`dirigent: before_model_resolve init in progress, suppressing agentId=${agentId} channel=${channelId}`); return NO_REPLY; } 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} first=${first.agentId} all=${speakers.map(s => s.agentId).join(",")}`); // If this agent is NOT the first speaker, trigger first speaker and suppress this one if (first.agentId !== agentId && moderatorBotToken) { await sendAndDelete(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger); return NO_REPLY; } // If this agent IS the first speaker, fall through to normal turn logic } else { // No registered agents visible — let everyone respond freely return; } } catch (err) { api.logger.warn(`dirigent: before_model_resolve init failed: ${String(err)}`); return; } finally { initializingChannels.delete(channelId); } } // If channel is dormant: suppress all agents if (isDormant(channelId)) return NO_REPLY; if (!isCurrentSpeaker(channelId, agentId)) { api.logger.info(`dirigent: before_model_resolve blocking non-speaker session=${sessionKey} agentId=${agentId} channel=${channelId}`); incrementBlockedPending(channelId, agentId); return NO_REPLY; } // Mark that this is a legitimate turn (guards agent_end against stale NO_REPLY completions) markTurnStarted(channelId, agentId); // Current speaker: record anchor message ID for tail-match polling if (moderatorBotToken) { try { const anchorId = await getLatestMessageId(moderatorBotToken, channelId); if (anchorId) { setAnchor(channelId, agentId, anchorId); api.logger.info(`dirigent: before_model_resolve anchor set channel=${channelId} agentId=${agentId} anchorId=${anchorId}`); } } catch (err) { api.logger.warn(`dirigent: before_model_resolve failed to get anchor: ${String(err)}`); } } // Verify agent has a known Discord user ID (needed for tail-match later) const identity = identityRegistry.findByAgentId(agentId); if (!identity) { api.logger.warn(`dirigent: before_model_resolve no identity for agentId=${agentId} — proceeding without tail-match capability`); } }); }