Files
Dirigent/plugin/core/moderator-discord.ts
hzhang b5196e972c 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>
2026-04-08 22:41:25 +01:00

240 lines
8.4 KiB
TypeScript

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];
const padded = segment + "=".repeat((4 - (segment.length % 4)) % 4);
return Buffer.from(padded, "base64").toString("utf8");
} catch {
return undefined;
}
}
export function resolveDiscordUserId(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 type ModeratorMessageResult =
| { ok: true; status: number; channelId: string; messageId?: string }
| { ok: false; status?: number; channelId: string; error: string };
export async function sendModeratorMessage(
token: string,
channelId: string,
content: string,
logger: Logger,
): Promise<ModeratorMessageResult> {
try {
const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
method: "POST",
headers: {
Authorization: `Bot ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ content }),
});
const text = await r.text();
let json: Record<string, unknown> | null = null;
try {
json = text ? (JSON.parse(text) as Record<string, unknown>) : null;
} catch {
json = null;
}
if (!r.ok) {
const error = `discord api error (${r.status}): ${text || "<empty response>"}`;
logger.warn(`dirigent: moderator send failed channel=${channelId} ${error}`);
return { ok: false, status: r.status, channelId, error };
}
const messageId = typeof json?.id === "string" ? json.id : undefined;
logger.info(`dirigent: moderator message sent to channel=${channelId} messageId=${messageId ?? "unknown"}`);
return { ok: true, status: r.status, channelId, messageId };
} catch (err) {
const error = String(err);
logger.warn(`dirigent: moderator send error channel=${channelId}: ${error}`);
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;
}
}