diff --git a/plugin/tools/register-tools.ts b/plugin/tools/register-tools.ts index bca3a6b..71846f1 100644 --- a/plugin/tools/register-tools.ts +++ b/plugin/tools/register-tools.ts @@ -1,7 +1,10 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { ChannelPolicy, DirigentConfig } from "../rules.js"; -type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list"; +type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list" | "channel-member-list"; + +const PERM_VIEW_CHANNEL = 1n << 10n; // 0x400 +const PERM_ADMINISTRATOR = 1n << 3n; // 0x8 type ToolDeps = { api: OpenClawPluginApi; @@ -13,34 +16,262 @@ type ToolDeps = { 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 & { - discordControlApiBaseUrl?: string; - discordControlApiToken?: string; - discordControlCallerId?: string; enableDiscordControlTool?: boolean; + discordControlAccountId?: string; }; if (live.enableDiscordControlTool === false) { return { content: [{ type: "text", text: "discord actions disabled by config" }], isError: true }; } - const baseUrl = (live.discordControlApiBaseUrl || "http://127.0.0.1:8790").replace(/\/$/, ""); - const body = pickDefined({ ...params, action }); - const headers: Record = { "Content-Type": "application/json" }; - if (live.discordControlApiToken) headers.Authorization = `Bearer ${live.discordControlApiToken}`; - if (live.discordControlCallerId) headers["X-OpenClaw-Caller-Id"] = live.discordControlCallerId; - const r = await fetch(`${baseUrl}/v1/discord/action`, { - method: "POST", - headers, - body: JSON.stringify(body), - }); - const text = await r.text(); - if (!r.ok) { - return { content: [{ type: "text", text: `discord action failed (${r.status}): ${text}` }], 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 }; } - return { content: [{ type: "text", text }] }; + + 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-member-list") { + const channelId = String(params.channelId || "").trim(); + if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true }; + + 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 guildId = String(ch.json?.guild_id || ""); + if (!guildId) return { content: [{ type: "text", text: "channel has no guild_id (not a guild channel)" }], isError: true }; + + const rolesResp = await discordRequest(token, "GET", `/guilds/${guildId}/roles`); + if (!rolesResp.ok) return { content: [{ type: "text", text: `discord action failed (${rolesResp.status}): ${rolesResp.text}` }], isError: true }; + const rolePerms = new Map(); + for (const r of Array.isArray(rolesResp.json) ? rolesResp.json : []) { + rolePerms.set(String(r?.id || ""), toBigIntPerm(r?.permissions)); + } + + const members: any[] = []; + let after = ""; + while (true) { + const q = new URLSearchParams({ limit: "1000" }); + if (after) q.set("after", after); + const mResp = await discordRequest(token, "GET", `/guilds/${guildId}/members?${q.toString()}`); + if (!mResp.ok) return { content: [{ type: "text", text: `discord action failed (${mResp.status}): ${mResp.text}` }], isError: true }; + const batch = Array.isArray(mResp.json) ? mResp.json : []; + members.push(...batch); + if (batch.length < 1000) break; + after = String(batch[batch.length - 1]?.user?.id || ""); + if (!after) break; + } + + const overwrites = Array.isArray(ch.json?.permission_overwrites) ? ch.json.permission_overwrites : []; + const visible = members.filter((m) => canViewChannel(m, guildId, rolePerms, overwrites)); + + return { + content: [{ + type: "text", + text: JSON.stringify({ + ok: true, + accountId: selected.accountId, + channelId, + guildId, + totalGuildMembers: members.length, + visibleCount: visible.length, + members: visible.map((m) => ({ + userId: m?.user?.id, + username: m?.user?.username, + globalName: m?.user?.global_name, + nick: m?.nick, + roles: m?.roles || [], + })), + }, 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( @@ -51,6 +282,7 @@ export function registerDirigentTools(deps: ToolDeps): void { type: "object", additionalProperties: false, properties: { + accountId: { type: "string" }, guildId: { type: "string" }, name: { type: "string" }, type: { type: "number" }, @@ -80,6 +312,7 @@ export function registerDirigentTools(deps: ToolDeps): void { type: "object", additionalProperties: false, properties: { + accountId: { type: "string" }, channelId: { type: "string" }, mode: { type: "string", enum: ["merge", "replace"] }, addUserIds: { type: "array", items: { type: "string" } }, @@ -105,6 +338,7 @@ export function registerDirigentTools(deps: ToolDeps): void { type: "object", additionalProperties: false, properties: { + accountId: { type: "string" }, guildId: { type: "string" }, limit: { type: "number" }, after: { type: "string" }, @@ -119,6 +353,26 @@ export function registerDirigentTools(deps: ToolDeps): void { { optional: false }, ); + api.registerTool( + { + name: "dirigent_discord_channel_member_list", + description: "List effective visible members for a Discord channel by computing VIEW_CHANNEL permission.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + accountId: { type: "string" }, + channelId: { type: "string" }, + }, + required: ["channelId"], + }, + async execute(_id: string, params: Record) { + return executeDiscordAction("channel-member-list", params); + }, + }, + { optional: false }, + ); + api.registerTool( { name: "dirigent_policy_get",