feat: resolve allowlists in channel plugins

This commit is contained in:
Peter Steinberger
2026-01-18 22:51:05 +00:00
parent ace8a1b44e
commit d198474415
4 changed files with 270 additions and 72 deletions

View File

@@ -7,6 +7,7 @@ import {
type WizardPrompter, type WizardPrompter,
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
import { listMatrixDirectoryGroupsLive } from "./directory-live.js"; import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
import { listMatrixDirectoryPeersLive } from "./directory-live.js";
import { resolveMatrixAccount } from "./matrix/accounts.js"; import { resolveMatrixAccount } from "./matrix/accounts.js";
import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js"; import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";
import type { CoreConfig, DmPolicy } from "./types.js"; import type { CoreConfig, DmPolicy } from "./types.js";
@@ -49,40 +50,86 @@ async function promptMatrixAllowFrom(params: {
}): Promise<CoreConfig> { }): Promise<CoreConfig> {
const { cfg, prompter } = params; const { cfg, prompter } = params;
const existingAllowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? []; const existingAllowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? [];
const entry = await prompter.text({ const account = resolveMatrixAccount({ cfg });
message: "Matrix allowFrom (user id)", const canResolve = Boolean(account.configured);
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)];
return { const parseInput = (raw: string) =>
...cfg, raw
channels: { .split(/[\n,;]+/g)
...cfg.channels, .map((entry) => entry.trim())
matrix: { .filter(Boolean);
...cfg.channels?.matrix,
enabled: true, const isFullUserId = (value: string) => value.startsWith("@") && value.includes(":");
dm: {
...cfg.channels?.matrix?.dm, while (true) {
policy: "allowlist", const entry = await prompter.text({
allowFrom: unique, 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") { function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist" | "disabled") {
@@ -121,6 +168,7 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
allowFromKey: "channels.matrix.dm.allowFrom", allowFromKey: "channels.matrix.dm.allowFrom",
getCurrent: (cfg) => (cfg as CoreConfig).channels?.matrix?.dm?.policy ?? "pairing", getCurrent: (cfg) => (cfg as CoreConfig).channels?.matrix?.dm?.policy ?? "pairing",
setPolicy: (cfg, policy) => setMatrixDmPolicy(cfg as CoreConfig, policy), setPolicy: (cfg, policy) => setMatrixDmPolicy(cfg as CoreConfig, policy),
promptAllowFrom: promptMatrixAllowFrom,
}; };
export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {

View File

@@ -16,6 +16,7 @@ import { resolveMSTeamsCredentials } from "./token.js";
import { import {
parseMSTeamsTeamEntry, parseMSTeamsTeamEntry,
resolveMSTeamsChannelAllowlist, resolveMSTeamsChannelAllowlist,
resolveMSTeamsUserAllowlist,
} from "./resolve-allowlist.js"; } from "./resolve-allowlist.js";
const channel = "msteams" as const; 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<ClawdbotConfig> {
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<void> { async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note( await prompter.note(
[ [
@@ -106,6 +198,7 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
allowFromKey: "channels.msteams.allowFrom", allowFromKey: "channels.msteams.allowFrom",
getCurrent: (cfg) => cfg.channels?.msteams?.dmPolicy ?? "pairing", getCurrent: (cfg) => cfg.channels?.msteams?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setMSTeamsDmPolicy(cfg, policy), setPolicy: (cfg, policy) => setMSTeamsDmPolicy(cfg, policy),
promptAllowFrom: promptMSTeamsAllowFrom,
}; };
export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = { export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = {

View File

@@ -207,6 +207,17 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
allowFromKey: "channels.zalo.allowFrom", allowFromKey: "channels.zalo.allowFrom",
getCurrent: (cfg) => (cfg.channels?.zalo?.dmPolicy ?? "pairing") as "pairing", getCurrent: (cfg) => (cfg.channels?.zalo?.dmPolicy ?? "pairing") as "pairing",
setPolicy: (cfg, policy) => setZaloDmPolicy(cfg as ClawdbotConfig, policy), 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 = { export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {

View File

@@ -19,7 +19,7 @@ import {
checkZcaAuthenticated, checkZcaAuthenticated,
} from "./accounts.js"; } from "./accounts.js";
import { runZca, runZcaInteractive, checkZcaInstalled, parseJsonOutput } from "./zca.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; const channel = "zalouser" as const;
@@ -67,25 +67,73 @@ async function promptZalouserAllowFrom(params: {
const { cfg, prompter, accountId } = params; const { cfg, prompter, accountId } = params;
const resolved = resolveZalouserAccountSync({ cfg, accountId }); const resolved = resolveZalouserAccountSync({ cfg, accountId });
const existingAllowFrom = resolved.config.allowFrom ?? []; const existingAllowFrom = resolved.config.allowFrom ?? [];
const entry = await prompter.text({ const parseInput = (raw: string) =>
message: "Zalouser allowFrom (user id)", raw
placeholder: "123456789", .split(/[\n,;]+/g)
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, .map((entry) => entry.trim())
validate: (value) => { .filter(Boolean);
const raw = String(value ?? "").trim();
if (!raw) return "Required"; const resolveUserId = async (input: string): Promise<string | null> => {
if (!/^\d+$/.test(raw)) return "Use a numeric Zalo user id"; const trimmed = input.trim();
return undefined; if (!trimmed) return null;
}, if (/^\d+$/.test(trimmed)) return trimmed;
}); const ok = await checkZcaInstalled();
const normalized = String(entry).trim(); if (!ok) return null;
const merged = [ const result = await runZca(["friend", "find", trimmed], {
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean), profile: resolved.profile,
normalized, timeout: 15000,
]; });
const unique = [...new Set(merged)]; if (!result.ok) return null;
const parsed = parseJsonOutput<ZcaFriend[]>(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 { return {
...cfg, ...cfg,
channels: { channels: {
@@ -93,32 +141,19 @@ async function promptZalouserAllowFrom(params: {
zalouser: { zalouser: {
...cfg.channels?.zalouser, ...cfg.channels?.zalouser,
enabled: true, enabled: true,
dmPolicy: "allowlist", accounts: {
allowFrom: unique, ...(cfg.channels?.zalouser?.accounts ?? {}),
[accountId]: {
...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
}, },
}, },
} as ClawdbotConfig; } 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( function setZalouserGroupPolicy(
@@ -237,6 +272,17 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
allowFromKey: "channels.zalouser.allowFrom", allowFromKey: "channels.zalouser.allowFrom",
getCurrent: (cfg) => ((cfg as ClawdbotConfig).channels?.zalouser?.dmPolicy ?? "pairing") as "pairing", getCurrent: (cfg) => ((cfg as ClawdbotConfig).channels?.zalouser?.dmPolicy ?? "pairing") as "pairing",
setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as ClawdbotConfig, policy), 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 = { export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {