feat: rewrite plugin as v2 with globalThis-based turn management

Complete rewrite of the Dirigent plugin turn management system to work
correctly with OpenClaw's VM-context-per-session architecture:

- All turn state stored on globalThis (persists across VM context hot-reloads)
- Hooks registered unconditionally on every api instance; event-level dedup
  (runId Set for agent_end, WeakSet for before_model_resolve) prevents
  double-processing
- Gateway lifecycle events (gateway_start/stop) guarded once via globalThis flag
- Shared initializingChannels lock prevents concurrent channel init across VM
  contexts in message_received and before_model_resolve
- New ChannelStore and IdentityRegistry replace old policy/session-state modules
- Added agent_end hook with tail-match polling for Discord delivery confirmation
- Added web control page, padded-cell auto-scan, discussion tool support
- Removed obsolete v1 modules: channel-resolver, channel-modes, discussion-service,
  session-state, turn-bootstrap, policy/store, rules, decision-input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
h z
2026-04-08 22:41:25 +01:00
parent dea345698b
commit b5196e972c
40 changed files with 2427 additions and 2753 deletions

View File

@@ -1,5 +1,5 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { buildUserIdToAccountIdMap } from "./identity.js";
import type { IdentityRegistry } from "./identity-registry.js";
const PERM_VIEW_CHANNEL = 1n << 10n;
const PERM_ADMINISTRATOR = 1n << 3n;
@@ -84,7 +84,14 @@ function canViewChannel(member: any, guildId: string, guildRoles: Map<string, bi
return (perms & PERM_VIEW_CHANNEL) !== 0n;
}
function getAnyDiscordToken(api: OpenClawPluginApi): string | undefined {
function getDiscoveryToken(api: OpenClawPluginApi): string | undefined {
// Prefer moderator bot token from pluginConfig — it has guild member access
const pluginCfg = (api.pluginConfig as Record<string, unknown>) || {};
const moderatorToken = pluginCfg.moderatorBotToken;
if (typeof moderatorToken === "string" && moderatorToken) {
return moderatorToken;
}
// Fall back to any discord account token
const root = (api.config as Record<string, unknown>) || {};
const channels = (root.channels as Record<string, unknown>) || {};
const discord = (channels.discord as Record<string, unknown>) || {};
@@ -95,8 +102,15 @@ function getAnyDiscordToken(api: OpenClawPluginApi): string | undefined {
return undefined;
}
export async function fetchVisibleChannelBotAccountIds(api: OpenClawPluginApi, channelId: string): Promise<string[]> {
const token = getAnyDiscordToken(api);
/**
* Returns agentIds for all agents visible in the channel, resolved via the identity registry.
*/
export async function fetchVisibleChannelBotAccountIds(
api: OpenClawPluginApi,
channelId: string,
identityRegistry?: IdentityRegistry,
): Promise<string[]> {
const token = getDiscoveryToken(api);
if (!token) return [];
const ch = await discordRequest(token, "GET", `/channels/${channelId}`);
@@ -131,11 +145,13 @@ export async function fetchVisibleChannelBotAccountIds(api: OpenClawPluginApi, c
.map((m) => String(m?.user?.id || ""))
.filter(Boolean);
const userToAccount = buildUserIdToAccountIdMap(api);
const out = new Set<string>();
for (const uid of visibleUserIds) {
const aid = userToAccount.get(uid);
if (aid) out.add(aid);
if (identityRegistry) {
const discordToAgent = identityRegistry.buildDiscordToAgentMap();
for (const uid of visibleUserIds) {
const aid = discordToAgent.get(uid);
if (aid) out.add(aid);
}
}
return [...out];
}

View File

@@ -1,43 +0,0 @@
const channelStates = new Map();
export function getChannelState(channelId) {
if (!channelStates.has(channelId)) {
channelStates.set(channelId, {
mode: "normal",
shuffling: false,
});
}
return channelStates.get(channelId);
}
export function enterMultiMessageMode(channelId) {
const state = getChannelState(channelId);
state.mode = "multi-message";
channelStates.set(channelId, state);
}
export function exitMultiMessageMode(channelId) {
const state = getChannelState(channelId);
state.mode = "normal";
channelStates.set(channelId, state);
}
export function isMultiMessageMode(channelId) {
return getChannelState(channelId).mode === "multi-message";
}
export function setChannelShuffling(channelId, enabled) {
const state = getChannelState(channelId);
state.shuffling = enabled;
channelStates.set(channelId, state);
}
export function getChannelShuffling(channelId) {
return getChannelState(channelId).shuffling;
}
export function markLastShuffled(channelId) {
const state = getChannelState(channelId);
state.lastShuffledAt = Date.now();
channelStates.set(channelId, state);
}

View File

@@ -1,48 +0,0 @@
import type { ChannelRuntimeMode, ChannelRuntimeState } from "../rules.js";
export type ChannelMode = ChannelRuntimeMode;
export type ChannelModesState = ChannelRuntimeState;
const channelStates = new Map<string, ChannelModesState>();
export function getChannelState(channelId: string): ChannelModesState {
if (!channelStates.has(channelId)) {
channelStates.set(channelId, {
mode: "normal",
shuffling: false,
});
}
return channelStates.get(channelId)!;
}
export function enterMultiMessageMode(channelId: string): void {
const state = getChannelState(channelId);
state.mode = "multi-message";
channelStates.set(channelId, state);
}
export function exitMultiMessageMode(channelId: string): void {
const state = getChannelState(channelId);
state.mode = "normal";
channelStates.set(channelId, state);
}
export function isMultiMessageMode(channelId: string): boolean {
return getChannelState(channelId).mode === "multi-message";
}
export function setChannelShuffling(channelId: string, enabled: boolean): void {
const state = getChannelState(channelId);
state.shuffling = enabled;
channelStates.set(channelId, state);
}
export function getChannelShuffling(channelId: string): boolean {
return getChannelState(channelId).shuffling;
}
export function markLastShuffled(channelId: string): void {
const state = getChannelState(channelId);
state.lastShuffledAt = Date.now();
channelStates.set(channelId, state);
}

View File

@@ -0,0 +1,136 @@
import fs from "node:fs";
import path from "node:path";
export type ChannelMode = "none" | "work" | "report" | "discussion" | "chat";
export type TurnManagerState = "disabled" | "dead" | "normal" | "shuffle" | "archived";
/** Modes that cannot be changed once set. */
const LOCKED_MODES = new Set<ChannelMode>(["work", "discussion"]);
/** Derive turn-manager state from mode + agent count. */
export function deriveTurnManagerState(mode: ChannelMode, agentCount: number, concluded = false): TurnManagerState {
if (mode === "none" || mode === "work") return "disabled";
if (mode === "report") return "dead";
if (mode === "discussion") {
if (concluded) return "archived";
if (agentCount <= 1) return "disabled";
if (agentCount === 2) return "normal";
return "shuffle";
}
if (mode === "chat") {
if (agentCount <= 1) return "disabled";
if (agentCount === 2) return "normal";
return "shuffle";
}
return "disabled";
}
export type DiscussionMeta = {
initiatorAgentId: string;
callbackGuildId: string;
callbackChannelId: string;
concluded: boolean;
};
export type ChannelRecord = {
mode: ChannelMode;
/** For discussion channels: metadata about the discussion. */
discussion?: DiscussionMeta;
};
export class ChannelStore {
private filePath: string;
private records: Record<string, ChannelRecord> = {};
private loaded = false;
constructor(filePath: string) {
this.filePath = filePath;
}
private load(): void {
if (this.loaded) return;
this.loaded = true;
if (!fs.existsSync(this.filePath)) {
this.records = {};
return;
}
try {
const raw = fs.readFileSync(this.filePath, "utf8");
this.records = JSON.parse(raw) ?? {};
} catch {
this.records = {};
}
}
private save(): void {
const dir = path.dirname(this.filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(this.filePath, JSON.stringify(this.records, null, 2), "utf8");
}
getMode(channelId: string): ChannelMode {
this.load();
return this.records[channelId]?.mode ?? "none";
}
getRecord(channelId: string): ChannelRecord {
this.load();
return this.records[channelId] ?? { mode: "none" };
}
/**
* Set channel mode. Throws if the channel is currently in a locked mode,
* or if the requested mode is locked (must use setLockedMode instead).
*/
setMode(channelId: string, mode: ChannelMode): void {
this.load();
const current = this.records[channelId]?.mode ?? "none";
if (LOCKED_MODES.has(current)) {
throw new Error(`Channel ${channelId} is in locked mode "${current}" and cannot be changed.`);
}
if (LOCKED_MODES.has(mode)) {
throw new Error(`Mode "${mode}" can only be set at channel creation via the dedicated tool.`);
}
this.records[channelId] = { ...this.records[channelId], mode };
this.save();
}
/**
* Set a locked mode (work or discussion). Only callable from creation tools.
* Throws if the channel already has any mode set.
*/
setLockedMode(channelId: string, mode: ChannelMode, discussion?: DiscussionMeta): void {
this.load();
if (this.records[channelId]) {
throw new Error(`Channel ${channelId} already has mode "${this.records[channelId].mode}".`);
}
const record: ChannelRecord = { mode };
if (discussion) record.discussion = discussion;
this.records[channelId] = record;
this.save();
}
/** Mark a discussion as concluded (sets archived state). */
concludeDiscussion(channelId: string): void {
this.load();
const rec = this.records[channelId];
if (!rec || rec.mode !== "discussion") {
throw new Error(`Channel ${channelId} is not a discussion channel.`);
}
if (!rec.discussion) {
throw new Error(`Channel ${channelId} has no discussion metadata.`);
}
rec.discussion = { ...rec.discussion, concluded: true };
this.save();
}
isLocked(channelId: string): boolean {
this.load();
return LOCKED_MODES.has(this.records[channelId]?.mode ?? "none");
}
listAll(): Array<{ channelId: string } & ChannelRecord> {
this.load();
return Object.entries(this.records).map(([channelId, rec]) => ({ channelId, ...rec }));
}
}

View File

@@ -1 +0,0 @@
export * from './discussion-messages.ts';

View File

@@ -1,77 +0,0 @@
export function buildDiscussionKickoffMessage(discussGuide: string): string {
return [
"[Discussion Started]",
"",
"This channel was created for a temporary agent discussion.",
"",
"Goal:",
discussGuide,
"",
"Instructions:",
"1. Discuss only the topic above.",
"2. Work toward a concrete conclusion.",
"3. When the initiator decides the goal has been achieved, the initiator must:",
" - write a summary document to a file",
" - call the tool: discuss-callback(summaryPath)",
" - provide the summary document path",
"",
"Completion rule:",
"Only the discussion initiator may finish this discussion.",
"",
"After callback:",
"- this channel will be closed",
"- further discussion messages will be ignored",
"- this channel will remain only for archive/reference",
"- the original work channel will be notified with the summary file path",
].join("\n");
}
export function buildDiscussionIdleReminderMessage(): string {
return [
"[Discussion Idle]",
"",
"No agent responded in the latest discussion round.",
"If the discussion goal has already been achieved, the initiator should now:",
"1. write the discussion summary to a file in the workspace",
"2. call discuss-callback with the summary file path",
"",
"This reminder does not mean the discussion was automatically summarized or closed.",
"If more discussion is still needed, continue the discussion in this channel.",
].join("\n");
}
export function buildDiscussionClosedMessage(): string {
return [
"[Channel Closed]",
"",
"This discussion channel has been closed.",
"It is now kept for archive/reference only.",
"Further discussion in this channel is ignored.",
"If follow-up work is needed, continue it from the origin work channel instead.",
].join("\n");
}
const DISCUSSION_RESULT_READY_HEADER = "[Discussion Result Ready]";
export function buildDiscussionOriginCallbackMessage(summaryPath: string, discussionChannelId: string): string {
return [
DISCUSSION_RESULT_READY_HEADER,
"",
"A temporary discussion has completed.",
"",
"Summary file:",
summaryPath,
"",
"Source discussion channel:",
`<#${discussionChannelId}>`,
"",
"Status:",
"completed",
"",
"Continue the original task using the summary file above.",
].join("\n");
}
export function isDiscussionOriginCallbackMessage(content: string): boolean {
return content.includes(DISCUSSION_RESULT_READY_HEADER);
}

View File

@@ -1,166 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { closeDiscussion, createDiscussion, getDiscussion, isDiscussionClosed, markDiscussionIdleReminderSent, type DiscussionMetadata } from "./discussion-state.js";
import {
buildDiscussionClosedMessage,
buildDiscussionIdleReminderMessage,
buildDiscussionKickoffMessage,
buildDiscussionOriginCallbackMessage,
} from "./discussion-messages.js";
import { sendModeratorMessage } from "./moderator-discord.js";
type DiscussionServiceDeps = {
api: OpenClawPluginApi;
moderatorBotToken?: string;
moderatorUserId?: string;
workspaceRoot?: string;
forceNoReplyForSession: (sessionKey: string) => void;
getDiscussionSessionKeys?: (channelId: string) => string[];
};
export function createDiscussionService(deps: DiscussionServiceDeps) {
const defaultWorkspaceRoot = path.resolve(deps.workspaceRoot || process.cwd());
async function initDiscussion(params: {
discussionChannelId: string;
originChannelId: string;
initiatorAgentId: string;
initiatorSessionId: string;
initiatorWorkspaceRoot?: string;
discussGuide: string;
}): Promise<DiscussionMetadata> {
const metadata = createDiscussion({
mode: "discussion",
discussionChannelId: params.discussionChannelId,
originChannelId: params.originChannelId,
initiatorAgentId: params.initiatorAgentId,
initiatorSessionId: params.initiatorSessionId,
initiatorWorkspaceRoot: params.initiatorWorkspaceRoot,
discussGuide: params.discussGuide,
status: "active",
createdAt: new Date().toISOString(),
});
if (deps.moderatorBotToken) {
const result = await sendModeratorMessage(
deps.moderatorBotToken,
params.discussionChannelId,
buildDiscussionKickoffMessage(params.discussGuide),
deps.api.logger,
);
if (!result.ok) {
deps.api.logger.warn(`dirigent: discussion kickoff message failed channel=${params.discussionChannelId} error=${result.error}`);
}
}
return metadata;
}
async function maybeSendIdleReminder(channelId: string): Promise<void> {
const metadata = getDiscussion(channelId);
if (!metadata || metadata.status !== "active" || metadata.idleReminderSent) return;
markDiscussionIdleReminderSent(channelId);
if (deps.moderatorBotToken) {
const result = await sendModeratorMessage(
deps.moderatorBotToken,
channelId,
buildDiscussionIdleReminderMessage(),
deps.api.logger,
);
if (!result.ok) {
deps.api.logger.warn(`dirigent: discussion idle reminder failed channel=${channelId} error=${result.error}`);
}
}
}
function validateSummaryPath(summaryPath: string, workspaceRoot?: string): string {
if (!summaryPath || !summaryPath.trim()) throw new Error("summaryPath is required");
const effectiveWorkspaceRoot = path.resolve(workspaceRoot || defaultWorkspaceRoot);
const resolved = path.resolve(effectiveWorkspaceRoot, summaryPath);
const relative = path.relative(effectiveWorkspaceRoot, resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error("summaryPath must stay inside the initiator workspace");
}
const real = fs.realpathSync.native(resolved);
const realWorkspace = fs.realpathSync.native(effectiveWorkspaceRoot);
const realRelative = path.relative(realWorkspace, real);
if (realRelative.startsWith("..") || path.isAbsolute(realRelative)) {
throw new Error("summaryPath resolves outside the initiator workspace");
}
const stat = fs.statSync(real);
if (!stat.isFile()) throw new Error("summaryPath must point to a file");
return real;
}
async function handleCallback(params: {
channelId: string;
summaryPath: string;
callerAgentId?: string;
callerSessionKey?: string;
}): Promise<{ ok: true; summaryPath: string; discussion: DiscussionMetadata }> {
const metadata = getDiscussion(params.channelId);
if (!metadata) throw new Error("current channel is not a discussion channel");
if (metadata.status !== "active" || isDiscussionClosed(params.channelId)) throw new Error("discussion is already closed");
if (!params.callerSessionKey || params.callerSessionKey !== metadata.initiatorSessionId) {
throw new Error("only the discussion initiator session may call discuss-callback");
}
if (params.callerAgentId && params.callerAgentId !== metadata.initiatorAgentId) {
throw new Error("only the discussion initiator agent may call discuss-callback");
}
const realPath = validateSummaryPath(params.summaryPath, metadata.initiatorWorkspaceRoot);
const closed = closeDiscussion(params.channelId, realPath);
if (!closed) throw new Error("failed to close discussion");
const discussionSessionKeys = new Set<string>([
metadata.initiatorSessionId,
...(deps.getDiscussionSessionKeys?.(metadata.discussionChannelId) || []),
]);
for (const sessionKey of discussionSessionKeys) {
if (sessionKey) deps.forceNoReplyForSession(sessionKey);
}
if (deps.moderatorBotToken) {
const result = await sendModeratorMessage(
deps.moderatorBotToken,
metadata.originChannelId,
buildDiscussionOriginCallbackMessage(realPath, metadata.discussionChannelId),
deps.api.logger,
);
if (!result.ok) {
deps.api.logger.warn(
`dirigent: discussion origin callback notification failed originChannel=${metadata.originChannelId} error=${result.error}`,
);
}
}
return { ok: true, summaryPath: realPath, discussion: closed };
}
async function maybeReplyClosedChannel(channelId: string, senderId?: string): Promise<boolean> {
const metadata = getDiscussion(channelId);
if (!metadata || metadata.status !== "closed") return false;
if (deps.moderatorUserId && senderId && senderId === deps.moderatorUserId) return true;
if (!deps.moderatorBotToken) return true;
const result = await sendModeratorMessage(deps.moderatorBotToken, channelId, buildDiscussionClosedMessage(), deps.api.logger);
if (!result.ok) {
deps.api.logger.warn(`dirigent: discussion closed reply failed channel=${channelId} error=${result.error}`);
}
return true;
}
return {
initDiscussion,
getDiscussion,
isClosedDiscussion(channelId: string): boolean {
return isDiscussionClosed(channelId);
},
maybeSendIdleReminder,
maybeReplyClosedChannel,
handleCallback,
};
}

View File

@@ -1 +0,0 @@
export * from './discussion-state.ts';

View File

@@ -1,56 +0,0 @@
export type DiscussionStatus = "active" | "closed";
export type DiscussionMetadata = {
mode: "discussion";
discussionChannelId: string;
originChannelId: string;
initiatorAgentId: string;
initiatorSessionId: string;
initiatorWorkspaceRoot?: string;
discussGuide: string;
status: DiscussionStatus;
createdAt: string;
completedAt?: string;
summaryPath?: string;
idleReminderSent?: boolean;
};
const discussionByChannelId = new Map<string, DiscussionMetadata>();
export function createDiscussion(metadata: DiscussionMetadata): DiscussionMetadata {
discussionByChannelId.set(metadata.discussionChannelId, metadata);
return metadata;
}
export function getDiscussion(channelId: string): DiscussionMetadata | undefined {
return discussionByChannelId.get(channelId);
}
export function isDiscussionChannel(channelId: string): boolean {
return discussionByChannelId.has(channelId);
}
export function isDiscussionClosed(channelId: string): boolean {
return discussionByChannelId.get(channelId)?.status === "closed";
}
export function markDiscussionIdleReminderSent(channelId: string): void {
const rec = discussionByChannelId.get(channelId);
if (!rec) return;
rec.idleReminderSent = true;
}
export function clearDiscussionIdleReminderSent(channelId: string): void {
const rec = discussionByChannelId.get(channelId);
if (!rec) return;
rec.idleReminderSent = false;
}
export function closeDiscussion(channelId: string, summaryPath: string): DiscussionMetadata | undefined {
const rec = discussionByChannelId.get(channelId);
if (!rec) return undefined;
rec.status = "closed";
rec.summaryPath = summaryPath;
rec.completedAt = new Date().toISOString();
return rec;
}

View File

@@ -0,0 +1,93 @@
import fs from "node:fs";
import path from "node:path";
export type IdentityEntry = {
discordUserId: string;
agentId: string;
agentName: string;
};
export class IdentityRegistry {
private filePath: string;
private entries: IdentityEntry[] = [];
private loaded = false;
constructor(filePath: string) {
this.filePath = filePath;
}
private load(): void {
if (this.loaded) return;
this.loaded = true;
if (!fs.existsSync(this.filePath)) {
this.entries = [];
return;
}
try {
const raw = fs.readFileSync(this.filePath, "utf8");
const parsed = JSON.parse(raw);
this.entries = Array.isArray(parsed) ? parsed : [];
} catch {
this.entries = [];
}
}
private save(): void {
const dir = path.dirname(this.filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(this.filePath, JSON.stringify(this.entries, null, 2), "utf8");
}
upsert(entry: IdentityEntry): void {
this.load();
const idx = this.entries.findIndex((e) => e.agentId === entry.agentId);
if (idx >= 0) {
this.entries[idx] = entry;
} else {
this.entries.push(entry);
}
this.save();
}
remove(agentId: string): boolean {
this.load();
const before = this.entries.length;
this.entries = this.entries.filter((e) => e.agentId !== agentId);
if (this.entries.length !== before) {
this.save();
return true;
}
return false;
}
findByAgentId(agentId: string): IdentityEntry | undefined {
this.load();
return this.entries.find((e) => e.agentId === agentId);
}
findByDiscordUserId(discordUserId: string): IdentityEntry | undefined {
this.load();
return this.entries.find((e) => e.discordUserId === discordUserId);
}
list(): IdentityEntry[] {
this.load();
return [...this.entries];
}
/** Build a map from discordUserId → agentId for fast lookup. */
buildDiscordToAgentMap(): Map<string, string> {
this.load();
const map = new Map<string, string>();
for (const e of this.entries) map.set(e.discordUserId, e.agentId);
return map;
}
/** Build a map from agentId → discordUserId for fast lookup. */
buildAgentToDiscordMap(): Map<string, string> {
this.load();
const map = new Map<string, string>();
for (const e of this.entries) map.set(e.agentId, e.discordUserId);
return map;
}
}

View File

@@ -1,79 +0,0 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
function userIdFromToken(token: string): string | undefined {
try {
const segment = token.split(".")[0];
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
return Buffer.from(padded, "base64").toString("utf8");
} catch {
return undefined;
}
}
function resolveDiscordUserIdFromAccount(api: OpenClawPluginApi, accountId: string): string | undefined {
const root = (api.config as Record<string, unknown>) || {};
const channels = (root.channels as Record<string, unknown>) || {};
const discord = (channels.discord as Record<string, unknown>) || {};
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
const acct = accounts[accountId];
if (!acct?.token || typeof acct.token !== "string") return undefined;
return userIdFromToken(acct.token);
}
export function resolveAccountId(api: OpenClawPluginApi, agentId: string): string | undefined {
const root = (api.config as Record<string, unknown>) || {};
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(bindings)) return undefined;
for (const b of bindings) {
if (b.agentId === agentId) {
const match = b.match as Record<string, unknown> | undefined;
if (match?.channel === "discord" && typeof match.accountId === "string") {
return match.accountId;
}
}
}
return undefined;
}
export function buildAgentIdentity(api: OpenClawPluginApi, agentId: string): string | undefined {
const root = (api.config as Record<string, unknown>) || {};
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
const agents = ((root.agents as Record<string, unknown>)?.list as Array<Record<string, unknown>>) || [];
if (!Array.isArray(bindings)) return undefined;
let accountId: string | undefined;
for (const b of bindings) {
if (b.agentId === agentId) {
const match = b.match as Record<string, unknown> | undefined;
if (match?.channel === "discord" && typeof match.accountId === "string") {
accountId = match.accountId;
break;
}
}
}
if (!accountId) return undefined;
const agent = agents.find((a: Record<string, unknown>) => a.id === agentId);
const name = (agent?.name as string) || agentId;
const discordUserId = resolveDiscordUserIdFromAccount(api, accountId);
let identity = `You are ${name} (Discord account: ${accountId}`;
if (discordUserId) identity += `, Discord userId: ${discordUserId}`;
identity += `).`;
return identity;
}
export function buildUserIdToAccountIdMap(api: OpenClawPluginApi): Map<string, string> {
const root = (api.config as Record<string, unknown>) || {};
const channels = (root.channels as Record<string, unknown>) || {};
const discord = (channels.discord as Record<string, unknown>) || {};
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
const map = new Map<string, string>();
for (const [accountId, acct] of Object.entries(accounts)) {
if (typeof acct.token === "string") {
const userId = userIdFromToken(acct.token);
if (userId) map.set(userId, accountId);
}
}
return map;
}

View File

@@ -1,5 +1,3 @@
import type { DirigentConfig } from "../rules.js";
function userIdFromToken(token: string): string | undefined {
try {
const segment = token.split(".")[0];
@@ -25,7 +23,7 @@ export function extractMentionedUserIds(content: string): string[] {
return ids;
}
export function getModeratorUserId(config: DirigentConfig): string | undefined {
if (!config.moderatorBotToken) return undefined;
return userIdFromToken(config.moderatorBotToken);
export function getModeratorUserIdFromToken(token: string | undefined): string | undefined {
if (!token) return undefined;
return userIdFromToken(token);
}

View File

@@ -1 +0,0 @@
export * from './moderator-discord.ts';

View File

@@ -1,5 +1,7 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
type Logger = { info: (m: string) => void; warn: (m: string) => void };
function userIdFromToken(token: string): string | undefined {
try {
const segment = token.split(".")[0];
@@ -28,7 +30,7 @@ export async function sendModeratorMessage(
token: string,
channelId: string,
content: string,
logger: { info: (msg: string) => void; warn: (msg: string) => void },
logger: Logger,
): Promise<ModeratorMessageResult> {
try {
const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
@@ -63,3 +65,175 @@ export async function sendModeratorMessage(
return { ok: false, channelId, error };
}
}
/** Delete a Discord message. */
export async function deleteMessage(
token: string,
channelId: string,
messageId: string,
logger: Logger,
): Promise<void> {
try {
const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages/${messageId}`, {
method: "DELETE",
headers: { Authorization: `Bot ${token}` },
});
if (!r.ok) {
logger.warn(`dirigent: deleteMessage failed channel=${channelId} msg=${messageId} status=${r.status}`);
}
} catch (err) {
logger.warn(`dirigent: deleteMessage error: ${String(err)}`);
}
}
/** Send a message then immediately delete it (used for schedule_identifier trigger). */
export async function sendAndDelete(
token: string,
channelId: string,
content: string,
logger: Logger,
): Promise<void> {
const result = await sendModeratorMessage(token, channelId, content, logger);
if (result.ok && result.messageId) {
// Small delay to ensure Discord has processed the message before deletion
await new Promise((r) => setTimeout(r, 300));
await deleteMessage(token, channelId, result.messageId, logger);
}
}
/** Get the latest message ID in a channel (for use as poll anchor). */
export async function getLatestMessageId(
token: string,
channelId: string,
): Promise<string | undefined> {
try {
const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages?limit=1`, {
headers: { Authorization: `Bot ${token}` },
});
if (!r.ok) return undefined;
const msgs = (await r.json()) as Array<{ id: string }>;
return msgs[0]?.id;
} catch {
return undefined;
}
}
type DiscordMessage = { id: string; author: { id: string }; content: string };
/**
* Poll the channel until a message from agentDiscordUserId with id > anchorId
* ends with the tail fingerprint.
*
* @returns the matching messageId, or undefined on timeout.
*/
export async function pollForTailMatch(opts: {
token: string;
channelId: string;
anchorId: string;
agentDiscordUserId: string;
tailFingerprint: string;
timeoutMs?: number;
pollIntervalMs?: number;
/** Callback checked each poll; if true, polling is aborted (interrupted). */
isInterrupted?: () => boolean;
}): Promise<{ matched: boolean; interrupted: boolean }> {
const {
token, channelId, anchorId, agentDiscordUserId,
tailFingerprint, timeoutMs = 15000, pollIntervalMs = 800,
isInterrupted = () => false,
} = opts;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (isInterrupted()) return { matched: false, interrupted: true };
try {
const r = await fetch(
`https://discord.com/api/v10/channels/${channelId}/messages?limit=20`,
{ headers: { Authorization: `Bot ${token}` } },
);
if (r.ok) {
const msgs = (await r.json()) as DiscordMessage[];
const candidates = msgs.filter(
(m) => m.author.id === agentDiscordUserId && BigInt(m.id) > BigInt(anchorId),
);
if (candidates.length > 0) {
// Most recent is first in Discord's response
const latest = candidates[0];
if (latest.content.endsWith(tailFingerprint)) {
return { matched: true, interrupted: false };
}
}
}
} catch {
// ignore transient errors, keep polling
}
await new Promise((r) => setTimeout(r, pollIntervalMs));
}
return { matched: false, interrupted: false };
}
/** Create a Discord channel in a guild. Returns the new channel ID or throws. */
export async function createDiscordChannel(opts: {
token: string;
guildId: string;
name: string;
/** Permission overwrites: [{id, type (0=role/1=member), allow, deny}] */
permissionOverwrites?: Array<{ id: string; type: number; allow?: string; deny?: string }>;
logger: Logger;
}): Promise<string> {
const { token, guildId, name, permissionOverwrites = [], logger } = opts;
const r = await fetch(`https://discord.com/api/v10/guilds/${guildId}/channels`, {
method: "POST",
headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ name, type: 0, permission_overwrites: permissionOverwrites }),
});
const json = (await r.json()) as Record<string, unknown>;
if (!r.ok) {
const err = `Discord channel create failed (${r.status}): ${JSON.stringify(json)}`;
logger.warn(`dirigent: ${err}`);
throw new Error(err);
}
const channelId = json.id as string;
logger.info(`dirigent: created Discord channel ${name} id=${channelId} in guild=${guildId}`);
return channelId;
}
/** Fetch guilds where the moderator bot has admin permissions. */
export async function fetchAdminGuilds(token: string): Promise<Array<{ id: string; name: string }>> {
const r = await fetch("https://discord.com/api/v10/users/@me/guilds", {
headers: { Authorization: `Bot ${token}` },
});
if (!r.ok) return [];
const guilds = (await r.json()) as Array<{ id: string; name: string; permissions: string }>;
const ADMIN = 8n;
return guilds.filter((g) => (BigInt(g.permissions ?? "0") & ADMIN) === ADMIN);
}
/** Fetch private group channels in a guild visible to the moderator bot. */
export async function fetchGuildChannels(
token: string,
guildId: string,
): Promise<Array<{ id: string; name: string; type: number }>> {
const r = await fetch(`https://discord.com/api/v10/guilds/${guildId}/channels`, {
headers: { Authorization: `Bot ${token}` },
});
if (!r.ok) return [];
const channels = (await r.json()) as Array<{ id: string; name: string; type: number }>;
// type 0 = GUILD_TEXT; filter to text channels only (group private channels are type 0)
return channels.filter((c) => c.type === 0);
}
/** Get bot's own Discord user ID from token. */
export function getBotUserIdFromToken(token: string): string | undefined {
try {
const segment = token.split(".")[0];
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
return Buffer.from(padded, "base64").toString("utf8");
} catch {
return undefined;
}
}

View File

@@ -0,0 +1,59 @@
import fs from "node:fs";
import path from "node:path";
import type { IdentityRegistry } from "./identity-registry.js";
type EgoData = {
columns?: string[];
agentScope?: Record<string, Record<string, string>>;
};
/**
* Scan padded-cell's ego.json and upsert agent Discord IDs into the identity registry.
* Only runs if ego.json contains the "discord-id" column — otherwise treated as absent.
*
* @returns number of entries upserted, or -1 if padded-cell is not detected.
*/
export function scanPaddedCell(
registry: IdentityRegistry,
openclawDir: string,
logger: { info: (m: string) => void; warn: (m: string) => void },
): number {
const egoPath = path.join(openclawDir, "ego.json");
if (!fs.existsSync(egoPath)) {
logger.info("dirigent: padded-cell ego.json not found — skipping auto-registration");
return -1;
}
let ego: EgoData;
try {
ego = JSON.parse(fs.readFileSync(egoPath, "utf8"));
} catch (e) {
logger.warn(`dirigent: failed to parse ego.json: ${String(e)}`);
return -1;
}
if (!Array.isArray(ego.columns) || !ego.columns.includes("discord-id")) {
logger.info('dirigent: ego.json does not have "discord-id" column — padded-cell not configured for Discord, skipping');
return -1;
}
const agentScope = ego.agentScope ?? {};
let count = 0;
for (const [agentId, fields] of Object.entries(agentScope)) {
const discordUserId = fields["discord-id"];
if (!discordUserId || typeof discordUserId !== "string") continue;
const existing = registry.findByAgentId(agentId);
registry.upsert({
agentId,
discordUserId,
agentName: existing?.agentName ?? agentId,
});
count++;
}
logger.info(`dirigent: padded-cell scan complete — upserted ${count} identity entries`);
return count;
}

View File

@@ -1,44 +0,0 @@
import type { Decision } from "../rules.js";
export type DecisionRecord = {
decision: Decision;
createdAt: number;
needsRestore?: boolean;
};
export const MAX_SESSION_DECISIONS = 2000;
export const DECISION_TTL_MS = 5 * 60 * 1000;
export const sessionDecision = new Map<string, DecisionRecord>();
export const sessionAllowed = new Map<string, boolean>();
export const sessionInjected = new Set<string>();
export const sessionChannelId = new Map<string, string>();
export const sessionAccountId = new Map<string, string>();
export const sessionTurnHandled = new Set<string>();
export const forceNoReplySessions = new Set<string>();
export const discussionChannelSessions = new Map<string, Set<string>>();
export function recordDiscussionSession(channelId: string, sessionKey: string): void {
if (!channelId || !sessionKey) return;
const current = discussionChannelSessions.get(channelId) || new Set<string>();
current.add(sessionKey);
discussionChannelSessions.set(channelId, current);
}
export function getDiscussionSessionKeys(channelId: string): string[] {
return [...(discussionChannelSessions.get(channelId) || new Set<string>())];
}
export function pruneDecisionMap(now = Date.now()): void {
for (const [k, v] of sessionDecision.entries()) {
if (now - v.createdAt > DECISION_TTL_MS) sessionDecision.delete(k);
}
if (sessionDecision.size <= MAX_SESSION_DECISIONS) return;
const keys = sessionDecision.keys();
while (sessionDecision.size > MAX_SESSION_DECISIONS) {
const k = keys.next();
if (k.done) break;
sessionDecision.delete(k.value);
}
}

View File

@@ -1,127 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { initTurnOrder } from "../turn-manager.js";
import { fetchVisibleChannelBotAccountIds } from "./channel-members.js";
const channelSeenAccounts = new Map<string, Set<string>>();
const channelBootstrapTried = new Set<string>();
let cacheLoaded = false;
function cachePath(api: OpenClawPluginApi): string {
return api.resolvePath("~/.openclaw/dirigent-channel-members.json");
}
function loadCache(api: OpenClawPluginApi): void {
if (cacheLoaded) return;
cacheLoaded = true;
const p = cachePath(api);
try {
if (!fs.existsSync(p)) return;
const raw = fs.readFileSync(p, "utf8");
const parsed = JSON.parse(raw) as Record<string, { botAccountIds?: string[]; source?: string; guildId?: string; updatedAt?: string }>;
for (const [channelId, rec] of Object.entries(parsed || {})) {
const ids = Array.isArray(rec?.botAccountIds) ? rec.botAccountIds.filter(Boolean) : [];
if (ids.length > 0) channelSeenAccounts.set(channelId, new Set(ids));
}
} catch (err) {
api.logger.warn?.(`dirigent: failed loading channel member cache: ${String(err)}`);
}
}
function inferGuildIdFromChannelId(api: OpenClawPluginApi, channelId: string): string | undefined {
const root = (api.config as Record<string, unknown>) || {};
const channels = (root.channels as Record<string, unknown>) || {};
const discord = (channels.discord as Record<string, unknown>) || {};
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
for (const rec of Object.values(accounts)) {
const chMap = (rec?.channels as Record<string, Record<string, unknown>> | undefined) || undefined;
if (!chMap) continue;
const direct = chMap[channelId];
const prefixed = chMap[`channel:${channelId}`];
const found = (direct || prefixed) as Record<string, unknown> | undefined;
if (found && typeof found.guildId === "string" && found.guildId.trim()) return found.guildId.trim();
}
return undefined;
}
function persistCache(api: OpenClawPluginApi): void {
const p = cachePath(api);
const out: Record<string, { botAccountIds: string[]; updatedAt: string; source: string; guildId?: string }> = {};
for (const [channelId, set] of channelSeenAccounts.entries()) {
out[channelId] = {
botAccountIds: [...set],
updatedAt: new Date().toISOString(),
source: "dirigent/turn-bootstrap",
guildId: inferGuildIdFromChannelId(api, channelId),
};
}
try {
fs.mkdirSync(path.dirname(p), { recursive: true });
const tmp = `${p}.tmp`;
fs.writeFileSync(tmp, `${JSON.stringify(out, null, 2)}\n`, "utf8");
fs.renameSync(tmp, p);
} catch (err) {
api.logger.warn?.(`dirigent: failed persisting channel member cache: ${String(err)}`);
}
}
function getAllBotAccountIds(api: OpenClawPluginApi): string[] {
const root = (api.config as Record<string, unknown>) || {};
const bindings = root.bindings as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(bindings)) return [];
const ids: string[] = [];
for (const b of bindings) {
const match = b.match as Record<string, unknown> | undefined;
if (match?.channel === "discord" && typeof match.accountId === "string") {
ids.push(match.accountId);
}
}
return ids;
}
function getChannelBotAccountIds(api: OpenClawPluginApi, channelId: string): string[] {
const allBots = new Set(getAllBotAccountIds(api));
const seen = channelSeenAccounts.get(channelId);
if (!seen) return [];
return [...seen].filter((id) => allBots.has(id));
}
export function recordChannelAccount(api: OpenClawPluginApi, channelId: string, accountId: string): boolean {
loadCache(api);
let seen = channelSeenAccounts.get(channelId);
if (!seen) {
seen = new Set();
channelSeenAccounts.set(channelId, seen);
}
if (seen.has(accountId)) return false;
seen.add(accountId);
persistCache(api);
return true;
}
export async function ensureTurnOrder(api: OpenClawPluginApi, channelId: string): Promise<void> {
loadCache(api);
let botAccounts = getChannelBotAccountIds(api, channelId);
api.logger.info(
`dirigent: turn-debug ensureTurnOrder enter channel=${channelId} cached=${JSON.stringify(botAccounts)} bootstrapTried=${channelBootstrapTried.has(channelId)}`,
);
if (botAccounts.length === 0 && !channelBootstrapTried.has(channelId)) {
channelBootstrapTried.add(channelId);
const discovered = await fetchVisibleChannelBotAccountIds(api, channelId).catch(() => [] as string[]);
api.logger.info(
`dirigent: turn-debug ensureTurnOrder bootstrap-discovered channel=${channelId} discovered=${JSON.stringify(discovered)}`,
);
for (const aid of discovered) recordChannelAccount(api, channelId, aid);
botAccounts = getChannelBotAccountIds(api, channelId);
}
if (botAccounts.length > 0) {
api.logger.info(
`dirigent: turn-debug ensureTurnOrder initTurnOrder channel=${channelId} members=${JSON.stringify(botAccounts)}`,
);
initTurnOrder(channelId, botAccounts);
}
}

View File

@@ -1,45 +0,0 @@
type DebugConfig = {
enableDebugLogs?: boolean;
debugLogChannelIds?: string[];
};
export function pickDefined(input: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(input)) {
if (v !== undefined) out[k] = v;
}
return out;
}
export function shouldDebugLog(cfg: DebugConfig, channelId?: string): boolean {
if (!cfg.enableDebugLogs) return false;
const allow = Array.isArray(cfg.debugLogChannelIds) ? cfg.debugLogChannelIds : [];
if (allow.length === 0) return true;
if (!channelId) return true;
return allow.includes(channelId);
}
export function debugCtxSummary(ctx: Record<string, unknown>, event: Record<string, unknown>) {
const meta = ((ctx.metadata || event.metadata || {}) as Record<string, unknown>) || {};
return {
sessionKey: typeof ctx.sessionKey === "string" ? ctx.sessionKey : undefined,
commandSource: typeof ctx.commandSource === "string" ? ctx.commandSource : undefined,
messageProvider: typeof ctx.messageProvider === "string" ? ctx.messageProvider : undefined,
channel: typeof ctx.channel === "string" ? ctx.channel : undefined,
channelId: typeof ctx.channelId === "string" ? ctx.channelId : undefined,
senderId: typeof ctx.senderId === "string" ? ctx.senderId : undefined,
from: typeof ctx.from === "string" ? ctx.from : undefined,
metaSenderId:
typeof meta.senderId === "string"
? meta.senderId
: typeof meta.sender_id === "string"
? meta.sender_id
: undefined,
metaUserId:
typeof meta.userId === "string"
? meta.userId
: typeof meta.user_id === "string"
? meta.user_id
: undefined,
};
}