refactor: new design — sidecar services, moderator Gateway client, tool execute API
- Replace standalone no-reply-api Docker service with unified sidecar (services/main.mjs) that routes /no-reply/* and /moderator/* and starts/stops with openclaw-gateway - Add moderator Discord Gateway client (services/moderator/index.mjs) for real-time MESSAGE_CREATE push instead of polling; notifies plugin via HTTP callback - Add plugin HTTP routes (plugin/web/dirigent-api.ts) for moderator → plugin callbacks (wake-from-dormant, interrupt tail-match) - Fix tool registration format: AgentTool requires execute: not handler:; factory form for tools needing ctx - Rename no-reply-process.ts → sidecar-process.ts, startNoReplyApi → startSideCar - Remove dead config fields from openclaw.plugin.json (humanList, agentList, listMode, channelPoliciesFile, endSymbols, waitIdentifier, multiMessage*, bypassUserIds, etc.) - Rename noReplyPort → sideCarPort - Remove docker-compose.yml, dev-up/down scripts, package-plugin.mjs, test-no-reply-api.mjs - Update install.mjs: clean dist before build, copy services/, drop dead config writes - Update README, Makefile, smoke script for new architecture Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ 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 { sendScheduleTrigger, userIdFromBotToken } from "../core/moderator-discord.js";
|
||||
import { fetchVisibleChannelBotAccountIds } from "../core/channel-members.js";
|
||||
import type { InterruptFn } from "./agent-end.js";
|
||||
|
||||
@@ -14,24 +14,17 @@ type Deps = {
|
||||
moderatorBotToken: string | undefined;
|
||||
scheduleIdentifier: string;
|
||||
interruptTailMatch: InterruptFn;
|
||||
debugMode: boolean;
|
||||
/**
|
||||
* When true, the moderator service handles wake-from-dormant and
|
||||
* interrupt-tail-match via HTTP callback. This hook only runs speaker-list
|
||||
* initialization in that case.
|
||||
*/
|
||||
moderatorHandlesMessages?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<string, unknown>)[_CONCLUDED_REPLY_DEDUP_KEY]) {
|
||||
(globalThis as Record<string, unknown>)[_CONCLUDED_REPLY_DEDUP_KEY] = new Set<string>();
|
||||
}
|
||||
const concludedReplyDedup: Set<string> = (globalThis as Record<string, unknown>)[_CONCLUDED_REPLY_DEDUP_KEY] as Set<string>;
|
||||
|
||||
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 { api, channelStore, identityRegistry, moderatorBotToken, scheduleIdentifier, interruptTailMatch, debugMode, moderatorHandlesMessages } = deps;
|
||||
const moderatorBotUserId = moderatorBotToken ? userIdFromBotToken(moderatorBotToken) : undefined;
|
||||
|
||||
api.on("message_received", async (event, ctx) => {
|
||||
@@ -42,23 +35,19 @@ export function registerMessageReceivedHook(deps: Deps): void {
|
||||
// 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;
|
||||
@@ -69,45 +58,12 @@ export function registerMessageReceivedHook(deps: Deps): void {
|
||||
|
||||
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<string, unknown> | undefined;
|
||||
const convInfo = metadata?.conversation_info as Record<string, unknown> | 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
|
||||
// ── Speaker-list initialization (always runs, even with moderator service) ──
|
||||
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;
|
||||
@@ -126,7 +82,7 @@ export function registerMessageReceivedHook(deps: Deps): void {
|
||||
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);
|
||||
await sendScheduleTrigger(moderatorBotToken, channelId, `<@${first.discordUserId}>${scheduleIdentifier}`, api.logger, debugMode);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
@@ -134,8 +90,8 @@ export function registerMessageReceivedHook(deps: Deps): void {
|
||||
}
|
||||
}
|
||||
|
||||
// chat / discussion (active): check if this is an external message
|
||||
// that should interrupt an in-progress tail-match or wake dormant
|
||||
// ── Wake / interrupt (skipped when moderator service handles it via HTTP callback) ──
|
||||
if (moderatorHandlesMessages) return;
|
||||
|
||||
const senderId = String(
|
||||
(e.metadata as Record<string, unknown>)?.senderId ??
|
||||
@@ -143,7 +99,6 @@ export function registerMessageReceivedHook(deps: Deps): void {
|
||||
e.from ?? "",
|
||||
);
|
||||
|
||||
// Identify the sender: is it the current speaker's Discord account?
|
||||
const currentSpeakerIsThisSender = (() => {
|
||||
if (!senderId) return false;
|
||||
const entry = identityRegistry.findByDiscordUserId(senderId);
|
||||
@@ -152,17 +107,16 @@ export function registerMessageReceivedHook(deps: Deps): void {
|
||||
})();
|
||||
|
||||
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}`);
|
||||
if (senderId !== moderatorBotUserId) {
|
||||
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);
|
||||
await sendScheduleTrigger(moderatorBotToken, channelId, msg, api.logger, debugMode);
|
||||
api.logger.info(`dirigent: woke dormant channel=${channelId} first speaker=${first.agentId}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user