import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; type Logger = { info: (m: string) => void; warn: (m: string) => void }; export function userIdFromBotToken(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) || {}; const channels = (root.channels as Record) || {}; const discord = (channels.discord as Record) || {}; const accounts = (discord.accounts as Record>) || {}; const acct = accounts[accountId]; if (!acct?.token || typeof acct.token !== "string") return undefined; return userIdFromBotToken(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 { 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 | null = null; try { json = text ? (JSON.parse(text) as Record) : null; } catch { json = null; } if (!r.ok) { const error = `discord api error (${r.status}): ${text || ""}`; 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 { 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)}`); } } /** * Per-channel last schedule-trigger message ID. * Stored on globalThis so it survives VM-context hot-reloads. * Used by sendScheduleTrigger to delete the PREVIOUS trigger when a new one is sent. */ const _LAST_TRIGGER_KEY = "_dirigentLastTriggerMsgId"; if (!(globalThis as Record)[_LAST_TRIGGER_KEY]) { (globalThis as Record)[_LAST_TRIGGER_KEY] = new Map(); } const lastTriggerMsgId: Map = (globalThis as Record)[_LAST_TRIGGER_KEY] as Map; /** * Send a schedule-identifier trigger message, then delete the PREVIOUS one for * this channel (so only the current trigger is visible at any time). * * In debugMode the previous message is NOT deleted, leaving a full trigger * history visible in Discord for inspection. */ export async function sendScheduleTrigger( token: string, channelId: string, content: string, logger: Logger, debugMode = false, ): Promise { const prevMsgId = lastTriggerMsgId.get(channelId); const result = await sendModeratorMessage(token, channelId, content, logger); if (!result.ok || !result.messageId) return; if (!debugMode) { // Track the new message so the NEXT call can delete it lastTriggerMsgId.set(channelId, result.messageId); // Delete the previous trigger with a small delay if (prevMsgId) { await new Promise((r) => setTimeout(r, 300)); await deleteMessage(token, channelId, prevMsgId, logger); } } // debugMode: don't track, don't delete — every trigger stays in history } /** Get the latest message ID in a channel (for use as poll anchor). */ export async function getLatestMessageId( token: string, channelId: string, ): Promise { 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 any message from agentDiscordUserId with id > anchorId * appears. The anchor is set in before_model_resolve just before the LLM call, * so any agent message after it must belong to this turn. * * Uses exponential back-off starting at initialPollMs, doubling each miss, * capped at maxPollMs. Times out after timeoutMs (default 30 s) to avoid * getting permanently stuck when the agent's Discord message is never delivered. * * @returns { matched: true } when found, { interrupted: true } when aborted, * { matched: false, interrupted: false } on timeout. */ export async function pollForTailMatch(opts: { token: string; channelId: string; anchorId: string; agentDiscordUserId: string; /** Initial poll interval in ms (default 800). */ initialPollMs?: number; /** Maximum poll interval in ms (default 8 000). */ maxPollMs?: number; /** Give up and return after this many ms (default 30 000). */ timeoutMs?: number; /** Callback checked before each poll; if true, polling is aborted. */ isInterrupted?: () => boolean; }): Promise<{ matched: boolean; interrupted: boolean }> { const { token, channelId, anchorId, agentDiscordUserId, initialPollMs = 800, maxPollMs = 8_000, timeoutMs = 30_000, isInterrupted = () => false, } = opts; let interval = initialPollMs; const deadline = Date.now() + timeoutMs; while (true) { if (isInterrupted()) return { matched: false, interrupted: true }; if (Date.now() >= deadline) return { matched: false, interrupted: false }; 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 found = msgs.some( (m) => m.author.id === agentDiscordUserId && BigInt(m.id) > BigInt(anchorId), ); if (found) return { matched: true, interrupted: false }; } } catch { // ignore transient errors, keep polling } await new Promise((r) => setTimeout(r, interval)); // Exponential back-off, capped at maxPollMs interval = Math.min(interval * 2, maxPollMs); } } /** 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 { 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; 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> { 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> { 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; } }