feat(channels): add resolve command + defaults

This commit is contained in:
Peter Steinberger
2026-01-18 00:41:57 +00:00
parent b543339373
commit c7ea47e886
60 changed files with 4418 additions and 101 deletions

View File

@@ -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 }) => {

View 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;
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,

View File

@@ -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);
});
});

View File

@@ -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 = {

View 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;
}