refactor: unify extension allowlist resolver and directory scaffolding

This commit is contained in:
Peter Steinberger 2026-03-07 22:13:54 +00:00
parent 8e0e76697a
commit 7230b96cc7
10 changed files with 398 additions and 223 deletions

View File

@ -0,0 +1,40 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
import { describe, expect, it, vi } from "vitest";
vi.mock("./accounts.js", () => ({
resolveFeishuAccount: vi.fn(() => ({
configured: false,
config: {
allowFrom: ["user:alice", "user:bob"],
dms: {
"user:carla": {},
},
groups: {
"chat-1": {},
},
groupAllowFrom: ["chat-2"],
},
})),
}));
import { listFeishuDirectoryGroups, listFeishuDirectoryPeers } from "./directory.js";
describe("feishu directory (config-backed)", () => {
const cfg = {} as ClawdbotConfig;
it("merges allowFrom + dms into peer entries", async () => {
const peers = await listFeishuDirectoryPeers({ cfg, query: "a" });
expect(peers).toEqual([
{ kind: "user", id: "alice" },
{ kind: "user", id: "carla" },
]);
});
it("merges groups map + groupAllowFrom into group entries", async () => {
const groups = await listFeishuDirectoryGroups({ cfg });
expect(groups).toEqual([
{ kind: "group", id: "chat-1" },
{ kind: "group", id: "chat-2" },
]);
});
});

View File

@ -1,3 +1,7 @@
import {
listDirectoryGroupEntriesFromMapKeysAndAllowFrom,
listDirectoryUserEntriesFromAllowFromAndMapKeys,
} from "openclaw/plugin-sdk";
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
@ -22,31 +26,14 @@ export async function listFeishuDirectoryPeers(params: {
accountId?: string;
}): Promise<FeishuDirectoryPeer[]> {
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
const feishuCfg = account.config;
const q = params.query?.trim().toLowerCase() || "";
const ids = new Set<string>();
for (const entry of feishuCfg?.allowFrom ?? []) {
const trimmed = String(entry).trim();
if (trimmed && trimmed !== "*") {
ids.add(trimmed);
}
}
for (const userId of Object.keys(feishuCfg?.dms ?? {})) {
const trimmed = userId.trim();
if (trimmed) {
ids.add(trimmed);
}
}
return Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.map((raw) => normalizeFeishuTarget(raw) ?? raw)
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
.map((id) => ({ kind: "user" as const, id }));
return listDirectoryUserEntriesFromAllowFromAndMapKeys({
allowFrom: account.config.allowFrom,
map: account.config.dms,
query: params.query,
limit: params.limit,
normalizeAllowFromId: (entry) => normalizeFeishuTarget(entry) ?? entry,
normalizeMapKeyId: (entry) => normalizeFeishuTarget(entry) ?? entry,
});
}
export async function listFeishuDirectoryGroups(params: {
@ -56,30 +43,12 @@ export async function listFeishuDirectoryGroups(params: {
accountId?: string;
}): Promise<FeishuDirectoryGroup[]> {
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
const feishuCfg = account.config;
const q = params.query?.trim().toLowerCase() || "";
const ids = new Set<string>();
for (const groupId of Object.keys(feishuCfg?.groups ?? {})) {
const trimmed = groupId.trim();
if (trimmed && trimmed !== "*") {
ids.add(trimmed);
}
}
for (const entry of feishuCfg?.groupAllowFrom ?? []) {
const trimmed = String(entry).trim();
if (trimmed && trimmed !== "*") {
ids.add(trimmed);
}
}
return Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
.map((id) => ({ kind: "group" as const, id }));
return listDirectoryGroupEntriesFromMapKeysAndAllowFrom({
groups: account.config.groups,
allowFrom: account.config.groupAllowFrom,
query: params.query,
limit: params.limit,
});
}
export async function listFeishuDirectoryPeersLive(params: {

View File

@ -1,3 +1,4 @@
import { mapAllowlistResolutionInputs } from "openclaw/plugin-sdk";
import type {
ChannelDirectoryEntry,
ChannelResolveKind,
@ -71,56 +72,54 @@ export async function resolveMatrixTargets(params: {
kind: ChannelResolveKind;
runtime?: RuntimeEnv;
}): Promise<ChannelResolveResult[]> {
const results: ChannelResolveResult[] = [];
for (const input of params.inputs) {
const trimmed = input.trim();
if (!trimmed) {
results.push({ input, resolved: false, note: "empty input" });
continue;
}
if (params.kind === "user") {
if (trimmed.startsWith("@") && trimmed.includes(":")) {
results.push({ input, resolved: true, id: trimmed });
continue;
return await mapAllowlistResolutionInputs({
inputs: params.inputs,
mapInput: async (input): Promise<ChannelResolveResult> => {
const trimmed = input.trim();
if (!trimmed) {
return { input, resolved: false, note: "empty input" };
}
if (params.kind === "user") {
if (trimmed.startsWith("@") && trimmed.includes(":")) {
return { input, resolved: true, id: trimmed };
}
try {
const matches = await listMatrixDirectoryPeersLive({
cfg: params.cfg,
query: trimmed,
limit: 5,
});
const best = pickBestUserMatch(matches, trimmed);
return {
input,
resolved: Boolean(best?.id),
id: best?.id,
name: best?.name,
note: best ? undefined : describeUserMatchFailure(matches, trimmed),
};
} catch (err) {
params.runtime?.error?.(`matrix resolve failed: ${String(err)}`);
return { input, resolved: false, note: "lookup failed" };
}
}
try {
const matches = await listMatrixDirectoryPeersLive({
const matches = await listMatrixDirectoryGroupsLive({
cfg: params.cfg,
query: trimmed,
limit: 5,
});
const best = pickBestUserMatch(matches, trimmed);
results.push({
const best = pickBestGroupMatch(matches, trimmed);
return {
input,
resolved: Boolean(best?.id),
id: best?.id,
name: best?.name,
note: best ? undefined : describeUserMatchFailure(matches, trimmed),
});
note: matches.length > 1 ? "multiple matches; chose first" : undefined,
};
} catch (err) {
params.runtime?.error?.(`matrix resolve failed: ${String(err)}`);
results.push({ input, resolved: false, note: "lookup failed" });
return { input, resolved: false, note: "lookup failed" };
}
continue;
}
try {
const matches = await listMatrixDirectoryGroupsLive({
cfg: params.cfg,
query: trimmed,
limit: 5,
});
const best = pickBestGroupMatch(matches, trimmed);
results.push({
input,
resolved: Boolean(best?.id),
id: best?.id,
name: best?.name,
note: matches.length > 1 ? "multiple matches; chose first" : undefined,
});
} catch (err) {
params.runtime?.error?.(`matrix resolve failed: ${String(err)}`);
results.push({ input, resolved: false, note: "lookup failed" });
}
}
return results;
},
});
}

View File

@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";
const {
listTeamsByName,
listChannelsForTeam,
normalizeQuery,
resolveGraphToken,
searchGraphUsers,
} = vi.hoisted(() => ({
listTeamsByName: vi.fn(),
listChannelsForTeam: vi.fn(),
normalizeQuery: vi.fn((value: string) => value.trim().toLowerCase()),
resolveGraphToken: vi.fn(async () => "graph-token"),
searchGraphUsers: vi.fn(),
}));
vi.mock("./graph.js", () => ({
listTeamsByName,
listChannelsForTeam,
normalizeQuery,
resolveGraphToken,
}));
vi.mock("./graph-users.js", () => ({
searchGraphUsers,
}));
import {
resolveMSTeamsChannelAllowlist,
resolveMSTeamsUserAllowlist,
} from "./resolve-allowlist.js";
describe("resolveMSTeamsUserAllowlist", () => {
it("marks empty input unresolved", async () => {
const [result] = await resolveMSTeamsUserAllowlist({ cfg: {}, entries: [" "] });
expect(result).toEqual({ input: " ", resolved: false });
});
it("resolves first Graph user match", async () => {
searchGraphUsers.mockResolvedValueOnce([
{ id: "user-1", displayName: "Alice One" },
{ id: "user-2", displayName: "Alice Two" },
]);
const [result] = await resolveMSTeamsUserAllowlist({ cfg: {}, entries: ["alice"] });
expect(result).toEqual({
input: "alice",
resolved: true,
id: "user-1",
name: "Alice One",
note: "multiple matches; chose first",
});
});
});
describe("resolveMSTeamsChannelAllowlist", () => {
it("resolves team/channel by team name + channel display name", async () => {
listTeamsByName.mockResolvedValueOnce([{ id: "team-1", displayName: "Product Team" }]);
listChannelsForTeam.mockResolvedValueOnce([
{ id: "channel-1", displayName: "General" },
{ id: "channel-2", displayName: "Roadmap" },
]);
const [result] = await resolveMSTeamsChannelAllowlist({
cfg: {},
entries: ["Product Team/Roadmap"],
});
expect(result).toEqual({
input: "Product Team/Roadmap",
resolved: true,
teamId: "team-1",
teamName: "Product Team",
channelId: "channel-2",
channelName: "Roadmap",
note: "multiple channels; chose first",
});
});
});

View File

@ -1,3 +1,4 @@
import { mapAllowlistResolutionInputs } from "openclaw/plugin-sdk";
import { searchGraphUsers } from "./graph-users.js";
import {
listChannelsForTeam,
@ -105,61 +106,55 @@ export async function resolveMSTeamsChannelAllowlist(params: {
entries: string[];
}): Promise<MSTeamsChannelResolution[]> {
const token = await resolveGraphToken(params.cfg);
const results: MSTeamsChannelResolution[] = [];
for (const input of params.entries) {
const { team, channel } = parseMSTeamsTeamChannelInput(input);
if (!team) {
results.push({ input, resolved: false });
continue;
}
const teams = /^[0-9a-fA-F-]{16,}$/.test(team)
? [{ id: team, displayName: team }]
: await listTeamsByName(token, team);
if (teams.length === 0) {
results.push({ input, resolved: false, note: "team not found" });
continue;
}
const teamMatch = teams[0];
const teamId = teamMatch.id?.trim();
const teamName = teamMatch.displayName?.trim() || team;
if (!teamId) {
results.push({ input, resolved: false, note: "team id missing" });
continue;
}
if (!channel) {
results.push({
return await mapAllowlistResolutionInputs({
inputs: params.entries,
mapInput: async (input): Promise<MSTeamsChannelResolution> => {
const { team, channel } = parseMSTeamsTeamChannelInput(input);
if (!team) {
return { input, resolved: false };
}
const teams = /^[0-9a-fA-F-]{16,}$/.test(team)
? [{ id: team, displayName: team }]
: await listTeamsByName(token, team);
if (teams.length === 0) {
return { input, resolved: false, note: "team not found" };
}
const teamMatch = teams[0];
const teamId = teamMatch.id?.trim();
const teamName = teamMatch.displayName?.trim() || team;
if (!teamId) {
return { input, resolved: false, note: "team id missing" };
}
if (!channel) {
return {
input,
resolved: true,
teamId,
teamName,
note: teams.length > 1 ? "multiple teams; chose first" : undefined,
};
}
const channels = await listChannelsForTeam(token, teamId);
const channelMatch =
channels.find((item) => item.id === channel) ??
channels.find((item) => item.displayName?.toLowerCase() === channel.toLowerCase()) ??
channels.find((item) =>
item.displayName?.toLowerCase().includes(channel.toLowerCase() ?? ""),
);
if (!channelMatch?.id) {
return { input, resolved: false, note: "channel not found" };
}
return {
input,
resolved: true,
teamId,
teamName,
note: teams.length > 1 ? "multiple teams; chose first" : undefined,
});
continue;
}
const channels = await listChannelsForTeam(token, teamId);
const channelMatch =
channels.find((item) => item.id === channel) ??
channels.find((item) => item.displayName?.toLowerCase() === channel.toLowerCase()) ??
channels.find((item) =>
item.displayName?.toLowerCase().includes(channel.toLowerCase() ?? ""),
);
if (!channelMatch?.id) {
results.push({ input, resolved: false, note: "channel not found" });
continue;
}
results.push({
input,
resolved: true,
teamId,
teamName,
channelId: channelMatch.id,
channelName: channelMatch.displayName ?? channel,
note: channels.length > 1 ? "multiple channels; chose first" : undefined,
});
}
return results;
channelId: channelMatch.id,
channelName: channelMatch.displayName ?? channel,
note: channels.length > 1 ? "multiple channels; chose first" : undefined,
};
},
});
}
export async function resolveMSTeamsUserAllowlist(params: {
@ -167,32 +162,28 @@ export async function resolveMSTeamsUserAllowlist(params: {
entries: string[];
}): Promise<MSTeamsUserResolution[]> {
const token = await resolveGraphToken(params.cfg);
const results: MSTeamsUserResolution[] = [];
for (const input of params.entries) {
const query = normalizeQuery(normalizeMSTeamsUserInput(input));
if (!query) {
results.push({ input, resolved: false });
continue;
}
if (/^[0-9a-fA-F-]{16,}$/.test(query)) {
results.push({ input, resolved: true, id: query });
continue;
}
const users = await searchGraphUsers({ token, query, top: 10 });
const match = users[0];
if (!match?.id) {
results.push({ input, resolved: false });
continue;
}
results.push({
input,
resolved: true,
id: match.id,
name: match.displayName ?? undefined,
note: users.length > 1 ? "multiple matches; chose first" : undefined,
});
}
return results;
return await mapAllowlistResolutionInputs({
inputs: params.entries,
mapInput: async (input): Promise<MSTeamsUserResolution> => {
const query = normalizeQuery(normalizeMSTeamsUserInput(input));
if (!query) {
return { input, resolved: false };
}
if (/^[0-9a-fA-F-]{16,}$/.test(query)) {
return { input, resolved: true, id: query };
}
const users = await searchGraphUsers({ token, query, top: 10 });
const match = users[0];
if (!match?.id) {
return { input, resolved: false };
}
return {
input,
resolved: true,
id: match.id,
name: match.displayName ?? undefined,
note: users.length > 1 ? "multiple matches; chose first" : undefined,
};
},
});
}

View File

@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
listDirectoryGroupEntriesFromMapKeysAndAllowFrom,
listDirectoryGroupEntriesFromMapKeys,
listDirectoryUserEntriesFromAllowFromAndMapKeys,
listDirectoryUserEntriesFromAllowFrom,
} from "./directory-config-helpers.js";
@ -37,3 +39,41 @@ describe("listDirectoryGroupEntriesFromMapKeys", () => {
]);
});
});
describe("listDirectoryUserEntriesFromAllowFromAndMapKeys", () => {
it("merges allowFrom and map keys with dedupe/query/limit", () => {
const entries = listDirectoryUserEntriesFromAllowFromAndMapKeys({
allowFrom: ["user:alice", "user:bob"],
map: {
"user:carla": {},
"user:alice": {},
},
normalizeAllowFromId: (entry) => entry.replace(/^user:/i, ""),
normalizeMapKeyId: (entry) => entry.replace(/^user:/i, ""),
query: "a",
limit: 2,
});
expect(entries).toEqual([
{ kind: "user", id: "alice" },
{ kind: "user", id: "carla" },
]);
});
});
describe("listDirectoryGroupEntriesFromMapKeysAndAllowFrom", () => {
it("merges groups keys and group allowFrom entries", () => {
const entries = listDirectoryGroupEntriesFromMapKeysAndAllowFrom({
groups: {
"team/a": {},
},
allowFrom: ["team/b", "team/a"],
query: "team/",
});
expect(entries).toEqual([
{ kind: "group", id: "team/a" },
{ kind: "group", id: "team/b" },
]);
});
});

View File

@ -22,44 +22,106 @@ function toDirectoryEntries(kind: "user" | "group", ids: string[]): ChannelDirec
return ids.map((id) => ({ kind, id }) as const);
}
function collectDirectoryIdsFromEntries(params: {
entries?: readonly unknown[];
normalizeId?: (entry: string) => string | null | undefined;
}): string[] {
return (params.entries ?? [])
.map((entry) => String(entry).trim())
.filter((entry) => Boolean(entry) && entry !== "*")
.map((entry) => {
const normalized = params.normalizeId ? params.normalizeId(entry) : entry;
return typeof normalized === "string" ? normalized.trim() : "";
})
.filter(Boolean);
}
function collectDirectoryIdsFromMapKeys(params: {
groups?: Record<string, unknown>;
normalizeId?: (entry: string) => string | null | undefined;
}): string[] {
return Object.keys(params.groups ?? {})
.map((entry) => entry.trim())
.filter((entry) => Boolean(entry) && entry !== "*")
.map((entry) => {
const normalized = params.normalizeId ? params.normalizeId(entry) : entry;
return typeof normalized === "string" ? normalized.trim() : "";
})
.filter(Boolean);
}
function dedupeDirectoryIds(ids: string[]): string[] {
return Array.from(new Set(ids));
}
export function listDirectoryUserEntriesFromAllowFrom(params: {
allowFrom?: readonly unknown[];
query?: string | null;
limit?: number | null;
normalizeId?: (entry: string) => string | null | undefined;
}): ChannelDirectoryEntry[] {
const ids = Array.from(
new Set(
(params.allowFrom ?? [])
.map((entry) => String(entry).trim())
.filter((entry) => Boolean(entry) && entry !== "*")
.map((entry) => {
const normalized = params.normalizeId ? params.normalizeId(entry) : entry;
return typeof normalized === "string" ? normalized.trim() : "";
})
.filter(Boolean),
),
const ids = dedupeDirectoryIds(
collectDirectoryIdsFromEntries({
entries: params.allowFrom,
normalizeId: params.normalizeId,
}),
);
return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params));
}
export function listDirectoryUserEntriesFromAllowFromAndMapKeys(params: {
allowFrom?: readonly unknown[];
map?: Record<string, unknown>;
query?: string | null;
limit?: number | null;
normalizeAllowFromId?: (entry: string) => string | null | undefined;
normalizeMapKeyId?: (entry: string) => string | null | undefined;
}): ChannelDirectoryEntry[] {
const ids = dedupeDirectoryIds([
...collectDirectoryIdsFromEntries({
entries: params.allowFrom,
normalizeId: params.normalizeAllowFromId,
}),
...collectDirectoryIdsFromMapKeys({
groups: params.map,
normalizeId: params.normalizeMapKeyId,
}),
]);
return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params));
}
export function listDirectoryGroupEntriesFromMapKeys(params: {
groups?: Record<string, unknown>;
query?: string | null;
limit?: number | null;
normalizeId?: (entry: string) => string | null | undefined;
}): ChannelDirectoryEntry[] {
const ids = Array.from(
new Set(
Object.keys(params.groups ?? {})
.map((entry) => entry.trim())
.filter((entry) => Boolean(entry) && entry !== "*")
.map((entry) => {
const normalized = params.normalizeId ? params.normalizeId(entry) : entry;
return typeof normalized === "string" ? normalized.trim() : "";
})
.filter(Boolean),
),
const ids = dedupeDirectoryIds(
collectDirectoryIdsFromMapKeys({
groups: params.groups,
normalizeId: params.normalizeId,
}),
);
return toDirectoryEntries("group", applyDirectoryQueryAndLimit(ids, params));
}
export function listDirectoryGroupEntriesFromMapKeysAndAllowFrom(params: {
groups?: Record<string, unknown>;
allowFrom?: readonly unknown[];
query?: string | null;
limit?: number | null;
normalizeMapKeyId?: (entry: string) => string | null | undefined;
normalizeAllowFromId?: (entry: string) => string | null | undefined;
}): ChannelDirectoryEntry[] {
const ids = dedupeDirectoryIds([
...collectDirectoryIdsFromMapKeys({
groups: params.groups,
normalizeId: params.normalizeMapKeyId,
}),
...collectDirectoryIdsFromEntries({
entries: params.allowFrom,
normalizeId: params.normalizeAllowFromId,
}),
]);
return toDirectoryEntries("group", applyDirectoryQueryAndLimit(ids, params));
}

View File

@ -1,40 +1,18 @@
import { describe, expect, it } from "vitest";
import {
mapBasicAllowlistResolutionEntries,
type BasicAllowlistResolutionEntry,
} from "./allowlist-resolution.js";
import { mapAllowlistResolutionInputs } from "./allowlist-resolution.js";
describe("mapBasicAllowlistResolutionEntries", () => {
it("maps entries to normalized allowlist resolver output", () => {
const entries: BasicAllowlistResolutionEntry[] = [
{
input: "alice",
resolved: true,
id: "U123",
name: "Alice",
note: "ok",
describe("mapAllowlistResolutionInputs", () => {
it("maps inputs sequentially and preserves order", async () => {
const visited: string[] = [];
const result = await mapAllowlistResolutionInputs({
inputs: ["one", "two", "three"],
mapInput: async (input) => {
visited.push(input);
return input.toUpperCase();
},
{
input: "bob",
resolved: false,
},
];
});
expect(mapBasicAllowlistResolutionEntries(entries)).toEqual([
{
input: "alice",
resolved: true,
id: "U123",
name: "Alice",
note: "ok",
},
{
input: "bob",
resolved: false,
id: undefined,
name: undefined,
note: undefined,
},
]);
expect(visited).toEqual(["one", "two", "three"]);
expect(result).toEqual(["ONE", "TWO", "THREE"]);
});
});

View File

@ -17,3 +17,14 @@ export function mapBasicAllowlistResolutionEntries(
note: entry.note,
}));
}
export async function mapAllowlistResolutionInputs<T>(params: {
inputs: string[];
mapInput: (input: string) => Promise<T> | T;
}): Promise<T[]> {
const results: T[] = [];
for (const input of params.inputs) {
results.push(await params.mapInput(input));
}
return results;
}

View File

@ -133,6 +133,7 @@ export { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matchin
export type { FileLockHandle, FileLockOptions } from "./file-lock.js";
export { acquireFileLock, withFileLock } from "./file-lock.js";
export {
mapAllowlistResolutionInputs,
mapBasicAllowlistResolutionEntries,
type BasicAllowlistResolutionEntry,
} from "./allowlist-resolution.js";
@ -515,6 +516,12 @@ export { optionalStringEnum, stringEnum } from "../agents/schema/typebox.js";
export type { PollInput } from "../polls.js";
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
export {
listDirectoryGroupEntriesFromMapKeys,
listDirectoryGroupEntriesFromMapKeysAndAllowFrom,
listDirectoryUserEntriesFromAllowFrom,
listDirectoryUserEntriesFromAllowFromAndMapKeys,
} from "../channels/plugins/directory-config-helpers.js";
export {
clearAccountEntryFields,
deleteAccountFromConfigSection,