Complete rewrite of the Dirigent plugin turn management system to work correctly with OpenClaw's VM-context-per-session architecture: - All turn state stored on globalThis (persists across VM context hot-reloads) - Hooks registered unconditionally on every api instance; event-level dedup (runId Set for agent_end, WeakSet for before_model_resolve) prevents double-processing - Gateway lifecycle events (gateway_start/stop) guarded once via globalThis flag - Shared initializingChannels lock prevents concurrent channel init across VM contexts in message_received and before_model_resolve - New ChannelStore and IdentityRegistry replace old policy/session-state modules - Added agent_end hook with tail-match polling for Discord delivery confirmation - Added web control page, padded-cell auto-scan, discussion tool support - Removed obsolete v1 modules: channel-resolver, channel-modes, discussion-service, session-state, turn-bootstrap, policy/store, rules, decision-input Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
158 lines
5.2 KiB
TypeScript
158 lines
5.2 KiB
TypeScript
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
import type { IdentityRegistry } from "./identity-registry.js";
|
|
|
|
const PERM_VIEW_CHANNEL = 1n << 10n;
|
|
const PERM_ADMINISTRATOR = 1n << 3n;
|
|
|
|
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 roleOrMemberType(v: unknown): number {
|
|
if (typeof v === "number") return v;
|
|
if (typeof v === "string" && v.toLowerCase() === "member") return 1;
|
|
return 0;
|
|
}
|
|
|
|
async function discordRequest(token: string, method: string, path: string): Promise<{ ok: boolean; status: number; json: any; text: string }> {
|
|
const r = await fetch(`https://discord.com/api/v10${path}`, {
|
|
method,
|
|
headers: {
|
|
Authorization: `Bot ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
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, json, text };
|
|
}
|
|
|
|
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;
|
|
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;
|
|
}
|
|
|
|
function getDiscoveryToken(api: OpenClawPluginApi): string | undefined {
|
|
// Prefer moderator bot token from pluginConfig — it has guild member access
|
|
const pluginCfg = (api.pluginConfig as Record<string, unknown>) || {};
|
|
const moderatorToken = pluginCfg.moderatorBotToken;
|
|
if (typeof moderatorToken === "string" && moderatorToken) {
|
|
return moderatorToken;
|
|
}
|
|
// Fall back to any discord account token
|
|
const root = (api.config as Record<string, unknown>) || {};
|
|
const channels = (root.channels as Record<string, unknown>) || {};
|
|
const discord = (channels.discord as Record<string, unknown>) || {};
|
|
const accounts = (discord.accounts as Record<string, Record<string, unknown>>) || {};
|
|
for (const rec of Object.values(accounts)) {
|
|
if (typeof rec?.token === "string" && rec.token) return rec.token;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Returns agentIds for all agents visible in the channel, resolved via the identity registry.
|
|
*/
|
|
export async function fetchVisibleChannelBotAccountIds(
|
|
api: OpenClawPluginApi,
|
|
channelId: string,
|
|
identityRegistry?: IdentityRegistry,
|
|
): Promise<string[]> {
|
|
const token = getDiscoveryToken(api);
|
|
if (!token) return [];
|
|
|
|
const ch = await discordRequest(token, "GET", `/channels/${channelId}`);
|
|
if (!ch.ok) return [];
|
|
const guildId = String(ch.json?.guild_id || "");
|
|
if (!guildId) return [];
|
|
|
|
const rolesResp = await discordRequest(token, "GET", `/guilds/${guildId}/roles`);
|
|
if (!rolesResp.ok) return [];
|
|
const rolePerms = new Map<string, bigint>();
|
|
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 [];
|
|
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 visibleUserIds = members
|
|
.filter((m) => canViewChannel(m, guildId, rolePerms, overwrites))
|
|
.map((m) => String(m?.user?.id || ""))
|
|
.filter(Boolean);
|
|
|
|
const out = new Set<string>();
|
|
if (identityRegistry) {
|
|
const discordToAgent = identityRegistry.buildDiscordToAgentMap();
|
|
for (const uid of visibleUserIds) {
|
|
const aid = discordToAgent.get(uid);
|
|
if (aid) out.add(aid);
|
|
}
|
|
}
|
|
return [...out];
|
|
}
|