feat(channels): add resolve command + defaults
This commit is contained in:
@@ -8,8 +8,16 @@ import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
|
||||
import { msteamsOnboardingAdapter } from "./onboarding.js";
|
||||
import { msteamsOutbound } from "./outbound.js";
|
||||
import { probeMSTeams } from "./probe.js";
|
||||
import {
|
||||
resolveMSTeamsChannelAllowlist,
|
||||
resolveMSTeamsUserAllowlist,
|
||||
} from "./resolve-allowlist.js";
|
||||
import { sendMessageMSTeams } from "./send.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
import {
|
||||
listMSTeamsDirectoryGroupsLive,
|
||||
listMSTeamsDirectoryPeersLive,
|
||||
} from "./directory-live.js";
|
||||
|
||||
type ResolvedMSTeamsAccount = {
|
||||
accountId: string;
|
||||
@@ -112,7 +120,8 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
},
|
||||
security: {
|
||||
collectWarnings: ({ cfg }) => {
|
||||
const groupPolicy = cfg.channels?.msteams?.groupPolicy ?? "allowlist";
|
||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = cfg.channels?.msteams?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
if (groupPolicy !== "open") return [];
|
||||
return [
|
||||
`- MS Teams groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.msteams.groupPolicy="allowlist" + channels.msteams.groupAllowFrom to restrict senders.`,
|
||||
@@ -189,6 +198,137 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "group", id }) as const);
|
||||
},
|
||||
listPeersLive: async ({ cfg, query, limit }) =>
|
||||
listMSTeamsDirectoryPeersLive({ cfg, query, limit }),
|
||||
listGroupsLive: async ({ cfg, query, limit }) =>
|
||||
listMSTeamsDirectoryGroupsLive({ cfg, query, limit }),
|
||||
},
|
||||
resolver: {
|
||||
resolveTargets: async ({ cfg, inputs, kind, runtime }) => {
|
||||
const results = inputs.map((input) => ({
|
||||
input,
|
||||
resolved: false,
|
||||
id: undefined as string | undefined,
|
||||
name: undefined as string | undefined,
|
||||
note: undefined as string | undefined,
|
||||
}));
|
||||
|
||||
const stripPrefix = (value: string) =>
|
||||
value
|
||||
.replace(/^(msteams|teams):/i, "")
|
||||
.replace(/^(user|conversation):/i, "")
|
||||
.trim();
|
||||
|
||||
if (kind === "user") {
|
||||
const pending: Array<{ input: string; query: string; index: number }> = [];
|
||||
results.forEach((entry, index) => {
|
||||
const trimmed = entry.input.trim();
|
||||
if (!trimmed) {
|
||||
entry.note = "empty input";
|
||||
return;
|
||||
}
|
||||
const cleaned = stripPrefix(trimmed);
|
||||
if (/^[0-9a-fA-F-]{16,}$/.test(cleaned) || cleaned.includes("@")) {
|
||||
entry.resolved = true;
|
||||
entry.id = cleaned;
|
||||
return;
|
||||
}
|
||||
pending.push({ input: entry.input, query: cleaned, index });
|
||||
});
|
||||
|
||||
if (pending.length > 0) {
|
||||
try {
|
||||
const resolved = await resolveMSTeamsUserAllowlist({
|
||||
cfg,
|
||||
entries: pending.map((entry) => entry.query),
|
||||
});
|
||||
resolved.forEach((entry, idx) => {
|
||||
const target = results[pending[idx]?.index ?? -1];
|
||||
if (!target) return;
|
||||
target.resolved = entry.resolved;
|
||||
target.id = entry.id;
|
||||
target.name = entry.name;
|
||||
target.note = entry.note;
|
||||
});
|
||||
} catch (err) {
|
||||
runtime.error?.(`msteams resolve failed: ${String(err)}`);
|
||||
pending.forEach(({ index }) => {
|
||||
const entry = results[index];
|
||||
if (entry) entry.note = "lookup failed";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const pending: Array<{ input: string; query: string; index: number }> = [];
|
||||
results.forEach((entry, index) => {
|
||||
const trimmed = entry.input.trim();
|
||||
if (!trimmed) {
|
||||
entry.note = "empty input";
|
||||
return;
|
||||
}
|
||||
if (/^conversation:/i.test(trimmed)) {
|
||||
const id = trimmed.replace(/^conversation:/i, "").trim();
|
||||
if (id) {
|
||||
entry.resolved = true;
|
||||
entry.id = id;
|
||||
entry.note = "conversation id";
|
||||
} else {
|
||||
entry.note = "empty conversation id";
|
||||
}
|
||||
return;
|
||||
}
|
||||
pending.push({
|
||||
input: entry.input,
|
||||
query: trimmed
|
||||
.replace(/^(msteams|teams):/i, "")
|
||||
.replace(/^team:/i, "")
|
||||
.trim(),
|
||||
index,
|
||||
});
|
||||
});
|
||||
|
||||
if (pending.length > 0) {
|
||||
try {
|
||||
const resolved = await resolveMSTeamsChannelAllowlist({
|
||||
cfg,
|
||||
entries: pending.map((entry) => entry.query),
|
||||
});
|
||||
resolved.forEach((entry, idx) => {
|
||||
const target = results[pending[idx]?.index ?? -1];
|
||||
if (!target) return;
|
||||
if (!entry.resolved || !entry.teamId) {
|
||||
target.resolved = false;
|
||||
target.note = entry.note;
|
||||
return;
|
||||
}
|
||||
target.resolved = true;
|
||||
if (entry.channelId) {
|
||||
target.id = `${entry.teamId}/${entry.channelId}`;
|
||||
target.name =
|
||||
entry.channelName && entry.teamName
|
||||
? `${entry.teamName}/${entry.channelName}`
|
||||
: entry.channelName ?? entry.teamName;
|
||||
} else {
|
||||
target.id = entry.teamId;
|
||||
target.name = entry.teamName;
|
||||
target.note = "team id";
|
||||
}
|
||||
if (entry.note) target.note = entry.note;
|
||||
});
|
||||
} catch (err) {
|
||||
runtime.error?.(`msteams resolve failed: ${String(err)}`);
|
||||
pending.forEach(({ index }) => {
|
||||
const entry = results[index];
|
||||
if (entry) entry.note = "lookup failed";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
listActions: ({ cfg }) => {
|
||||
|
||||
179
extensions/msteams/src/directory-live.ts
Normal file
179
extensions/msteams/src/directory-live.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import type { ChannelDirectoryEntry } from "../../../src/channels/plugins/types.js";
|
||||
|
||||
import { GRAPH_ROOT } from "./attachments/shared.js";
|
||||
import { loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
|
||||
type GraphUser = {
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
userPrincipalName?: string;
|
||||
mail?: string;
|
||||
};
|
||||
|
||||
type GraphGroup = {
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
type GraphChannel = {
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
type GraphResponse<T> = { value?: T[] };
|
||||
|
||||
function readAccessToken(value: unknown): string | null {
|
||||
if (typeof value === "string") return value;
|
||||
if (value && typeof value === "object") {
|
||||
const token =
|
||||
(value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token;
|
||||
return typeof token === "string" ? token : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeQuery(value?: string | null): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
function escapeOData(value: string): string {
|
||||
return value.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
async function fetchGraphJson<T>(params: {
|
||||
token: string;
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
}): Promise<T> {
|
||||
const res = await fetch(`${GRAPH_ROOT}${params.path}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.token}`,
|
||||
...(params.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Graph ${params.path} failed (${res.status}): ${text || "unknown error"}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async function resolveGraphToken(cfg: unknown): Promise<string> {
|
||||
const creds = resolveMSTeamsCredentials((cfg as { channels?: { msteams?: unknown } })?.channels?.msteams);
|
||||
if (!creds) throw new Error("MS Teams credentials missing");
|
||||
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
|
||||
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
|
||||
const token = await tokenProvider.getAccessToken("https://graph.microsoft.com/.default");
|
||||
const accessToken = readAccessToken(token);
|
||||
if (!accessToken) throw new Error("MS Teams graph token unavailable");
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
async function listTeamsByName(token: string, query: string): Promise<GraphGroup[]> {
|
||||
const escaped = escapeOData(query);
|
||||
const filter = `resourceProvisioningOptions/Any(x:x eq 'Team') and startsWith(displayName,'${escaped}')`;
|
||||
const path = `/groups?$filter=${encodeURIComponent(filter)}&$select=id,displayName`;
|
||||
const res = await fetchGraphJson<GraphResponse<GraphGroup>>({ token, path });
|
||||
return res.value ?? [];
|
||||
}
|
||||
|
||||
async function listChannelsForTeam(token: string, teamId: string): Promise<GraphChannel[]> {
|
||||
const path = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName`;
|
||||
const res = await fetchGraphJson<GraphResponse<GraphChannel>>({ token, path });
|
||||
return res.value ?? [];
|
||||
}
|
||||
|
||||
export async function listMSTeamsDirectoryPeersLive(params: {
|
||||
cfg: unknown;
|
||||
query?: string | null;
|
||||
limit?: number | null;
|
||||
}): Promise<ChannelDirectoryEntry[]> {
|
||||
const query = normalizeQuery(params.query);
|
||||
if (!query) return [];
|
||||
const token = await resolveGraphToken(params.cfg);
|
||||
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
|
||||
|
||||
let users: GraphUser[] = [];
|
||||
if (query.includes("@")) {
|
||||
const escaped = escapeOData(query);
|
||||
const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`;
|
||||
const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`;
|
||||
const res = await fetchGraphJson<GraphResponse<GraphUser>>({ token, path });
|
||||
users = res.value ?? [];
|
||||
} else {
|
||||
const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=${limit}`;
|
||||
const res = await fetchGraphJson<GraphResponse<GraphUser>>({
|
||||
token,
|
||||
path,
|
||||
headers: { ConsistencyLevel: "eventual" },
|
||||
});
|
||||
users = res.value ?? [];
|
||||
}
|
||||
|
||||
return users
|
||||
.map((user) => {
|
||||
const id = user.id?.trim();
|
||||
if (!id) return null;
|
||||
const name = user.displayName?.trim();
|
||||
const handle = user.userPrincipalName?.trim() || user.mail?.trim();
|
||||
return {
|
||||
kind: "user",
|
||||
id: `user:${id}`,
|
||||
name: name || undefined,
|
||||
handle: handle ? `@${handle}` : undefined,
|
||||
raw: user,
|
||||
} satisfies ChannelDirectoryEntry;
|
||||
})
|
||||
.filter(Boolean) as ChannelDirectoryEntry[];
|
||||
}
|
||||
|
||||
export async function listMSTeamsDirectoryGroupsLive(params: {
|
||||
cfg: unknown;
|
||||
query?: string | null;
|
||||
limit?: number | null;
|
||||
}): Promise<ChannelDirectoryEntry[]> {
|
||||
const rawQuery = normalizeQuery(params.query);
|
||||
if (!rawQuery) return [];
|
||||
const token = await resolveGraphToken(params.cfg);
|
||||
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
|
||||
const [teamQuery, channelQuery] = rawQuery.includes("/")
|
||||
? rawQuery.split("/", 2).map((part) => part.trim()).filter(Boolean)
|
||||
: [rawQuery, null];
|
||||
|
||||
const teams = await listTeamsByName(token, teamQuery);
|
||||
const results: ChannelDirectoryEntry[] = [];
|
||||
|
||||
for (const team of teams) {
|
||||
const teamId = team.id?.trim();
|
||||
if (!teamId) continue;
|
||||
const teamName = team.displayName?.trim() || teamQuery;
|
||||
if (!channelQuery) {
|
||||
results.push({
|
||||
kind: "group",
|
||||
id: `team:${teamId}`,
|
||||
name: teamName,
|
||||
handle: teamName ? `#${teamName}` : undefined,
|
||||
raw: team,
|
||||
});
|
||||
if (results.length >= limit) return results;
|
||||
continue;
|
||||
}
|
||||
const channels = await listChannelsForTeam(token, teamId);
|
||||
for (const channel of channels) {
|
||||
const name = channel.displayName?.trim();
|
||||
if (!name) continue;
|
||||
if (!name.toLowerCase().includes(channelQuery.toLowerCase())) continue;
|
||||
results.push({
|
||||
kind: "group",
|
||||
id: `conversation:${channel.id}`,
|
||||
name: `${teamName}/${name}`,
|
||||
handle: `#${name}`,
|
||||
raw: channel,
|
||||
});
|
||||
if (results.length >= limit) return results;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -176,7 +176,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
}
|
||||
}
|
||||
|
||||
const groupPolicy = !isDirectMessage && msteamsCfg ? (msteamsCfg.groupPolicy ?? "allowlist") : "disabled";
|
||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy =
|
||||
!isDirectMessage && msteamsCfg
|
||||
? (msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist")
|
||||
: "disabled";
|
||||
const groupAllowFrom =
|
||||
!isDirectMessage && msteamsCfg
|
||||
? (msteamsCfg.groupAllowFrom ??
|
||||
@@ -186,6 +190,16 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
!isDirectMessage && msteamsCfg
|
||||
? [...groupAllowFrom.map((v) => String(v)), ...storedAllowFrom]
|
||||
: [];
|
||||
const teamId = activity.channelData?.team?.id;
|
||||
const teamName = activity.channelData?.team?.name;
|
||||
const channelName = activity.channelData?.channel?.name;
|
||||
const channelGate = resolveMSTeamsRouteConfig({
|
||||
cfg: msteamsCfg,
|
||||
teamId,
|
||||
teamName,
|
||||
conversationId,
|
||||
channelName,
|
||||
});
|
||||
|
||||
if (!isDirectMessage && msteamsCfg) {
|
||||
if (groupPolicy === "disabled") {
|
||||
@@ -196,25 +210,33 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
}
|
||||
|
||||
if (groupPolicy === "allowlist") {
|
||||
if (effectiveGroupAllowFrom.length === 0) {
|
||||
log.debug("dropping group message (groupPolicy: allowlist, no groupAllowFrom)", {
|
||||
if (channelGate.allowlistConfigured && !channelGate.allowed) {
|
||||
log.debug("dropping group message (not in team/channel allowlist)", {
|
||||
conversationId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const allowed = isMSTeamsGroupAllowed({
|
||||
groupPolicy,
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
senderId,
|
||||
senderName,
|
||||
});
|
||||
if (!allowed) {
|
||||
log.debug("dropping group message (not in groupAllowFrom)", {
|
||||
sender: senderId,
|
||||
label: senderName,
|
||||
if (effectiveGroupAllowFrom.length === 0 && !channelGate.allowlistConfigured) {
|
||||
log.debug("dropping group message (groupPolicy: allowlist, no allowlist)", {
|
||||
conversationId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (effectiveGroupAllowFrom.length > 0) {
|
||||
const allowed = isMSTeamsGroupAllowed({
|
||||
groupPolicy,
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
senderId,
|
||||
senderName,
|
||||
});
|
||||
if (!allowed) {
|
||||
log.debug("dropping group message (not in groupAllowFrom)", {
|
||||
sender: senderId,
|
||||
label: senderName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,7 +266,6 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
|
||||
// Build conversation reference for proactive replies.
|
||||
const agent = activity.recipient;
|
||||
const teamId = activity.channelData?.team?.id;
|
||||
const conversationRef: StoredConversationReference = {
|
||||
activityId: activity.id,
|
||||
user: { id: from.id, name: from.name, aadObjectId: from.aadObjectId },
|
||||
@@ -326,11 +347,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
});
|
||||
|
||||
const channelId = conversationId;
|
||||
const { teamConfig, channelConfig } = resolveMSTeamsRouteConfig({
|
||||
cfg: msteamsCfg,
|
||||
teamId,
|
||||
conversationId: channelId,
|
||||
});
|
||||
const { teamConfig, channelConfig } = channelGate;
|
||||
const { requireMention, replyStyle } = resolveMSTeamsReplyPolicy({
|
||||
isDirectMessage,
|
||||
globalConfig: msteamsCfg,
|
||||
|
||||
@@ -9,11 +9,61 @@ import { formatUnknownError } from "./errors.js";
|
||||
import type { MSTeamsAdapter } from "./messenger.js";
|
||||
import { registerMSTeamsHandlers } from "./monitor-handler.js";
|
||||
import { createMSTeamsPollStoreFs, type MSTeamsPollStore } from "./polls.js";
|
||||
import {
|
||||
resolveMSTeamsChannelAllowlist,
|
||||
resolveMSTeamsUserAllowlist,
|
||||
} from "./resolve-allowlist.js";
|
||||
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
|
||||
const log = getChildLogger({ name: "msteams" });
|
||||
|
||||
function mergeAllowlist(params: {
|
||||
existing?: Array<string | number>;
|
||||
additions: string[];
|
||||
}): string[] {
|
||||
const seen = new Set<string>();
|
||||
const merged: string[] = [];
|
||||
const push = (value: string) => {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) return;
|
||||
const key = normalized.toLowerCase();
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
merged.push(normalized);
|
||||
};
|
||||
for (const entry of params.existing ?? []) {
|
||||
push(String(entry));
|
||||
}
|
||||
for (const entry of params.additions) {
|
||||
push(entry);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function summarizeMapping(
|
||||
label: string,
|
||||
mapping: string[],
|
||||
unresolved: string[],
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const lines: string[] = [];
|
||||
if (mapping.length > 0) {
|
||||
const sample = mapping.slice(0, 6);
|
||||
const suffix = mapping.length > sample.length ? ` (+${mapping.length - sample.length})` : "";
|
||||
lines.push(`${label} resolved: ${sample.join(", ")}${suffix}`);
|
||||
}
|
||||
if (unresolved.length > 0) {
|
||||
const sample = unresolved.slice(0, 6);
|
||||
const suffix =
|
||||
unresolved.length > sample.length ? ` (+${unresolved.length - sample.length})` : "";
|
||||
lines.push(`${label} unresolved: ${sample.join(", ")}${suffix}`);
|
||||
}
|
||||
if (lines.length > 0) {
|
||||
runtime.log?.(lines.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
export type MonitorMSTeamsOpts = {
|
||||
cfg: ClawdbotConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
@@ -30,8 +80,8 @@ export type MonitorMSTeamsResult = {
|
||||
export async function monitorMSTeamsProvider(
|
||||
opts: MonitorMSTeamsOpts,
|
||||
): Promise<MonitorMSTeamsResult> {
|
||||
const cfg = opts.cfg;
|
||||
const msteamsCfg = cfg.channels?.msteams;
|
||||
let cfg = opts.cfg;
|
||||
let msteamsCfg = cfg.channels?.msteams;
|
||||
if (!msteamsCfg?.enabled) {
|
||||
log.debug("msteams provider disabled");
|
||||
return { app: null, shutdown: async () => {} };
|
||||
@@ -52,6 +102,142 @@ export async function monitorMSTeamsProvider(
|
||||
},
|
||||
};
|
||||
|
||||
let allowFrom = msteamsCfg.allowFrom;
|
||||
let groupAllowFrom = msteamsCfg.groupAllowFrom;
|
||||
let teamsConfig = msteamsCfg.teams;
|
||||
|
||||
const cleanAllowEntry = (entry: string) =>
|
||||
entry
|
||||
.replace(/^(msteams|teams):/i, "")
|
||||
.replace(/^user:/i, "")
|
||||
.trim();
|
||||
|
||||
const resolveAllowlistUsers = async (label: string, entries: string[]) => {
|
||||
if (entries.length === 0) return { additions: [], unresolved: [] };
|
||||
const resolved = await resolveMSTeamsUserAllowlist({ cfg, entries });
|
||||
const additions: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
for (const entry of resolved) {
|
||||
if (entry.resolved && entry.id) {
|
||||
additions.push(entry.id);
|
||||
} else {
|
||||
unresolved.push(entry.input);
|
||||
}
|
||||
}
|
||||
const mapping = resolved
|
||||
.filter((entry) => entry.resolved && entry.id)
|
||||
.map((entry) => `${entry.input}→${entry.id}`);
|
||||
summarizeMapping(label, mapping, unresolved, runtime);
|
||||
return { additions, unresolved };
|
||||
};
|
||||
|
||||
try {
|
||||
const allowEntries =
|
||||
allowFrom?.map((entry) => cleanAllowEntry(String(entry))).filter(
|
||||
(entry) => entry && entry !== "*",
|
||||
) ?? [];
|
||||
if (allowEntries.length > 0) {
|
||||
const { additions } = await resolveAllowlistUsers("msteams users", allowEntries);
|
||||
allowFrom = mergeAllowlist({ existing: allowFrom, additions });
|
||||
}
|
||||
|
||||
if (Array.isArray(groupAllowFrom) && groupAllowFrom.length > 0) {
|
||||
const groupEntries = groupAllowFrom
|
||||
.map((entry) => cleanAllowEntry(String(entry)))
|
||||
.filter((entry) => entry && entry !== "*");
|
||||
if (groupEntries.length > 0) {
|
||||
const { additions } = await resolveAllowlistUsers("msteams group users", groupEntries);
|
||||
groupAllowFrom = mergeAllowlist({ existing: groupAllowFrom, additions });
|
||||
}
|
||||
}
|
||||
|
||||
if (teamsConfig && Object.keys(teamsConfig).length > 0) {
|
||||
const entries: Array<{ input: string; teamKey: string; channelKey?: string }> = [];
|
||||
for (const [teamKey, teamCfg] of Object.entries(teamsConfig)) {
|
||||
if (teamKey === "*") continue;
|
||||
const channels = teamCfg?.channels ?? {};
|
||||
const channelKeys = Object.keys(channels).filter((key) => key !== "*");
|
||||
if (channelKeys.length === 0) {
|
||||
entries.push({ input: teamKey, teamKey });
|
||||
continue;
|
||||
}
|
||||
for (const channelKey of channelKeys) {
|
||||
entries.push({
|
||||
input: `${teamKey}/${channelKey}`,
|
||||
teamKey,
|
||||
channelKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
const resolved = await resolveMSTeamsChannelAllowlist({
|
||||
cfg,
|
||||
entries: entries.map((entry) => entry.input),
|
||||
});
|
||||
const mapping: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
const nextTeams = { ...(teamsConfig ?? {}) };
|
||||
|
||||
resolved.forEach((entry, idx) => {
|
||||
const source = entries[idx];
|
||||
if (!source) return;
|
||||
const sourceTeam = teamsConfig?.[source.teamKey] ?? {};
|
||||
if (!entry.resolved || !entry.teamId) {
|
||||
unresolved.push(entry.input);
|
||||
return;
|
||||
}
|
||||
mapping.push(
|
||||
entry.channelId
|
||||
? `${entry.input}→${entry.teamId}/${entry.channelId}`
|
||||
: `${entry.input}→${entry.teamId}`,
|
||||
);
|
||||
const existing = nextTeams[entry.teamId] ?? {};
|
||||
const mergedChannels = {
|
||||
...(sourceTeam.channels ?? {}),
|
||||
...(existing.channels ?? {}),
|
||||
};
|
||||
const mergedTeam = { ...sourceTeam, ...existing, channels: mergedChannels };
|
||||
nextTeams[entry.teamId] = mergedTeam;
|
||||
if (source.channelKey && entry.channelId) {
|
||||
const sourceChannel = sourceTeam.channels?.[source.channelKey];
|
||||
if (sourceChannel) {
|
||||
nextTeams[entry.teamId] = {
|
||||
...mergedTeam,
|
||||
channels: {
|
||||
...mergedChannels,
|
||||
[entry.channelId]: {
|
||||
...sourceChannel,
|
||||
...(mergedChannels?.[entry.channelId] ?? {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
teamsConfig = nextTeams;
|
||||
summarizeMapping("msteams channels", mapping, unresolved, runtime);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
runtime.log?.(`msteams resolve failed; using config entries. ${String(err)}`);
|
||||
}
|
||||
|
||||
msteamsCfg = {
|
||||
...msteamsCfg,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
teams: teamsConfig,
|
||||
};
|
||||
cfg = {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
msteams: msteamsCfg,
|
||||
},
|
||||
};
|
||||
|
||||
const port = msteamsCfg.webhook?.port ?? 3978;
|
||||
const textLimit = resolveTextChunkLimit(cfg, "msteams");
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
@@ -7,9 +7,11 @@ import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
} from "../../../src/channels/plugins/onboarding-types.js";
|
||||
import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js";
|
||||
import { addWildcardAllowFrom } from "../../../src/channels/plugins/onboarding/helpers.js";
|
||||
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
import { resolveMSTeamsChannelAllowlist } from "./resolve-allowlist.js";
|
||||
|
||||
const channel = "msteams" as const;
|
||||
|
||||
@@ -44,6 +46,66 @@ async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise<void
|
||||
);
|
||||
}
|
||||
|
||||
function setMSTeamsGroupPolicy(
|
||||
cfg: ClawdbotConfig,
|
||||
groupPolicy: "open" | "allowlist" | "disabled",
|
||||
): ClawdbotConfig {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
msteams: {
|
||||
...cfg.channels?.msteams,
|
||||
enabled: true,
|
||||
groupPolicy,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setMSTeamsTeamsAllowlist(
|
||||
cfg: ClawdbotConfig,
|
||||
entries: Array<{ teamKey: string; channelKey?: string }>,
|
||||
): ClawdbotConfig {
|
||||
const baseTeams = cfg.channels?.msteams?.teams ?? {};
|
||||
const teams: Record<string, { channels?: Record<string, unknown> }> = { ...baseTeams };
|
||||
for (const entry of entries) {
|
||||
const teamKey = entry.teamKey;
|
||||
if (!teamKey) continue;
|
||||
const existing = teams[teamKey] ?? {};
|
||||
if (entry.channelKey) {
|
||||
const channels = { ...(existing.channels ?? {}) };
|
||||
channels[entry.channelKey] = channels[entry.channelKey] ?? {};
|
||||
teams[teamKey] = { ...existing, channels };
|
||||
} else {
|
||||
teams[teamKey] = existing;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
msteams: {
|
||||
...cfg.channels?.msteams,
|
||||
enabled: true,
|
||||
teams,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseMSTeamsTeamEntry(raw: string): { teamKey: string; channelKey?: string } | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
const parts = trimmed.split("/");
|
||||
const teamPart = parts[0]?.trim();
|
||||
if (!teamPart) return null;
|
||||
const channelPart = parts.length > 1 ? parts.slice(1).join("/").trim() : undefined;
|
||||
const teamKey = teamPart.replace(/^team:/i, "").trim();
|
||||
const channelKey = channelPart ? channelPart.replace(/^#/, "").trim() : undefined;
|
||||
return { teamKey, ...(channelKey ? { channelKey } : {}) };
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "MS Teams",
|
||||
channel,
|
||||
@@ -184,6 +246,93 @@ export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
};
|
||||
}
|
||||
|
||||
const currentEntries = Object.entries(next.channels?.msteams?.teams ?? {}).flatMap(
|
||||
([teamKey, value]) => {
|
||||
const channels = value?.channels ?? {};
|
||||
const channelKeys = Object.keys(channels);
|
||||
if (channelKeys.length === 0) return [teamKey];
|
||||
return channelKeys.map((channelKey) => `${teamKey}/${channelKey}`);
|
||||
},
|
||||
);
|
||||
const accessConfig = await promptChannelAccessConfig({
|
||||
prompter,
|
||||
label: "MS Teams channels",
|
||||
currentPolicy: next.channels?.msteams?.groupPolicy ?? "allowlist",
|
||||
currentEntries,
|
||||
placeholder: "Team Name/Channel Name, teamId/conversationId",
|
||||
updatePrompt: Boolean(next.channels?.msteams?.teams),
|
||||
});
|
||||
if (accessConfig) {
|
||||
if (accessConfig.policy !== "allowlist") {
|
||||
next = setMSTeamsGroupPolicy(next, accessConfig.policy);
|
||||
} else {
|
||||
let entries = accessConfig.entries
|
||||
.map((entry) => parseMSTeamsTeamEntry(entry))
|
||||
.filter(Boolean) as Array<{ teamKey: string; channelKey?: string }>;
|
||||
if (accessConfig.entries.length > 0 && resolveMSTeamsCredentials(next.channels?.msteams)) {
|
||||
try {
|
||||
const resolved = await resolveMSTeamsChannelAllowlist({
|
||||
cfg: next,
|
||||
entries: accessConfig.entries,
|
||||
});
|
||||
const resolvedChannels = resolved.filter(
|
||||
(entry) => entry.resolved && entry.teamId && entry.channelId,
|
||||
);
|
||||
const resolvedTeams = resolved.filter(
|
||||
(entry) => entry.resolved && entry.teamId && !entry.channelId,
|
||||
);
|
||||
const unresolved = resolved
|
||||
.filter((entry) => !entry.resolved)
|
||||
.map((entry) => entry.input);
|
||||
|
||||
entries = [
|
||||
...resolvedChannels.map((entry) => ({
|
||||
teamKey: entry.teamId as string,
|
||||
channelKey: entry.channelId as string,
|
||||
})),
|
||||
...resolvedTeams.map((entry) => ({
|
||||
teamKey: entry.teamId as string,
|
||||
})),
|
||||
...unresolved
|
||||
.map((entry) => parseMSTeamsTeamEntry(entry))
|
||||
.filter(Boolean),
|
||||
] as Array<{ teamKey: string; channelKey?: string }>;
|
||||
|
||||
if (resolvedChannels.length > 0 || resolvedTeams.length > 0 || unresolved.length > 0) {
|
||||
const summary: string[] = [];
|
||||
if (resolvedChannels.length > 0) {
|
||||
summary.push(
|
||||
`Resolved channels: ${resolvedChannels
|
||||
.map((entry) => entry.channelId)
|
||||
.filter(Boolean)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (resolvedTeams.length > 0) {
|
||||
summary.push(
|
||||
`Resolved teams: ${resolvedTeams
|
||||
.map((entry) => entry.teamId)
|
||||
.filter(Boolean)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (unresolved.length > 0) {
|
||||
summary.push(`Unresolved (kept as typed): ${unresolved.join(", ")}`);
|
||||
}
|
||||
await prompter.note(summary.join("\n"), "MS Teams channels");
|
||||
}
|
||||
} catch (err) {
|
||||
await prompter.note(
|
||||
`Channel lookup failed; keeping entries as typed. ${String(err)}`,
|
||||
"MS Teams channels",
|
||||
);
|
||||
}
|
||||
}
|
||||
next = setMSTeamsGroupPolicy(next, "allowlist");
|
||||
next = setMSTeamsTeamsAllowlist(next, entries);
|
||||
}
|
||||
}
|
||||
|
||||
return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
|
||||
},
|
||||
dmPolicy,
|
||||
|
||||
@@ -29,6 +29,8 @@ describe("msteams policy", () => {
|
||||
|
||||
expect(res.teamConfig?.requireMention).toBe(false);
|
||||
expect(res.channelConfig?.requireMention).toBe(true);
|
||||
expect(res.allowlistConfigured).toBe(true);
|
||||
expect(res.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("returns undefined configs when teamId is missing", () => {
|
||||
@@ -43,6 +45,32 @@ describe("msteams policy", () => {
|
||||
});
|
||||
expect(res.teamConfig).toBeUndefined();
|
||||
expect(res.channelConfig).toBeUndefined();
|
||||
expect(res.allowlistConfigured).toBe(true);
|
||||
expect(res.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("matches team and channel by name", () => {
|
||||
const cfg: MSTeamsConfig = {
|
||||
teams: {
|
||||
"My Team": {
|
||||
requireMention: true,
|
||||
channels: {
|
||||
"General Chat": { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = resolveMSTeamsRouteConfig({
|
||||
cfg,
|
||||
teamName: "My Team",
|
||||
channelName: "General Chat",
|
||||
conversationId: "ignored",
|
||||
});
|
||||
|
||||
expect(res.teamConfig?.requireMention).toBe(true);
|
||||
expect(res.channelConfig?.requireMention).toBe(false);
|
||||
expect(res.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,19 +9,73 @@ import type {
|
||||
export type MSTeamsResolvedRouteConfig = {
|
||||
teamConfig?: MSTeamsTeamConfig;
|
||||
channelConfig?: MSTeamsChannelConfig;
|
||||
allowlistConfigured: boolean;
|
||||
allowed: boolean;
|
||||
teamKey?: string;
|
||||
channelKey?: string;
|
||||
};
|
||||
|
||||
export function resolveMSTeamsRouteConfig(params: {
|
||||
cfg?: MSTeamsConfig;
|
||||
teamId?: string | null | undefined;
|
||||
teamName?: string | null | undefined;
|
||||
conversationId?: string | null | undefined;
|
||||
channelName?: string | null | undefined;
|
||||
}): MSTeamsResolvedRouteConfig {
|
||||
const teamId = params.teamId?.trim();
|
||||
const teamName = params.teamName?.trim();
|
||||
const conversationId = params.conversationId?.trim();
|
||||
const teamConfig = teamId ? params.cfg?.teams?.[teamId] : undefined;
|
||||
const channelConfig =
|
||||
teamConfig && conversationId ? teamConfig.channels?.[conversationId] : undefined;
|
||||
return { teamConfig, channelConfig };
|
||||
const channelName = params.channelName?.trim();
|
||||
const teams = params.cfg?.teams ?? {};
|
||||
const teamKeys = Object.keys(teams);
|
||||
const allowlistConfigured = teamKeys.length > 0;
|
||||
|
||||
const normalize = (value: string) =>
|
||||
value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^#/, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
let teamKey: string | undefined;
|
||||
if (teamId && teams[teamId]) teamKey = teamId;
|
||||
if (!teamKey && teamName) {
|
||||
const slug = normalize(teamName);
|
||||
if (slug) {
|
||||
teamKey = teamKeys.find((key) => normalize(key) === slug);
|
||||
}
|
||||
}
|
||||
if (!teamKey && teams["*"]) teamKey = "*";
|
||||
|
||||
const teamConfig = teamKey ? teams[teamKey] : undefined;
|
||||
const channels = teamConfig?.channels ?? {};
|
||||
const channelKeys = Object.keys(channels);
|
||||
|
||||
let channelKey: string | undefined;
|
||||
if (conversationId && channels[conversationId]) channelKey = conversationId;
|
||||
if (!channelKey && channelName) {
|
||||
const slug = normalize(channelName);
|
||||
if (slug) {
|
||||
channelKey = channelKeys.find((key) => normalize(key) === slug);
|
||||
}
|
||||
}
|
||||
if (!channelKey && channels["*"]) channelKey = "*";
|
||||
const channelConfig = channelKey ? channels[channelKey] : undefined;
|
||||
const channelAllowlistConfigured = channelKeys.length > 0;
|
||||
|
||||
const allowed = !allowlistConfigured
|
||||
? true
|
||||
: Boolean(teamConfig) && (!channelAllowlistConfigured || Boolean(channelConfig));
|
||||
|
||||
return {
|
||||
teamConfig,
|
||||
channelConfig,
|
||||
allowlistConfigured,
|
||||
allowed,
|
||||
teamKey,
|
||||
channelKey,
|
||||
};
|
||||
}
|
||||
|
||||
export type MSTeamsReplyPolicy = {
|
||||
|
||||
223
extensions/msteams/src/resolve-allowlist.ts
Normal file
223
extensions/msteams/src/resolve-allowlist.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { GRAPH_ROOT } from "./attachments/shared.js";
|
||||
import { loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
|
||||
type GraphUser = {
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
userPrincipalName?: string;
|
||||
mail?: string;
|
||||
};
|
||||
|
||||
type GraphGroup = {
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
type GraphChannel = {
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
type GraphResponse<T> = { value?: T[] };
|
||||
|
||||
export type MSTeamsChannelResolution = {
|
||||
input: string;
|
||||
resolved: boolean;
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
channelId?: string;
|
||||
channelName?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type MSTeamsUserResolution = {
|
||||
input: string;
|
||||
resolved: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
function readAccessToken(value: unknown): string | null {
|
||||
if (typeof value === "string") return value;
|
||||
if (value && typeof value === "object") {
|
||||
const token =
|
||||
(value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token;
|
||||
return typeof token === "string" ? token : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeQuery(value?: string | null): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
function escapeOData(value: string): string {
|
||||
return value.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
async function fetchGraphJson<T>(params: {
|
||||
token: string;
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
}): Promise<T> {
|
||||
const res = await fetch(`${GRAPH_ROOT}${params.path}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.token}`,
|
||||
...(params.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Graph ${params.path} failed (${res.status}): ${text || "unknown error"}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async function resolveGraphToken(cfg: unknown): Promise<string> {
|
||||
const creds = resolveMSTeamsCredentials((cfg as { channels?: { msteams?: unknown } })?.channels?.msteams);
|
||||
if (!creds) throw new Error("MS Teams credentials missing");
|
||||
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
|
||||
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
|
||||
const token = await tokenProvider.getAccessToken("https://graph.microsoft.com/.default");
|
||||
const accessToken = readAccessToken(token);
|
||||
if (!accessToken) throw new Error("MS Teams graph token unavailable");
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
function parseTeamChannelInput(raw: string): { team?: string; channel?: string } {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
const parts = trimmed.split("/");
|
||||
const team = parts[0]?.trim();
|
||||
const channel = parts.length > 1 ? parts.slice(1).join("/").trim() : undefined;
|
||||
return { team: team || undefined, channel: channel || undefined };
|
||||
}
|
||||
|
||||
async function listTeamsByName(token: string, query: string): Promise<GraphGroup[]> {
|
||||
const escaped = escapeOData(query);
|
||||
const filter = `resourceProvisioningOptions/Any(x:x eq 'Team') and startsWith(displayName,'${escaped}')`;
|
||||
const path = `/groups?$filter=${encodeURIComponent(filter)}&$select=id,displayName`;
|
||||
const res = await fetchGraphJson<GraphResponse<GraphGroup>>({ token, path });
|
||||
return res.value ?? [];
|
||||
}
|
||||
|
||||
async function listChannelsForTeam(token: string, teamId: string): Promise<GraphChannel[]> {
|
||||
const path = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName`;
|
||||
const res = await fetchGraphJson<GraphResponse<GraphChannel>>({ token, path });
|
||||
return res.value ?? [];
|
||||
}
|
||||
|
||||
export async function resolveMSTeamsChannelAllowlist(params: {
|
||||
cfg: unknown;
|
||||
entries: string[];
|
||||
}): Promise<MSTeamsChannelResolution[]> {
|
||||
const token = await resolveGraphToken(params.cfg);
|
||||
const results: MSTeamsChannelResolution[] = [];
|
||||
|
||||
for (const input of params.entries) {
|
||||
const { team, channel } = parseTeamChannelInput(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({
|
||||
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;
|
||||
}
|
||||
|
||||
export async function resolveMSTeamsUserAllowlist(params: {
|
||||
cfg: unknown;
|
||||
entries: string[];
|
||||
}): Promise<MSTeamsUserResolution[]> {
|
||||
const token = await resolveGraphToken(params.cfg);
|
||||
const results: MSTeamsUserResolution[] = [];
|
||||
|
||||
for (const input of params.entries) {
|
||||
const query = normalizeQuery(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;
|
||||
}
|
||||
let users: GraphUser[] = [];
|
||||
if (query.includes("@")) {
|
||||
const escaped = escapeOData(query);
|
||||
const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`;
|
||||
const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`;
|
||||
const res = await fetchGraphJson<GraphResponse<GraphUser>>({ token, path });
|
||||
users = res.value ?? [];
|
||||
} else {
|
||||
const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=10`;
|
||||
const res = await fetchGraphJson<GraphResponse<GraphUser>>({
|
||||
token,
|
||||
path,
|
||||
headers: { ConsistencyLevel: "eventual" },
|
||||
});
|
||||
users = res.value ?? [];
|
||||
}
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user