The moderator bot's own idle reminder message triggered message_received, which saw senderId != currentSpeaker and called wakeFromDormant, immediately undoing the dormant state just entered. Fix: derive the moderator bot's Discord user ID from the token and skip wake-from-dormant when the sender is the moderator bot itself. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
147 lines
6.5 KiB
TypeScript
147 lines
6.5 KiB
TypeScript
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;
|
|
};
|
|
|
|
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<string, unknown>;
|
|
const c = ctx as Record<string, unknown>;
|
|
|
|
// 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<string, unknown> | 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<string, unknown> | undefined;
|
|
const convInfo = metadata?.conversation_info as Record<string, unknown> | 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<string, unknown>)?.senderId ??
|
|
(e.metadata as Record<string, unknown>)?.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)}`);
|
|
}
|
|
});
|
|
}
|