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)}`); } } /** 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 { 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 { 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 { 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; } }