import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { ChannelPolicy, DirigentConfig } from "../rules.js"; type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list"; const PERM_VIEW_CHANNEL = 1n << 10n; // 0x400 const PERM_ADMINISTRATOR = 1n << 3n; // 0x8 type ToolDeps = { api: OpenClawPluginApi; baseConfig: DirigentConfig; policyState: { filePath: string; channelPolicies: Record }; pickDefined: (obj: Record) => Record; persistPolicies: (api: OpenClawPluginApi) => void; ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void; getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig; }; function toBigIntPerm(v: unknown): bigint { if (typeof v === "bigint") return v; if (typeof v === "number") return BigInt(Math.trunc(v)); if (typeof v === "string" && v.trim()) { try { return BigInt(v.trim()); } catch { return 0n; } } return 0n; } function parseAccountToken(api: OpenClawPluginApi, accountId?: string): { accountId: string; token: string } | null { const root = (api.config as Record) || {}; const channels = (root.channels as Record) || {}; const discord = (channels.discord as Record) || {}; const accounts = (discord.accounts as Record>) || {}; if (accountId && accounts[accountId] && typeof accounts[accountId].token === "string") { return { accountId, token: accounts[accountId].token as string }; } for (const [aid, rec] of Object.entries(accounts)) { if (typeof rec?.token === "string" && rec.token) return { accountId: aid, token: rec.token }; } return null; } async function discordRequest(token: string, method: string, path: string, body?: unknown): Promise<{ ok: boolean; status: number; text: string; json: any }> { const r = await fetch(`https://discord.com/api/v10${path}`, { method, headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json", }, body: body === undefined ? undefined : JSON.stringify(body), }); const text = await r.text(); let json: any = null; try { json = text ? JSON.parse(text) : null; } catch { json = null; } return { ok: r.ok, status: r.status, text, json }; } function roleOrMemberType(v: unknown): number { if (typeof v === "number") return v; if (typeof v === "string" && v.toLowerCase() === "member") return 1; return 0; } function canViewChannel(member: any, guildId: string, guildRoles: Map, channelOverwrites: any[]): boolean { const roleIds: string[] = Array.isArray(member?.roles) ? member.roles : []; let perms = guildRoles.get(guildId) || 0n; // @everyone for (const rid of roleIds) perms |= guildRoles.get(rid) || 0n; if ((perms & PERM_ADMINISTRATOR) !== 0n) return true; let everyoneAllow = 0n; let everyoneDeny = 0n; for (const ow of channelOverwrites) { if (String(ow?.id || "") === guildId && roleOrMemberType(ow?.type) === 0) { everyoneAllow = toBigIntPerm(ow?.allow); everyoneDeny = toBigIntPerm(ow?.deny); break; } } perms = (perms & ~everyoneDeny) | everyoneAllow; let roleAllow = 0n; let roleDeny = 0n; for (const ow of channelOverwrites) { if (roleOrMemberType(ow?.type) !== 0) continue; const id = String(ow?.id || ""); if (id !== guildId && roleIds.includes(id)) { roleAllow |= toBigIntPerm(ow?.allow); roleDeny |= toBigIntPerm(ow?.deny); } } perms = (perms & ~roleDeny) | roleAllow; for (const ow of channelOverwrites) { if (roleOrMemberType(ow?.type) !== 1) continue; if (String(ow?.id || "") === String(member?.user?.id || "")) { const allow = toBigIntPerm(ow?.allow); const deny = toBigIntPerm(ow?.deny); perms = (perms & ~deny) | allow; break; } } return (perms & PERM_VIEW_CHANNEL) !== 0n; } export function registerDirigentTools(deps: ToolDeps): void { const { api, baseConfig, policyState, pickDefined, persistPolicies, ensurePolicyStateLoaded, getLivePluginConfig } = deps; async function executeDiscordAction(action: DiscordControlAction, params: Record) { const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & { enableDiscordControlTool?: boolean; discordControlAccountId?: string; }; if (live.enableDiscordControlTool === false) { return { content: [{ type: "text", text: "discord actions disabled by config" }], isError: true }; } const selected = parseAccountToken(api, (params.accountId as string | undefined) || live.discordControlAccountId); if (!selected) { return { content: [{ type: "text", text: "no Discord bot token found in channels.discord.accounts" }], isError: true }; } const token = selected.token; if (action === "member-list") { const guildId = String(params.guildId || "").trim(); if (!guildId) return { content: [{ type: "text", text: "guildId is required" }], isError: true }; const limit = Math.max(1, Math.min(1000, Number(params.limit || 1000))); const after = String(params.after || "").trim(); const fieldsRaw = params.fields; const fields = Array.isArray(fieldsRaw) ? fieldsRaw.map(String) : typeof fieldsRaw === "string" ? fieldsRaw.split(",").map((s) => s.trim()).filter(Boolean) : []; const q = new URLSearchParams({ limit: String(limit) }); if (after) q.set("after", after); const resp = await discordRequest(token, "GET", `/guilds/${guildId}/members?${q.toString()}`); if (!resp.ok) return { content: [{ type: "text", text: `discord action failed (${resp.status}): ${resp.text}` }], isError: true }; let members = Array.isArray(resp.json) ? resp.json : []; if (fields.length > 0) { members = members.map((m: any) => { const out: Record = {}; for (const f of fields) out[f] = m?.[f]; return out; }); } return { content: [{ type: "text", text: JSON.stringify({ ok: true, accountId: selected.accountId, members }, null, 2) }] }; } if (action === "channel-private-create") { const guildId = String(params.guildId || "").trim(); const name = String(params.name || "").trim(); if (!guildId || !name) return { content: [{ type: "text", text: "guildId and name are required" }], isError: true }; const allowedUserIds = Array.isArray(params.allowedUserIds) ? params.allowedUserIds.map(String) : []; const allowedRoleIds = Array.isArray(params.allowedRoleIds) ? params.allowedRoleIds.map(String) : []; const allowMask = String(params.allowMask || "1024"); // VIEW_CHANNEL default const denyEveryoneMask = String(params.denyEveryoneMask || "1024"); const overwrites: any[] = [ { id: guildId, type: 0, allow: "0", deny: denyEveryoneMask }, ...allowedRoleIds.map((id) => ({ id, type: 0, allow: allowMask, deny: "0" })), ...allowedUserIds.map((id) => ({ id, type: 1, allow: allowMask, deny: "0" })), ]; const body = pickDefined({ name, type: typeof params.type === "number" ? params.type : 0, parent_id: params.parentId, topic: params.topic, position: params.position, nsfw: params.nsfw, permission_overwrites: overwrites, }); const resp = await discordRequest(token, "POST", `/guilds/${guildId}/channels`, body); if (!resp.ok) return { content: [{ type: "text", text: `discord action failed (${resp.status}): ${resp.text}` }], isError: true }; return { content: [{ type: "text", text: JSON.stringify({ ok: true, accountId: selected.accountId, channel: resp.json }, null, 2) }] }; } if (action === "channel-private-update") { const channelId = String(params.channelId || "").trim(); if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; const mode = String(params.mode || "merge").toLowerCase() === "replace" ? "replace" : "merge"; const addUserIds = Array.isArray(params.addUserIds) ? params.addUserIds.map(String) : []; const addRoleIds = Array.isArray(params.addRoleIds) ? params.addRoleIds.map(String) : []; const removeTargetIds = Array.isArray(params.removeTargetIds) ? params.removeTargetIds.map(String) : []; const allowMask = String(params.allowMask || "1024"); const denyMask = String(params.denyMask || "0"); const ch = await discordRequest(token, "GET", `/channels/${channelId}`); if (!ch.ok) return { content: [{ type: "text", text: `discord action failed (${ch.status}): ${ch.text}` }], isError: true }; const current = Array.isArray(ch.json?.permission_overwrites) ? [...ch.json.permission_overwrites] : []; const guildId = String(ch.json?.guild_id || ""); const everyone = current.find((x: any) => String(x?.id || "") === guildId && roleOrMemberType(x?.type) === 0); let next: any[] = mode === "replace" ? (everyone ? [everyone] : []) : current.filter((x: any) => !removeTargetIds.includes(String(x?.id || ""))); for (const id of addRoleIds) { next = next.filter((x: any) => String(x?.id || "") !== id); next.push({ id, type: 0, allow: allowMask, deny: denyMask }); } for (const id of addUserIds) { next = next.filter((x: any) => String(x?.id || "") !== id); next.push({ id, type: 1, allow: allowMask, deny: denyMask }); } const body = { permission_overwrites: next }; const resp = await discordRequest(token, "PATCH", `/channels/${channelId}`, body); if (!resp.ok) return { content: [{ type: "text", text: `discord action failed (${resp.status}): ${resp.text}` }], isError: true }; return { content: [{ type: "text", text: JSON.stringify({ ok: true, accountId: selected.accountId, channel: resp.json }, null, 2) }] }; } return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true }; } api.registerTool( { name: "dirigent_discord_channel_create", description: "Create a private Discord channel with specific user/role permissions.", parameters: { type: "object", additionalProperties: false, properties: { accountId: { type: "string" }, guildId: { type: "string" }, name: { type: "string" }, type: { type: "number" }, parentId: { type: "string" }, topic: { type: "string" }, position: { type: "number" }, nsfw: { type: "boolean" }, allowedUserIds: { type: "array", items: { type: "string" } }, allowedRoleIds: { type: "array", items: { type: "string" } }, allowMask: { type: "string" }, denyEveryoneMask: { type: "string" }, }, required: [], }, async execute(_id: string, params: Record) { return executeDiscordAction("channel-private-create", params); }, }, { optional: false }, ); api.registerTool( { name: "dirigent_discord_channel_update", description: "Update permissions on an existing private Discord channel.", parameters: { type: "object", additionalProperties: false, properties: { accountId: { type: "string" }, channelId: { type: "string" }, mode: { type: "string", enum: ["merge", "replace"] }, addUserIds: { type: "array", items: { type: "string" } }, addRoleIds: { type: "array", items: { type: "string" } }, removeTargetIds: { type: "array", items: { type: "string" } }, allowMask: { type: "string" }, denyMask: { type: "string" }, }, required: [], }, async execute(_id: string, params: Record) { return executeDiscordAction("channel-private-update", params); }, }, { optional: false }, ); api.registerTool( { name: "dirigent_discord_member_list", description: "List members of a Discord guild.", parameters: { type: "object", additionalProperties: false, properties: { accountId: { type: "string" }, guildId: { type: "string" }, limit: { type: "number" }, after: { type: "string" }, fields: { anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }] }, }, required: [], }, async execute(_id: string, params: Record) { return executeDiscordAction("member-list", params); }, }, { optional: false }, ); api.registerTool( { name: "dirigent_policy_get", description: "Get all Dirigent channel policies.", parameters: { type: "object", additionalProperties: false, properties: {}, required: [] }, async execute() { const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & { enableDirigentPolicyTool?: boolean }; if (live.enableDirigentPolicyTool === false) { return { content: [{ type: "text", text: "policy actions disabled by config" }], isError: true }; } ensurePolicyStateLoaded(api, live); return { content: [{ type: "text", text: JSON.stringify({ file: policyState.filePath, policies: policyState.channelPolicies }, null, 2) }], }; }, }, { optional: false }, ); api.registerTool( { name: "dirigent_policy_set", description: "Set or update a Dirigent channel policy.", parameters: { type: "object", additionalProperties: false, properties: { channelId: { type: "string" }, listMode: { type: "string", enum: ["human-list", "agent-list"] }, humanList: { type: "array", items: { type: "string" } }, agentList: { type: "array", items: { type: "string" } }, endSymbols: { type: "array", items: { type: "string" } }, }, required: ["channelId"], }, async execute(_id: string, params: Record) { const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & { enableDirigentPolicyTool?: boolean }; if (live.enableDirigentPolicyTool === false) { return { content: [{ type: "text", text: "policy actions disabled by config" }], isError: true }; } ensurePolicyStateLoaded(api, live); const channelId = String(params.channelId || "").trim(); if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; const prev = JSON.parse(JSON.stringify(policyState.channelPolicies)); try { const next: ChannelPolicy = { listMode: (params.listMode as "human-list" | "agent-list" | undefined) || undefined, humanList: Array.isArray(params.humanList) ? (params.humanList as string[]) : undefined, agentList: Array.isArray(params.agentList) ? (params.agentList as string[]) : undefined, endSymbols: Array.isArray(params.endSymbols) ? (params.endSymbols as string[]) : undefined, }; policyState.channelPolicies[channelId] = pickDefined(next as unknown as Record) as ChannelPolicy; persistPolicies(api); return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, policy: policyState.channelPolicies[channelId] }) }] }; } catch (err) { policyState.channelPolicies = prev; return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true }; } }, }, { optional: false }, ); api.registerTool( { name: "dirigent_policy_delete", description: "Delete a Dirigent channel policy.", parameters: { type: "object", additionalProperties: false, properties: { channelId: { type: "string" }, }, required: ["channelId"], }, async execute(_id: string, params: Record) { const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & { enableDirigentPolicyTool?: boolean }; if (live.enableDirigentPolicyTool === false) { return { content: [{ type: "text", text: "policy actions disabled by config" }], isError: true }; } ensurePolicyStateLoaded(api, live); const channelId = String(params.channelId || "").trim(); if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; const prev = JSON.parse(JSON.stringify(policyState.channelPolicies)); try { delete policyState.channelPolicies[channelId]; persistPolicies(api); return { content: [{ type: "text", text: JSON.stringify({ ok: true, channelId, deleted: true }) }] }; } catch (err) { policyState.channelPolicies = prev; return { content: [{ type: "text", text: `persist failed: ${String(err)}` }], isError: true }; } }, }, { optional: false }, ); }