diff --git a/plugin/commands/dirigent-command.ts b/plugin/commands/dirigent-command.ts index 3561253..7232816 100644 --- a/plugin/commands/dirigent-command.ts +++ b/plugin/commands/dirigent-command.ts @@ -1,17 +1,22 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { advanceTurn, getTurnDebugInfo, resetTurn } from "../turn-manager.js"; +import type { DirigentConfig } from "../rules.js"; type CommandDeps = { api: OpenClawPluginApi; - policyState: { channelPolicies: Record }; + baseConfig: DirigentConfig; + policyState: { filePath: string; channelPolicies: Record }; + persistPolicies: (api: OpenClawPluginApi) => void; + ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void; + getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig; }; export function registerDirigentCommand(deps: CommandDeps): void { - const { api, policyState } = deps; + const { api, baseConfig, policyState, persistPolicies, ensurePolicyStateLoaded, getLivePluginConfig } = deps; api.registerCommand({ name: "dirigent", - description: "Dirigent channel policy management", + description: "Dirigent runtime commands", acceptsArgs: true, handler: async (cmdCtx) => { const args = cmdCtx.args || ""; @@ -25,7 +30,10 @@ export function registerDirigentCommand(deps: CommandDeps): void { `/dirigent status - Show current channel status\n` + `/dirigent turn-status - Show turn-based speaking status\n` + `/dirigent turn-advance - Manually advance turn\n` + - `/dirigent turn-reset - Reset turn order`, + `/dirigent turn-reset - Reset turn order\n` + + `/dirigent_policy get \n` + + `/dirigent_policy set \n` + + `/dirigent_policy delete `, }; } @@ -56,4 +64,70 @@ export function registerDirigentCommand(deps: CommandDeps): void { return { text: `Unknown subcommand: ${subCmd}`, isError: true }; }, }); + + api.registerCommand({ + name: "dirigent_policy", + description: "Dirigent channel policy CRUD", + acceptsArgs: true, + handler: async (cmdCtx) => { + const live = getLivePluginConfig(api, baseConfig); + ensurePolicyStateLoaded(api, live); + + const args = (cmdCtx.args || "").trim(); + if (!args) { + return { + text: + "Usage:\n" + + "/dirigent_policy get \n" + + "/dirigent_policy set \n" + + "/dirigent_policy delete ", + isError: true, + }; + } + + const [opRaw, channelIdRaw, ...rest] = args.split(/\s+/); + const op = (opRaw || "").toLowerCase(); + const channelId = (channelIdRaw || "").trim(); + + if (!channelId || !/^\d+$/.test(channelId)) { + return { text: "channelId is required and must be numeric Discord channel id", isError: true }; + } + + if (op === "get") { + const policy = (policyState.channelPolicies as Record)[channelId]; + return { text: JSON.stringify({ ok: true, channelId, policy: policy || null }, null, 2) }; + } + + if (op === "delete") { + delete (policyState.channelPolicies as Record)[channelId]; + persistPolicies(api); + return { text: JSON.stringify({ ok: true, channelId, deleted: true }) }; + } + + if (op === "set") { + const jsonText = rest.join(" ").trim(); + if (!jsonText) { + return { text: "set requires ", isError: true }; + } + let parsed: Record; + try { + parsed = JSON.parse(jsonText); + } catch (e) { + return { text: `invalid policy-json: ${String(e)}`, isError: true }; + } + + const next: Record = {}; + if (typeof parsed.listMode === "string") next.listMode = parsed.listMode; + if (Array.isArray(parsed.humanList)) next.humanList = parsed.humanList.map(String); + if (Array.isArray(parsed.agentList)) next.agentList = parsed.agentList.map(String); + if (Array.isArray(parsed.endSymbols)) next.endSymbols = parsed.endSymbols.map(String); + + (policyState.channelPolicies as Record)[channelId] = next; + persistPolicies(api); + return { text: JSON.stringify({ ok: true, channelId, policy: next }, null, 2) }; + } + + return { text: `unsupported op: ${op}. use get|set|delete`, isError: true }; + }, + }); } diff --git a/plugin/index.ts b/plugin/index.ts index 7bd2672..3ef862f 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -113,10 +113,7 @@ export default { registerDirigentTools({ api, baseConfig: baseConfig as DirigentConfig, - policyState, pickDefined, - persistPolicies, - ensurePolicyStateLoaded, getLivePluginConfig, }); @@ -171,7 +168,11 @@ export default { // Register slash commands for Discord registerDirigentCommand({ api, + baseConfig: baseConfig as DirigentConfig, policyState, + persistPolicies, + ensurePolicyStateLoaded, + getLivePluginConfig, }); // Handle NO_REPLY detection before message write diff --git a/plugin/tools/register-tools.ts b/plugin/tools/register-tools.ts index e7b2f52..4180ada 100644 --- a/plugin/tools/register-tools.ts +++ b/plugin/tools/register-tools.ts @@ -1,30 +1,15 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import type { ChannelPolicy, DirigentConfig } from "../rules.js"; +import type { DirigentConfig } from "../rules.js"; type DiscordControlAction = "channel-private-create" | "channel-private-update"; -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) || {}; @@ -34,7 +19,6 @@ function parseAccountToken(api: OpenClawPluginApi, accountId?: string): { accoun 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 }; } @@ -62,52 +46,8 @@ function roleOrMemberType(v: unknown): number { 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; + const { api, baseConfig, pickDefined, getLivePluginConfig } = deps; async function executeDiscordAction(action: DiscordControlAction, params: Record) { const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & { @@ -122,7 +62,6 @@ export function registerDirigentTools(deps: ToolDeps): void { if (!selected) { return { content: [{ type: "text", text: "no Discord bot token found in channels.discord.accounts" }], isError: true }; } - const token = selected.token; if (action === "channel-private-create") { @@ -132,7 +71,7 @@ export function registerDirigentTools(deps: ToolDeps): void { 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 allowMask = String(params.allowMask || "1024"); const denyEveryoneMask = String(params.denyEveryoneMask || "1024"); const overwrites: any[] = [ @@ -156,194 +95,85 @@ export function registerDirigentTools(deps: ToolDeps): void { 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 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 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 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); + 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) }] }; + 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 }); } - return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true }; + const resp = await discordRequest(token, "PATCH", `/channels/${channelId}`, { permission_overwrites: next }); + 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) }] }; } - api.registerTool( - { - name: "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); + api.registerTool({ + name: "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: [], }, - { optional: false }, - ); + async execute(_id: string, params: Record) { + return executeDiscordAction("channel-private-create", params); + }, + }, { optional: false }); - api.registerTool( - { - name: "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); + api.registerTool({ + name: "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: [], }, - { optional: false }, - ); - - api.registerTool( - { - name: "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) }], - }; - }, + async execute(_id: string, params: Record) { + return executeDiscordAction("channel-private-update", params); }, - { optional: false }, - ); - - api.registerTool( - { - name: "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: "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 }, - ); + }, { optional: false }); }