From d1984744156328961ecfb1ef178f0f2d494b151f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 22:51:05 +0000 Subject: [PATCH] feat: resolve allowlists in channel plugins --- extensions/matrix/src/onboarding.ts | 110 +++++++++++++++------- extensions/msteams/src/onboarding.ts | 93 +++++++++++++++++++ extensions/zalo/src/onboarding.ts | 11 +++ extensions/zalouser/src/onboarding.ts | 128 +++++++++++++++++--------- 4 files changed, 270 insertions(+), 72 deletions(-) diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts index 121e3815a..5dba54238 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/onboarding.ts @@ -7,6 +7,7 @@ import { type WizardPrompter, } from "clawdbot/plugin-sdk"; import { listMatrixDirectoryGroupsLive } from "./directory-live.js"; +import { listMatrixDirectoryPeersLive } from "./directory-live.js"; import { resolveMatrixAccount } from "./matrix/accounts.js"; import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js"; import type { CoreConfig, DmPolicy } from "./types.js"; @@ -49,40 +50,86 @@ async function promptMatrixAllowFrom(params: { }): Promise { const { cfg, prompter } = params; const existingAllowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? []; - const entry = await prompter.text({ - message: "Matrix allowFrom (user id)", - placeholder: "@user:server", - initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) return "Required"; - if (!raw.startsWith("@")) return "Matrix user IDs should start with @"; - if (!raw.includes(":")) return "Matrix user IDs should include a server (:@server)"; - return undefined; - }, - }); - const normalized = String(entry).trim(); - const merged = [ - ...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean), - normalized, - ]; - const unique = [...new Set(merged)]; + const account = resolveMatrixAccount({ cfg }); + const canResolve = Boolean(account.configured); - return { - ...cfg, - channels: { - ...cfg.channels, - matrix: { - ...cfg.channels?.matrix, - enabled: true, - dm: { - ...cfg.channels?.matrix?.dm, - policy: "allowlist", - allowFrom: unique, + const parseInput = (raw: string) => + raw + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); + + const isFullUserId = (value: string) => value.startsWith("@") && value.includes(":"); + + while (true) { + const entry = await prompter.text({ + message: "Matrix allowFrom (username or user id)", + placeholder: "@user:server", + initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + const parts = parseInput(String(entry)); + const resolvedIds: string[] = []; + let unresolved: string[] = []; + + for (const part of parts) { + if (isFullUserId(part)) { + resolvedIds.push(part); + continue; + } + if (!canResolve) { + unresolved.push(part); + continue; + } + const results = await listMatrixDirectoryPeersLive({ + cfg, + query: part, + limit: 5, + }).catch(() => []); + const match = results.find((result) => result.id); + if (match?.id) { + resolvedIds.push(match.id); + if (results.length > 1) { + await prompter.note( + `Multiple matches for "${part}", using ${match.id}.`, + "Matrix allowlist", + ); + } + } else { + unresolved.push(part); + } + } + + if (unresolved.length > 0) { + await prompter.note( + `Could not resolve: ${unresolved.join(", ")}. Use full @user:server IDs.`, + "Matrix allowlist", + ); + continue; + } + + const unique = [ + ...new Set([ + ...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean), + ...resolvedIds, + ]), + ]; + return { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...cfg.channels?.matrix, + enabled: true, + dm: { + ...cfg.channels?.matrix?.dm, + policy: "allowlist", + allowFrom: unique, + }, }, }, - }, - }; + }; + } } function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist" | "disabled") { @@ -121,6 +168,7 @@ const dmPolicy: ChannelOnboardingDmPolicy = { allowFromKey: "channels.matrix.dm.allowFrom", getCurrent: (cfg) => (cfg as CoreConfig).channels?.matrix?.dm?.policy ?? "pairing", setPolicy: (cfg, policy) => setMatrixDmPolicy(cfg as CoreConfig, policy), + promptAllowFrom: promptMatrixAllowFrom, }; export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { diff --git a/extensions/msteams/src/onboarding.ts b/extensions/msteams/src/onboarding.ts index 34aaedbf6..8e4b071ec 100644 --- a/extensions/msteams/src/onboarding.ts +++ b/extensions/msteams/src/onboarding.ts @@ -16,6 +16,7 @@ import { resolveMSTeamsCredentials } from "./token.js"; import { parseMSTeamsTeamEntry, resolveMSTeamsChannelAllowlist, + resolveMSTeamsUserAllowlist, } from "./resolve-allowlist.js"; const channel = "msteams" as const; @@ -38,6 +39,97 @@ function setMSTeamsDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) { }; } +function setMSTeamsAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig { + return { + ...cfg, + channels: { + ...cfg.channels, + msteams: { + ...cfg.channels?.msteams, + allowFrom, + }, + }, + }; +} + +function parseAllowFromInput(raw: string): string[] { + return raw + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function looksLikeGuid(value: string): boolean { + return /^[0-9a-fA-F-]{16,}$/.test(value); +} + +async function promptMSTeamsAllowFrom(params: { + cfg: ClawdbotConfig; + prompter: WizardPrompter; +}): Promise { + const existing = params.cfg.channels?.msteams?.allowFrom ?? []; + await params.prompter.note( + [ + "Allowlist MS Teams DMs by display name, UPN/email, or user id.", + "We resolve names to user IDs via Microsoft Graph when credentials allow.", + "Examples:", + "- alex@example.com", + "- Alex Johnson", + "- 00000000-0000-0000-0000-000000000000", + ].join("\n"), + "MS Teams allowlist", + ); + + while (true) { + const entry = await params.prompter.text({ + message: "MS Teams allowFrom (usernames or ids)", + placeholder: "alex@example.com, Alex Johnson", + initialValue: existing[0] ? String(existing[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + const parts = parseAllowFromInput(String(entry)); + if (parts.length === 0) { + await params.prompter.note("Enter at least one user.", "MS Teams allowlist"); + continue; + } + + const resolved = await resolveMSTeamsUserAllowlist({ + cfg: params.cfg, + entries: parts, + }).catch(() => null); + + if (!resolved) { + const ids = parts.filter((part) => looksLikeGuid(part)); + if (ids.length !== parts.length) { + await params.prompter.note( + "Graph lookup unavailable. Use user IDs only.", + "MS Teams allowlist", + ); + continue; + } + const unique = [ + ...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids]), + ]; + return setMSTeamsAllowFrom(params.cfg, unique); + } + + const unresolved = resolved.filter((item) => !item.resolved || !item.id); + if (unresolved.length > 0) { + await params.prompter.note( + `Could not resolve: ${unresolved.map((item) => item.input).join(", ")}`, + "MS Teams allowlist", + ); + continue; + } + + const ids = resolved.map((item) => item.id as string); + const unique = [ + ...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids]), + ]; + return setMSTeamsAllowFrom(params.cfg, unique); + } +} + async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise { await prompter.note( [ @@ -106,6 +198,7 @@ const dmPolicy: ChannelOnboardingDmPolicy = { allowFromKey: "channels.msteams.allowFrom", getCurrent: (cfg) => cfg.channels?.msteams?.dmPolicy ?? "pairing", setPolicy: (cfg, policy) => setMSTeamsDmPolicy(cfg, policy), + promptAllowFrom: promptMSTeamsAllowFrom, }; export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = { diff --git a/extensions/zalo/src/onboarding.ts b/extensions/zalo/src/onboarding.ts index 82b427551..4cb681dbe 100644 --- a/extensions/zalo/src/onboarding.ts +++ b/extensions/zalo/src/onboarding.ts @@ -207,6 +207,17 @@ const dmPolicy: ChannelOnboardingDmPolicy = { allowFromKey: "channels.zalo.allowFrom", getCurrent: (cfg) => (cfg.channels?.zalo?.dmPolicy ?? "pairing") as "pairing", setPolicy: (cfg, policy) => setZaloDmPolicy(cfg as ClawdbotConfig, policy), + promptAllowFrom: async ({ cfg, prompter, accountId }) => { + const id = + accountId && normalizeAccountId(accountId) + ? normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID + : resolveDefaultZaloAccountId(cfg as ClawdbotConfig); + return promptZaloAllowFrom({ + cfg: cfg as ClawdbotConfig, + prompter, + accountId: id, + }); + }, }; export const zaloOnboardingAdapter: ChannelOnboardingAdapter = { diff --git a/extensions/zalouser/src/onboarding.ts b/extensions/zalouser/src/onboarding.ts index 1717b535b..a5c205015 100644 --- a/extensions/zalouser/src/onboarding.ts +++ b/extensions/zalouser/src/onboarding.ts @@ -19,7 +19,7 @@ import { checkZcaAuthenticated, } from "./accounts.js"; import { runZca, runZcaInteractive, checkZcaInstalled, parseJsonOutput } from "./zca.js"; -import type { ZcaGroup } from "./types.js"; +import type { ZcaFriend, ZcaGroup } from "./types.js"; const channel = "zalouser" as const; @@ -67,25 +67,73 @@ async function promptZalouserAllowFrom(params: { const { cfg, prompter, accountId } = params; const resolved = resolveZalouserAccountSync({ cfg, accountId }); const existingAllowFrom = resolved.config.allowFrom ?? []; - const entry = await prompter.text({ - message: "Zalouser allowFrom (user id)", - placeholder: "123456789", - initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) return "Required"; - if (!/^\d+$/.test(raw)) return "Use a numeric Zalo user id"; - return undefined; - }, - }); - const normalized = String(entry).trim(); - const merged = [ - ...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean), - normalized, - ]; - const unique = [...new Set(merged)]; + const parseInput = (raw: string) => + raw + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); + + const resolveUserId = async (input: string): Promise => { + const trimmed = input.trim(); + if (!trimmed) return null; + if (/^\d+$/.test(trimmed)) return trimmed; + const ok = await checkZcaInstalled(); + if (!ok) return null; + const result = await runZca(["friend", "find", trimmed], { + profile: resolved.profile, + timeout: 15000, + }); + if (!result.ok) return null; + const parsed = parseJsonOutput(result.stdout); + const rows = Array.isArray(parsed) ? parsed : []; + const match = rows[0]; + if (!match?.userId) return null; + if (rows.length > 1) { + await prompter.note( + `Multiple matches for "${trimmed}", using ${match.displayName ?? match.userId}.`, + "Zalo Personal allowlist", + ); + } + return String(match.userId); + }; + + while (true) { + const entry = await prompter.text({ + message: "Zalouser allowFrom (username or user id)", + placeholder: "Alice, 123456789", + initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + const parts = parseInput(String(entry)); + const results = await Promise.all(parts.map((part) => resolveUserId(part))); + const unresolved = parts.filter((_, idx) => !results[idx]); + if (unresolved.length > 0) { + await prompter.note( + `Could not resolve: ${unresolved.join(", ")}. Use numeric user ids or ensure zca is available.`, + "Zalo Personal allowlist", + ); + continue; + } + const merged = [ + ...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean), + ...(results.filter(Boolean) as string[]), + ]; + const unique = [...new Set(merged)]; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + zalouser: { + ...cfg.channels?.zalouser, + enabled: true, + dmPolicy: "allowlist", + allowFrom: unique, + }, + }, + } as ClawdbotConfig; + } - if (accountId === DEFAULT_ACCOUNT_ID) { return { ...cfg, channels: { @@ -93,32 +141,19 @@ async function promptZalouserAllowFrom(params: { zalouser: { ...cfg.channels?.zalouser, enabled: true, - dmPolicy: "allowlist", - allowFrom: unique, + accounts: { + ...(cfg.channels?.zalouser?.accounts ?? {}), + [accountId]: { + ...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}), + enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true, + dmPolicy: "allowlist", + allowFrom: unique, + }, + }, }, }, } as ClawdbotConfig; } - - return { - ...cfg, - channels: { - ...cfg.channels, - zalouser: { - ...cfg.channels?.zalouser, - enabled: true, - accounts: { - ...(cfg.channels?.zalouser?.accounts ?? {}), - [accountId]: { - ...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}), - enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true, - dmPolicy: "allowlist", - allowFrom: unique, - }, - }, - }, - }, - } as ClawdbotConfig; } function setZalouserGroupPolicy( @@ -237,6 +272,17 @@ const dmPolicy: ChannelOnboardingDmPolicy = { allowFromKey: "channels.zalouser.allowFrom", getCurrent: (cfg) => ((cfg as ClawdbotConfig).channels?.zalouser?.dmPolicy ?? "pairing") as "pairing", setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as ClawdbotConfig, policy), + promptAllowFrom: async ({ cfg, prompter, accountId }) => { + const id = + accountId && normalizeAccountId(accountId) + ? normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID + : resolveDefaultZalouserAccountId(cfg as ClawdbotConfig); + return promptZalouserAllowFrom({ + cfg: cfg as ClawdbotConfig, + prompter, + accountId: id, + }); + }, }; export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {