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:
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
136
plugin/core/channel-store.ts
Normal file
136
plugin/core/channel-store.ts
Normal 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 }));
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './discussion-messages.ts';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './discussion-state.ts';
|
||||
@@ -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;
|
||||
}
|
||||
93
plugin/core/identity-registry.ts
Normal file
93
plugin/core/identity-registry.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './moderator-discord.ts';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
59
plugin/core/padded-cell.ts
Normal file
59
plugin/core/padded-cell.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user