feat: start NEW_FEAT refactor (channel resolver + decision input modules) #16

Merged
hzhang merged 26 commits from feat/new-feat-refactor-start into feat/split-tools-and-mention-override 2026-03-08 07:25:42 +00:00
3 changed files with 152 additions and 247 deletions
Showing only changes of commit 3cde4a7795 - Show all commits

View File

@@ -1,17 +1,22 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { advanceTurn, getTurnDebugInfo, resetTurn } from "../turn-manager.js"; import { advanceTurn, getTurnDebugInfo, resetTurn } from "../turn-manager.js";
import type { DirigentConfig } from "../rules.js";
type CommandDeps = { type CommandDeps = {
api: OpenClawPluginApi; api: OpenClawPluginApi;
policyState: { channelPolicies: Record<string, unknown> }; baseConfig: DirigentConfig;
policyState: { filePath: string; channelPolicies: Record<string, unknown> };
persistPolicies: (api: OpenClawPluginApi) => void;
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig;
}; };
export function registerDirigentCommand(deps: CommandDeps): void { export function registerDirigentCommand(deps: CommandDeps): void {
const { api, policyState } = deps; const { api, baseConfig, policyState, persistPolicies, ensurePolicyStateLoaded, getLivePluginConfig } = deps;
api.registerCommand({ api.registerCommand({
name: "dirigent", name: "dirigent",
description: "Dirigent channel policy management", description: "Dirigent runtime commands",
acceptsArgs: true, acceptsArgs: true,
handler: async (cmdCtx) => { handler: async (cmdCtx) => {
const args = cmdCtx.args || ""; const args = cmdCtx.args || "";
@@ -25,7 +30,10 @@ export function registerDirigentCommand(deps: CommandDeps): void {
`/dirigent status - Show current channel status\n` + `/dirigent status - Show current channel status\n` +
`/dirigent turn-status - Show turn-based speaking status\n` + `/dirigent turn-status - Show turn-based speaking status\n` +
`/dirigent turn-advance - Manually advance turn\n` + `/dirigent turn-advance - Manually advance turn\n` +
`/dirigent turn-reset - Reset turn order`, `/dirigent turn-reset - Reset turn order\n` +
`/dirigent_policy get <discordChannelId>\n` +
`/dirigent_policy set <discordChannelId> <policy-json>\n` +
`/dirigent_policy delete <discordChannelId>`,
}; };
} }
@@ -56,4 +64,70 @@ export function registerDirigentCommand(deps: CommandDeps): void {
return { text: `Unknown subcommand: ${subCmd}`, isError: true }; 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 <discordChannelId>\n" +
"/dirigent_policy set <discordChannelId> <policy-json>\n" +
"/dirigent_policy delete <discordChannelId>",
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<string, unknown>)[channelId];
return { text: JSON.stringify({ ok: true, channelId, policy: policy || null }, null, 2) };
}
if (op === "delete") {
delete (policyState.channelPolicies as Record<string, unknown>)[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 <policy-json>", isError: true };
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(jsonText);
} catch (e) {
return { text: `invalid policy-json: ${String(e)}`, isError: true };
}
const next: Record<string, unknown> = {};
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<string, unknown>)[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 };
},
});
} }

View File

@@ -113,10 +113,7 @@ export default {
registerDirigentTools({ registerDirigentTools({
api, api,
baseConfig: baseConfig as DirigentConfig, baseConfig: baseConfig as DirigentConfig,
policyState,
pickDefined, pickDefined,
persistPolicies,
ensurePolicyStateLoaded,
getLivePluginConfig, getLivePluginConfig,
}); });
@@ -171,7 +168,11 @@ export default {
// Register slash commands for Discord // Register slash commands for Discord
registerDirigentCommand({ registerDirigentCommand({
api, api,
baseConfig: baseConfig as DirigentConfig,
policyState, policyState,
persistPolicies,
ensurePolicyStateLoaded,
getLivePluginConfig,
}); });
// Handle NO_REPLY detection before message write // Handle NO_REPLY detection before message write

View File

@@ -1,30 +1,15 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; 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"; type DiscordControlAction = "channel-private-create" | "channel-private-update";
const PERM_VIEW_CHANNEL = 1n << 10n; // 0x400
const PERM_ADMINISTRATOR = 1n << 3n; // 0x8
type ToolDeps = { type ToolDeps = {
api: OpenClawPluginApi; api: OpenClawPluginApi;
baseConfig: DirigentConfig; baseConfig: DirigentConfig;
policyState: { filePath: string; channelPolicies: Record<string, ChannelPolicy> };
pickDefined: (obj: Record<string, unknown>) => Record<string, unknown>; pickDefined: (obj: Record<string, unknown>) => Record<string, unknown>;
persistPolicies: (api: OpenClawPluginApi) => void;
ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void;
getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig; 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 { function parseAccountToken(api: OpenClawPluginApi, accountId?: string): { accountId: string; token: string } | null {
const root = (api.config as Record<string, unknown>) || {}; const root = (api.config as Record<string, unknown>) || {};
const channels = (root.channels as Record<string, unknown>) || {}; const channels = (root.channels as Record<string, unknown>) || {};
@@ -34,7 +19,6 @@ function parseAccountToken(api: OpenClawPluginApi, accountId?: string): { accoun
if (accountId && accounts[accountId] && typeof accounts[accountId].token === "string") { if (accountId && accounts[accountId] && typeof accounts[accountId].token === "string") {
return { accountId, token: accounts[accountId].token as string }; return { accountId, token: accounts[accountId].token as string };
} }
for (const [aid, rec] of Object.entries(accounts)) { for (const [aid, rec] of Object.entries(accounts)) {
if (typeof rec?.token === "string" && rec.token) return { accountId: aid, token: rec.token }; if (typeof rec?.token === "string" && rec.token) return { accountId: aid, token: rec.token };
} }
@@ -62,52 +46,8 @@ function roleOrMemberType(v: unknown): number {
return 0; return 0;
} }
function canViewChannel(member: any, guildId: string, guildRoles: Map<string, bigint>, 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 { 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<string, unknown>) { async function executeDiscordAction(action: DiscordControlAction, params: Record<string, unknown>) {
const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & { const live = getLivePluginConfig(api, baseConfig as DirigentConfig) as DirigentConfig & {
@@ -122,7 +62,6 @@ export function registerDirigentTools(deps: ToolDeps): void {
if (!selected) { if (!selected) {
return { content: [{ type: "text", text: "no Discord bot token found in channels.discord.accounts" }], isError: true }; return { content: [{ type: "text", text: "no Discord bot token found in channels.discord.accounts" }], isError: true };
} }
const token = selected.token; const token = selected.token;
if (action === "channel-private-create") { 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 allowedUserIds = Array.isArray(params.allowedUserIds) ? params.allowedUserIds.map(String) : [];
const allowedRoleIds = Array.isArray(params.allowedRoleIds) ? params.allowedRoleIds.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 denyEveryoneMask = String(params.denyEveryoneMask || "1024");
const overwrites: any[] = [ 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) }] }; 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();
const channelId = String(params.channelId || "").trim(); if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
if (!channelId) return { content: [{ type: "text", text: "channelId is required" }], isError: true };
const mode = String(params.mode || "merge").toLowerCase() === "replace" ? "replace" : "merge"; const mode = String(params.mode || "merge").toLowerCase() === "replace" ? "replace" : "merge";
const addUserIds = Array.isArray(params.addUserIds) ? params.addUserIds.map(String) : []; const addUserIds = Array.isArray(params.addUserIds) ? params.addUserIds.map(String) : [];
const addRoleIds = Array.isArray(params.addRoleIds) ? params.addRoleIds.map(String) : []; const addRoleIds = Array.isArray(params.addRoleIds) ? params.addRoleIds.map(String) : [];
const removeTargetIds = Array.isArray(params.removeTargetIds) ? params.removeTargetIds.map(String) : []; const removeTargetIds = Array.isArray(params.removeTargetIds) ? params.removeTargetIds.map(String) : [];
const allowMask = String(params.allowMask || "1024"); const allowMask = String(params.allowMask || "1024");
const denyMask = String(params.denyMask || "0"); const denyMask = String(params.denyMask || "0");
const ch = await discordRequest(token, "GET", `/channels/${channelId}`); 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 }; 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 current = Array.isArray(ch.json?.permission_overwrites) ? [...ch.json.permission_overwrites] : [];
const guildId = String(ch.json?.guild_id || ""); const guildId = String(ch.json?.guild_id || "");
const everyone = current.find((x: any) => String(x?.id || "") === guildId && roleOrMemberType(x?.type) === 0); 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 || ""))); let next: any[] = mode === "replace" ? (everyone ? [everyone] : []) : current.filter((x: any) => !removeTargetIds.includes(String(x?.id || "")));
for (const id of addRoleIds) {
for (const id of addRoleIds) { next = next.filter((x: any) => String(x?.id || "") !== id);
next = next.filter((x: any) => String(x?.id || "") !== id); next.push({ id, type: 0, allow: allowMask, deny: denyMask });
next.push({ id, type: 0, allow: allowMask, deny: denyMask }); }
} for (const id of addUserIds) {
for (const id of addUserIds) { next = next.filter((x: any) => String(x?.id || "") !== id);
next = next.filter((x: any) => String(x?.id || "") !== id); next.push({ id, type: 1, allow: allowMask, deny: denyMask });
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 }; 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( api.registerTool({
{ name: "discord_channel_create",
name: "discord_channel_create", description: "Create a private Discord channel with specific user/role permissions.",
description: "Create a private Discord channel with specific user/role permissions.", parameters: {
parameters: { type: "object",
type: "object", additionalProperties: false,
additionalProperties: false, properties: {
properties: { accountId: { type: "string" },
accountId: { type: "string" }, guildId: { type: "string" },
guildId: { type: "string" }, name: { type: "string" },
name: { type: "string" }, type: { type: "number" },
type: { type: "number" }, parentId: { type: "string" },
parentId: { type: "string" }, topic: { type: "string" },
topic: { type: "string" }, position: { type: "number" },
position: { type: "number" }, nsfw: { type: "boolean" },
nsfw: { type: "boolean" }, allowedUserIds: { type: "array", items: { type: "string" } },
allowedUserIds: { type: "array", items: { type: "string" } }, allowedRoleIds: { type: "array", items: { type: "string" } },
allowedRoleIds: { type: "array", items: { type: "string" } }, allowMask: { type: "string" },
allowMask: { type: "string" }, denyEveryoneMask: { type: "string" },
denyEveryoneMask: { type: "string" },
},
required: [],
},
async execute(_id: string, params: Record<string, unknown>) {
return executeDiscordAction("channel-private-create", params);
}, },
required: [],
}, },
{ optional: false }, async execute(_id: string, params: Record<string, unknown>) {
); return executeDiscordAction("channel-private-create", params);
},
}, { optional: false });
api.registerTool( api.registerTool({
{ name: "discord_channel_update",
name: "discord_channel_update", description: "Update permissions on an existing private Discord channel.",
description: "Update permissions on an existing private Discord channel.", parameters: {
parameters: { type: "object",
type: "object", additionalProperties: false,
additionalProperties: false, properties: {
properties: { accountId: { type: "string" },
accountId: { type: "string" }, channelId: { type: "string" },
channelId: { type: "string" }, mode: { type: "string", enum: ["merge", "replace"] },
mode: { type: "string", enum: ["merge", "replace"] }, addUserIds: { type: "array", items: { type: "string" } },
addUserIds: { type: "array", items: { type: "string" } }, addRoleIds: { type: "array", items: { type: "string" } },
addRoleIds: { type: "array", items: { type: "string" } }, removeTargetIds: { type: "array", items: { type: "string" } },
removeTargetIds: { type: "array", items: { type: "string" } }, allowMask: { type: "string" },
allowMask: { type: "string" }, denyMask: { type: "string" },
denyMask: { type: "string" },
},
required: [],
},
async execute(_id: string, params: Record<string, unknown>) {
return executeDiscordAction("channel-private-update", params);
}, },
required: [],
}, },
{ optional: false }, async execute(_id: string, params: Record<string, unknown>) {
); return executeDiscordAction("channel-private-update", params);
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) }],
};
},
}, },
{ optional: false }, }, { 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<string, unknown>) {
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<string, unknown>) 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<string, unknown>) {
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 },
);
} }