From 307235e207382e5c7b747f4409016773ad3dacd6 Mon Sep 17 00:00:00 2001 From: orion Date: Sun, 8 Mar 2026 06:08:51 +0000 Subject: [PATCH] feat(turn-init): use internal Discord channel member resolver for bootstrap (no public tool) --- plugin/core/channel-members.ts | 141 +++++++++++++++++++++++++++ plugin/core/turn-bootstrap.ts | 14 ++- plugin/hooks/before-message-write.ts | 6 +- plugin/hooks/before-model-resolve.ts | 4 +- plugin/hooks/message-received.ts | 8 +- 5 files changed, 162 insertions(+), 11 deletions(-) create mode 100644 plugin/core/channel-members.ts diff --git a/plugin/core/channel-members.ts b/plugin/core/channel-members.ts new file mode 100644 index 0000000..fab4e50 --- /dev/null +++ b/plugin/core/channel-members.ts @@ -0,0 +1,141 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { buildUserIdToAccountIdMap } from "./identity.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, 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 getAnyDiscordToken(api: OpenClawPluginApi): string | undefined { + const root = (api.config as Record) || {}; + const channels = (root.channels as Record) || {}; + const discord = (channels.discord as Record) || {}; + const accounts = (discord.accounts as Record>) || {}; + for (const rec of Object.values(accounts)) { + if (typeof rec?.token === "string" && rec.token) return rec.token; + } + return undefined; +} + +export async function fetchVisibleChannelBotAccountIds(api: OpenClawPluginApi, channelId: string): Promise { + const token = getAnyDiscordToken(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(); + 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 userToAccount = buildUserIdToAccountIdMap(api); + const out = new Set(); + for (const uid of visibleUserIds) { + const aid = userToAccount.get(uid); + if (aid) out.add(aid); + } + return [...out]; +} diff --git a/plugin/core/turn-bootstrap.ts b/plugin/core/turn-bootstrap.ts index 601a63f..960eb9f 100644 --- a/plugin/core/turn-bootstrap.ts +++ b/plugin/core/turn-bootstrap.ts @@ -1,7 +1,9 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { initTurnOrder } from "../turn-manager.js"; +import { fetchVisibleChannelBotAccountIds } from "./channel-members.js"; const channelSeenAccounts = new Map>(); +const channelBootstrapTried = new Set(); function getAllBotAccountIds(api: OpenClawPluginApi): string[] { const root = (api.config as Record) || {}; @@ -35,8 +37,16 @@ export function recordChannelAccount(channelId: string, accountId: string): bool return true; } -export function ensureTurnOrder(api: OpenClawPluginApi, channelId: string): void { - const botAccounts = getChannelBotAccountIds(api, channelId); +export async function ensureTurnOrder(api: OpenClawPluginApi, channelId: string): Promise { + let botAccounts = getChannelBotAccountIds(api, channelId); + + if (botAccounts.length === 0 && !channelBootstrapTried.has(channelId)) { + channelBootstrapTried.add(channelId); + const discovered = await fetchVisibleChannelBotAccountIds(api, channelId).catch(() => [] as string[]); + for (const aid of discovered) recordChannelAccount(channelId, aid); + botAccounts = getChannelBotAccountIds(api, channelId); + } + if (botAccounts.length > 0) { initTurnOrder(channelId, botAccounts); } diff --git a/plugin/hooks/before-message-write.ts b/plugin/hooks/before-message-write.ts index 94e2455..faee8ba 100644 --- a/plugin/hooks/before-message-write.ts +++ b/plugin/hooks/before-message-write.ts @@ -18,7 +18,7 @@ type BeforeMessageWriteDeps = { ensurePolicyStateLoaded: (api: OpenClawPluginApi, config: DirigentConfig) => void; getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig; shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean; - ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => void; + ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise | void; resolveDiscordUserId: (api: OpenClawPluginApi, accountId: string) => string | undefined; sendModeratorMessage: ( botToken: string, @@ -137,7 +137,7 @@ export function registerBeforeMessageWriteHook(deps: BeforeMessageWriteDeps): vo return; } - ensureTurnOrder(api, channelId); + void ensureTurnOrder(api, channelId); const nextSpeaker = onSpeakerDone(channelId, accountId, true); sessionAllowed.delete(key); sessionTurnHandled.add(key); @@ -166,7 +166,7 @@ export function registerBeforeMessageWriteHook(deps: BeforeMessageWriteDeps): vo } } } else if (hasEndSymbol) { - ensureTurnOrder(api, channelId); + void ensureTurnOrder(api, channelId); const nextSpeaker = onSpeakerDone(channelId, accountId, false); sessionAllowed.delete(key); sessionTurnHandled.add(key); diff --git a/plugin/hooks/before-model-resolve.ts b/plugin/hooks/before-model-resolve.ts index 589bcc3..0b551f6 100644 --- a/plugin/hooks/before-model-resolve.ts +++ b/plugin/hooks/before-model-resolve.ts @@ -28,7 +28,7 @@ type BeforeModelResolveDeps = { resolveAccountId: (api: OpenClawPluginApi, agentId: string) => string | undefined; pruneDecisionMap: () => void; shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean; - ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => void; + ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise | void; }; export function registerBeforeModelResolveHook(deps: BeforeModelResolveDeps): void { @@ -111,7 +111,7 @@ export function registerBeforeModelResolveHook(deps: BeforeModelResolveDeps): vo } if (derived.channelId) { - ensureTurnOrder(api, derived.channelId); + await ensureTurnOrder(api, derived.channelId); const accountId = resolveAccountId(api, ctx.agentId || ""); if (accountId) { const turnCheck = checkTurn(derived.channelId, accountId); diff --git a/plugin/hooks/message-received.ts b/plugin/hooks/message-received.ts index 132ca95..584f58d 100644 --- a/plugin/hooks/message-received.ts +++ b/plugin/hooks/message-received.ts @@ -14,7 +14,7 @@ type MessageReceivedDeps = { getLivePluginConfig: (api: OpenClawPluginApi, fallback: DirigentConfig) => DirigentConfig; shouldDebugLog: (config: DirigentConfig & DebugConfig, channelId?: string) => boolean; debugCtxSummary: (ctx: Record, event: Record) => Record; - ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => void; + ensureTurnOrder: (api: OpenClawPluginApi, channelId: string) => Promise | void; getModeratorUserId: (cfg: DirigentConfig) => string | undefined; recordChannelAccount: (channelId: string, accountId: string) => boolean; extractMentionedUserIds: (content: string) => string[]; @@ -46,7 +46,7 @@ export function registerMessageReceivedHook(deps: MessageReceivedDeps): void { } if (preChannelId) { - ensureTurnOrder(api, preChannelId); + await ensureTurnOrder(api, preChannelId); const metadata = (e as Record).metadata as Record | undefined; const from = (typeof metadata?.senderId === "string" && metadata.senderId) || @@ -65,7 +65,7 @@ export function registerMessageReceivedHook(deps: MessageReceivedDeps): void { if (senderAccountId && senderAccountId !== "default") { const isNew = recordChannelAccount(preChannelId, senderAccountId); if (isNew) { - ensureTurnOrder(api, preChannelId); + await ensureTurnOrder(api, preChannelId); api.logger.info(`dirigent: new account ${senderAccountId} seen in channel=${preChannelId}, turn order updated`); } } @@ -79,7 +79,7 @@ export function registerMessageReceivedHook(deps: MessageReceivedDeps): void { const mentionedAccountIds = mentionedUserIds.map((uid) => userIdMap.get(uid)).filter((aid): aid is string => !!aid); if (mentionedAccountIds.length > 0) { - ensureTurnOrder(api, preChannelId); + await ensureTurnOrder(api, preChannelId); const overrideSet = setMentionOverride(preChannelId, mentionedAccountIds); if (overrideSet) { api.logger.info(