Files
Dirigent/plugin/core/channel-members.ts
hzhang b5196e972c feat: rewrite plugin as v2 with globalThis-based turn management
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>
2026-04-08 22:41:25 +01:00

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];
}