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:
145
plugin/index.ts
145
plugin/index.ts
@@ -4,8 +4,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { IdentityRegistry } from "./core/identity-registry.js";
|
||||
import { ChannelStore } from "./core/channel-store.js";
|
||||
import { scanPaddedCell } from "./core/padded-cell.js";
|
||||
import { startNoReplyApi, stopNoReplyApi } from "./core/no-reply-process.js";
|
||||
import { startModeratorPresence, stopModeratorPresence } from "./moderator-presence.js";
|
||||
import { startSideCar, stopSideCar } from "./core/sidecar-process.js";
|
||||
import { registerBeforeModelResolveHook } from "./hooks/before-model-resolve.js";
|
||||
import { registerAgentEndHook } from "./hooks/agent-end.js";
|
||||
import { registerMessageReceivedHook } from "./hooks/message-received.js";
|
||||
@@ -13,33 +12,44 @@ import { registerDirigentTools } from "./tools/register-tools.js";
|
||||
import { registerSetChannelModeCommand } from "./commands/set-channel-mode-command.js";
|
||||
import { registerAddGuildCommand } from "./commands/add-guild-command.js";
|
||||
import { registerControlPage } from "./web/control-page.js";
|
||||
import { sendModeratorMessage, sendAndDelete } from "./core/moderator-discord.js";
|
||||
import { setSpeakerList } from "./turn-manager.js";
|
||||
import { registerDirigentApi } from "./web/dirigent-api.js";
|
||||
import { sendModeratorMessage, sendScheduleTrigger, getBotUserIdFromToken } from "./core/moderator-discord.js";
|
||||
import { setSpeakerList, isCurrentSpeaker, isDormant, wakeFromDormant } from "./turn-manager.js";
|
||||
import { fetchVisibleChannelBotAccountIds } from "./core/channel-members.js";
|
||||
|
||||
type PluginConfig = {
|
||||
moderatorBotToken?: string;
|
||||
noReplyProvider?: string;
|
||||
noReplyModel?: string;
|
||||
noReplyPort?: number;
|
||||
scheduleIdentifier?: string;
|
||||
identityFilePath?: string;
|
||||
channelStoreFilePath?: string;
|
||||
debugMode?: boolean;
|
||||
noReplyProvider?: string;
|
||||
noReplyModel?: string;
|
||||
sideCarPort?: number;
|
||||
};
|
||||
|
||||
function normalizeConfig(api: OpenClawPluginApi): Required<PluginConfig> {
|
||||
const cfg = (api.pluginConfig ?? {}) as PluginConfig;
|
||||
return {
|
||||
moderatorBotToken: cfg.moderatorBotToken ?? "",
|
||||
noReplyProvider: cfg.noReplyProvider ?? "dirigent",
|
||||
noReplyModel: cfg.noReplyModel ?? "no-reply",
|
||||
noReplyPort: Number(cfg.noReplyPort ?? 8787),
|
||||
scheduleIdentifier: cfg.scheduleIdentifier ?? "➡️",
|
||||
identityFilePath: cfg.identityFilePath ?? path.join(os.homedir(), ".openclaw", "dirigent-identity.json"),
|
||||
channelStoreFilePath: cfg.channelStoreFilePath ?? path.join(os.homedir(), ".openclaw", "dirigent-channels.json"),
|
||||
debugMode: cfg.debugMode ?? false,
|
||||
noReplyProvider: cfg.noReplyProvider ?? "dirigent",
|
||||
noReplyModel: cfg.noReplyModel ?? "no-reply",
|
||||
sideCarPort: cfg.sideCarPort ?? 8787,
|
||||
};
|
||||
}
|
||||
|
||||
function getGatewayPort(api: OpenClawPluginApi): number {
|
||||
try {
|
||||
return ((api.config as Record<string, unknown>)?.gateway as Record<string, unknown>)?.port as number ?? 18789;
|
||||
} catch {
|
||||
return 18789;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway lifecycle events (gateway_start / gateway_stop) are global — fired once
|
||||
* when the gateway process starts/stops, not per agent session. We guard these on
|
||||
@@ -76,6 +86,10 @@ export default {
|
||||
const identityRegistry = new IdentityRegistry(config.identityFilePath);
|
||||
const channelStore = new ChannelStore(config.channelStoreFilePath);
|
||||
|
||||
const moderatorBotToken = config.moderatorBotToken || undefined;
|
||||
const moderatorBotUserId = moderatorBotToken ? getBotUserIdFromToken(moderatorBotToken) : undefined;
|
||||
const moderatorServiceUrl = `http://127.0.0.1:${config.sideCarPort}/moderator`;
|
||||
|
||||
let paddedCellDetected = false;
|
||||
|
||||
function hasPaddedCell(): boolean {
|
||||
@@ -94,24 +108,27 @@ export default {
|
||||
if (!isGatewayLifecycleRegistered()) {
|
||||
markGatewayLifecycleRegistered();
|
||||
|
||||
api.on("gateway_start", () => {
|
||||
const live = normalizeConfig(api);
|
||||
const gatewayPort = getGatewayPort(api);
|
||||
|
||||
startNoReplyApi(api.logger, pluginDir, live.noReplyPort);
|
||||
// Start unified services (no-reply API + moderator bot)
|
||||
startSideCar(
|
||||
api.logger,
|
||||
pluginDir,
|
||||
config.sideCarPort,
|
||||
moderatorBotToken,
|
||||
undefined, // pluginApiToken — gateway handles auth for plugin routes
|
||||
gatewayPort,
|
||||
config.debugMode,
|
||||
);
|
||||
|
||||
if (live.moderatorBotToken) {
|
||||
startModeratorPresence(live.moderatorBotToken, api.logger);
|
||||
api.logger.info("dirigent: moderator bot presence started");
|
||||
} else {
|
||||
api.logger.info("dirigent: moderatorBotToken not set — moderator features disabled");
|
||||
}
|
||||
if (!moderatorBotToken) {
|
||||
api.logger.info("dirigent: moderatorBotToken not set — moderator features disabled");
|
||||
}
|
||||
|
||||
tryAutoScanPaddedCell();
|
||||
});
|
||||
tryAutoScanPaddedCell();
|
||||
|
||||
api.on("gateway_stop", () => {
|
||||
stopNoReplyApi(api.logger);
|
||||
stopModeratorPresence();
|
||||
stopSideCar(api.logger);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -120,18 +137,20 @@ export default {
|
||||
api,
|
||||
channelStore,
|
||||
identityRegistry,
|
||||
moderatorBotToken: config.moderatorBotToken || undefined,
|
||||
noReplyModel: config.noReplyModel,
|
||||
noReplyProvider: config.noReplyProvider,
|
||||
moderatorBotToken,
|
||||
scheduleIdentifier: config.scheduleIdentifier,
|
||||
debugMode: config.debugMode,
|
||||
noReplyProvider: config.noReplyProvider,
|
||||
noReplyModel: config.noReplyModel,
|
||||
});
|
||||
|
||||
const interruptTailMatch = registerAgentEndHook({
|
||||
api,
|
||||
channelStore,
|
||||
identityRegistry,
|
||||
moderatorBotToken: config.moderatorBotToken || undefined,
|
||||
moderatorBotToken,
|
||||
scheduleIdentifier: config.scheduleIdentifier,
|
||||
debugMode: config.debugMode,
|
||||
onDiscussionDormant: async (channelId: string) => {
|
||||
const live = normalizeConfig(api);
|
||||
if (!live.moderatorBotToken) return;
|
||||
@@ -148,13 +167,74 @@ export default {
|
||||
},
|
||||
});
|
||||
|
||||
// Speaker-list init still handled via message_received (needs OpenClaw API for channel member lookup)
|
||||
registerMessageReceivedHook({
|
||||
api,
|
||||
channelStore,
|
||||
identityRegistry,
|
||||
moderatorBotToken: config.moderatorBotToken || undefined,
|
||||
moderatorBotToken,
|
||||
scheduleIdentifier: config.scheduleIdentifier,
|
||||
interruptTailMatch,
|
||||
debugMode: config.debugMode,
|
||||
// When moderator service is active it handles wake/interrupt via HTTP callback;
|
||||
// message_received only needs to run speaker-list initialization.
|
||||
moderatorHandlesMessages: !!moderatorBotToken,
|
||||
});
|
||||
|
||||
// ── Dirigent API (moderator service → plugin callbacks) ───────────────
|
||||
registerDirigentApi({
|
||||
api,
|
||||
channelStore,
|
||||
moderatorBotUserId,
|
||||
scheduleIdentifier: config.scheduleIdentifier,
|
||||
moderatorServiceUrl,
|
||||
moderatorServiceToken: undefined,
|
||||
debugMode: config.debugMode,
|
||||
onNewMessage: async ({ channelId, senderId }) => {
|
||||
const mode = channelStore.getMode(channelId);
|
||||
|
||||
// Modes where agents don't participate
|
||||
if (mode === "none" || mode === "work" || mode === "report") return;
|
||||
|
||||
// Skip messages from the moderator bot itself (schedule triggers, etc.)
|
||||
if (senderId === moderatorBotUserId) return;
|
||||
|
||||
// Concluded discussion: send "closed" reply via moderator service
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Identify sender — is it the current speaker?
|
||||
const senderEntry = identityRegistry.findByDiscordUserId(senderId);
|
||||
const currentSpeakerIsThisSender = senderEntry
|
||||
? isCurrentSpeaker(channelId, senderEntry.agentId)
|
||||
: false;
|
||||
|
||||
if (!currentSpeakerIsThisSender) {
|
||||
// Non-current-speaker: interrupt any ongoing tail-match poll
|
||||
interruptTailMatch(channelId);
|
||||
api.logger.info(`dirigent: moderator-callback 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}>${config.scheduleIdentifier}`;
|
||||
await sendScheduleTrigger(moderatorBotToken, channelId, msg, api.logger, config.debugMode);
|
||||
api.logger.info(`dirigent: moderator-callback woke dormant channel=${channelId} first=${first.agentId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ── Tools ──────────────────────────────────────────────────────────────
|
||||
@@ -162,9 +242,9 @@ export default {
|
||||
api,
|
||||
channelStore,
|
||||
identityRegistry,
|
||||
moderatorBotToken: config.moderatorBotToken || undefined,
|
||||
moderatorBotToken,
|
||||
scheduleIdentifier: config.scheduleIdentifier,
|
||||
onDiscussionCreate: async ({ channelId, guildId, initiatorAgentId, callbackGuildId, callbackChannelId, discussionGuide, participants }) => {
|
||||
onDiscussionCreate: async ({ channelId, initiatorAgentId, callbackGuildId, callbackChannelId, discussionGuide, participants }) => {
|
||||
const live = normalizeConfig(api);
|
||||
if (!live.moderatorBotToken) return;
|
||||
|
||||
@@ -184,11 +264,12 @@ export default {
|
||||
if (speakers.length > 0) {
|
||||
setSpeakerList(channelId, speakers);
|
||||
const first = speakers[0];
|
||||
await sendAndDelete(
|
||||
await sendScheduleTrigger(
|
||||
live.moderatorBotToken,
|
||||
channelId,
|
||||
`<@${first.discordUserId}>${live.scheduleIdentifier}`,
|
||||
api.logger,
|
||||
live.debugMode,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
},
|
||||
@@ -203,7 +284,7 @@ export default {
|
||||
api,
|
||||
channelStore,
|
||||
identityRegistry,
|
||||
moderatorBotToken: config.moderatorBotToken || undefined,
|
||||
moderatorBotToken,
|
||||
openclawDir,
|
||||
hasPaddedCell,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user