refactor(src): split oversized modules

This commit is contained in:
Peter Steinberger
2026-01-14 01:08:15 +00:00
parent b2179de839
commit bcbfb357be
675 changed files with 91476 additions and 73453 deletions

BIN
src/msteams/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -1,795 +1,17 @@
import { detectMime } from "../media/mime.js";
import { saveMediaBuffer } from "../media/store.js";
export type MSTeamsAttachmentLike = {
contentType?: string | null;
contentUrl?: string | null;
name?: string | null;
thumbnailUrl?: string | null;
content?: unknown;
};
export type MSTeamsAccessTokenProvider = {
getAccessToken: (scope: string) => Promise<string>;
};
type DownloadCandidate = {
url: string;
fileHint?: string;
contentTypeHint?: string;
placeholder: string;
};
export type MSTeamsInboundMedia = {
path: string;
contentType?: string;
placeholder: string;
};
type InlineImageCandidate =
| {
kind: "data";
data: Buffer;
contentType?: string;
placeholder: string;
}
| {
kind: "url";
url: string;
contentType?: string;
fileHint?: string;
placeholder: string;
};
const IMAGE_EXT_RE = /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/i;
const IMG_SRC_RE = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
const ATTACHMENT_TAG_RE = /<attachment[^>]+id=["']([^"']+)["'][^>]*>/gi;
const DEFAULT_MEDIA_HOST_ALLOWLIST = [
"graph.microsoft.com",
"graph.microsoft.us",
"graph.microsoft.de",
"graph.microsoft.cn",
"sharepoint.com",
"sharepoint.us",
"sharepoint.de",
"sharepoint.cn",
"sharepoint-df.com",
"1drv.ms",
"onedrive.com",
"teams.microsoft.com",
"teams.cdn.office.net",
"statics.teams.cdn.office.net",
"office.com",
"office.net",
];
export type MSTeamsHtmlAttachmentSummary = {
htmlAttachments: number;
imgTags: number;
dataImages: number;
cidImages: number;
srcHosts: string[];
attachmentTags: number;
attachmentIds: string[];
};
export type MSTeamsGraphMediaResult = {
media: MSTeamsInboundMedia[];
hostedCount?: number;
attachmentCount?: number;
hostedStatus?: number;
attachmentStatus?: number;
messageUrl?: string;
tokenError?: boolean;
};
type GraphHostedContent = {
id?: string | null;
contentType?: string | null;
contentBytes?: string | null;
};
type GraphAttachment = {
id?: string | null;
contentType?: string | null;
contentUrl?: string | null;
name?: string | null;
thumbnailUrl?: string | null;
content?: unknown;
};
const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function normalizeContentType(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function inferPlaceholder(params: {
contentType?: string;
fileName?: string;
fileType?: string;
}): string {
const mime = params.contentType?.toLowerCase() ?? "";
const name = params.fileName?.toLowerCase() ?? "";
const fileType = params.fileType?.toLowerCase() ?? "";
const looksLikeImage =
mime.startsWith("image/") ||
IMAGE_EXT_RE.test(name) ||
IMAGE_EXT_RE.test(`x.${fileType}`);
return looksLikeImage ? "<media:image>" : "<media:document>";
}
function isLikelyImageAttachment(att: MSTeamsAttachmentLike): boolean {
const contentType = normalizeContentType(att.contentType) ?? "";
const name = typeof att.name === "string" ? att.name : "";
if (contentType.startsWith("image/")) return true;
if (IMAGE_EXT_RE.test(name)) return true;
if (
contentType === "application/vnd.microsoft.teams.file.download.info" &&
isRecord(att.content)
) {
const fileType =
typeof att.content.fileType === "string" ? att.content.fileType : "";
if (fileType && IMAGE_EXT_RE.test(`x.${fileType}`)) return true;
const fileName =
typeof att.content.fileName === "string" ? att.content.fileName : "";
if (fileName && IMAGE_EXT_RE.test(fileName)) return true;
}
return false;
}
function isHtmlAttachment(att: MSTeamsAttachmentLike): boolean {
const contentType = normalizeContentType(att.contentType) ?? "";
return contentType.startsWith("text/html");
}
function extractHtmlFromAttachment(
att: MSTeamsAttachmentLike,
): string | undefined {
if (!isHtmlAttachment(att)) return undefined;
if (typeof att.content === "string") return att.content;
if (!isRecord(att.content)) return undefined;
const text =
typeof att.content.text === "string"
? att.content.text
: typeof att.content.body === "string"
? att.content.body
: typeof att.content.content === "string"
? att.content.content
: undefined;
return text;
}
function decodeDataImage(src: string): InlineImageCandidate | null {
const match = /^data:(image\/[a-z0-9.+-]+)?(;base64)?,(.*)$/i.exec(src);
if (!match) return null;
const contentType = match[1]?.toLowerCase();
const isBase64 = Boolean(match[2]);
if (!isBase64) return null;
const payload = match[3] ?? "";
if (!payload) return null;
try {
const data = Buffer.from(payload, "base64");
return {
kind: "data",
data,
contentType,
placeholder: "<media:image>",
};
} catch {
return null;
}
}
function fileHintFromUrl(src: string): string | undefined {
try {
const url = new URL(src);
const name = url.pathname.split("/").pop();
return name || undefined;
} catch {
return undefined;
}
}
function extractInlineImageCandidates(
attachments: MSTeamsAttachmentLike[],
): InlineImageCandidate[] {
const out: InlineImageCandidate[] = [];
for (const att of attachments) {
const html = extractHtmlFromAttachment(att);
if (!html) continue;
IMG_SRC_RE.lastIndex = 0;
let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
while (match) {
const src = match[1]?.trim();
if (src && !src.startsWith("cid:")) {
if (src.startsWith("data:")) {
const decoded = decodeDataImage(src);
if (decoded) out.push(decoded);
} else {
out.push({
kind: "url",
url: src,
fileHint: fileHintFromUrl(src),
placeholder: "<media:image>",
});
}
}
match = IMG_SRC_RE.exec(html);
}
}
return out;
}
function safeHostForUrl(url: string): string {
try {
return new URL(url).hostname.toLowerCase();
} catch {
return "invalid-url";
}
}
function normalizeAllowHost(value: string): string {
const trimmed = value.trim().toLowerCase();
if (!trimmed) return "";
if (trimmed === "*") return "*";
return trimmed.replace(/^\*\.?/, "");
}
function resolveAllowedHosts(input?: string[]): string[] {
if (!Array.isArray(input) || input.length === 0) {
return DEFAULT_MEDIA_HOST_ALLOWLIST.slice();
}
const normalized = input.map(normalizeAllowHost).filter(Boolean);
if (normalized.includes("*")) return ["*"];
return normalized;
}
function isHostAllowed(host: string, allowlist: string[]): boolean {
if (allowlist.includes("*")) return true;
const normalized = host.toLowerCase();
return allowlist.some(
(entry) => normalized === entry || normalized.endsWith(`.${entry}`),
);
}
function isUrlAllowed(url: string, allowlist: string[]): boolean {
try {
const parsed = new URL(url);
if (parsed.protocol !== "https:") return false;
return isHostAllowed(parsed.hostname, allowlist);
} catch {
return false;
}
}
export function summarizeMSTeamsHtmlAttachments(
attachments: MSTeamsAttachmentLike[] | undefined,
): MSTeamsHtmlAttachmentSummary | undefined {
const list = Array.isArray(attachments) ? attachments : [];
if (list.length === 0) return undefined;
let htmlAttachments = 0;
let imgTags = 0;
let dataImages = 0;
let cidImages = 0;
const srcHosts = new Set<string>();
let attachmentTags = 0;
const attachmentIds = new Set<string>();
for (const att of list) {
const html = extractHtmlFromAttachment(att);
if (!html) continue;
htmlAttachments += 1;
IMG_SRC_RE.lastIndex = 0;
let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
while (match) {
imgTags += 1;
const src = match[1]?.trim();
if (src) {
if (src.startsWith("data:")) dataImages += 1;
else if (src.startsWith("cid:")) cidImages += 1;
else srcHosts.add(safeHostForUrl(src));
}
match = IMG_SRC_RE.exec(html);
}
ATTACHMENT_TAG_RE.lastIndex = 0;
match = ATTACHMENT_TAG_RE.exec(html);
while (match) {
attachmentTags += 1;
const id = match[1]?.trim();
if (id) attachmentIds.add(id);
match = ATTACHMENT_TAG_RE.exec(html);
}
}
if (htmlAttachments === 0) return undefined;
return {
htmlAttachments,
imgTags,
dataImages,
cidImages,
srcHosts: Array.from(srcHosts).slice(0, 5),
attachmentTags,
attachmentIds: Array.from(attachmentIds).slice(0, 5),
};
}
function readNestedString(
value: unknown,
keys: Array<string | number>,
): string | undefined {
let current: unknown = value;
for (const key of keys) {
if (!isRecord(current)) return undefined;
current = current[key as keyof typeof current];
}
return typeof current === "string" && current.trim()
? current.trim()
: undefined;
}
export function buildMSTeamsGraphMessageUrls(params: {
conversationType?: string | null;
conversationId?: string | null;
messageId?: string | null;
replyToId?: string | null;
conversationMessageId?: string | null;
channelData?: unknown;
}): string[] {
const conversationType = params.conversationType?.trim().toLowerCase() ?? "";
const messageIdCandidates = new Set<string>();
const pushCandidate = (value: string | null | undefined) => {
const trimmed = typeof value === "string" ? value.trim() : "";
if (trimmed) messageIdCandidates.add(trimmed);
};
pushCandidate(params.messageId);
pushCandidate(params.conversationMessageId);
pushCandidate(readNestedString(params.channelData, ["messageId"]));
pushCandidate(readNestedString(params.channelData, ["teamsMessageId"]));
const replyToId =
typeof params.replyToId === "string" ? params.replyToId.trim() : "";
if (conversationType === "channel") {
const teamId =
readNestedString(params.channelData, ["team", "id"]) ??
readNestedString(params.channelData, ["teamId"]);
const channelId =
readNestedString(params.channelData, ["channel", "id"]) ??
readNestedString(params.channelData, ["channelId"]) ??
readNestedString(params.channelData, ["teamsChannelId"]);
if (!teamId || !channelId) return [];
const urls: string[] = [];
if (replyToId) {
for (const candidate of messageIdCandidates) {
if (candidate === replyToId) continue;
urls.push(
`${GRAPH_ROOT}/teams/${encodeURIComponent(
teamId,
)}/channels/${encodeURIComponent(
channelId,
)}/messages/${encodeURIComponent(
replyToId,
)}/replies/${encodeURIComponent(candidate)}`,
);
}
}
if (messageIdCandidates.size === 0 && replyToId) {
messageIdCandidates.add(replyToId);
}
for (const candidate of messageIdCandidates) {
urls.push(
`${GRAPH_ROOT}/teams/${encodeURIComponent(
teamId,
)}/channels/${encodeURIComponent(
channelId,
)}/messages/${encodeURIComponent(candidate)}`,
);
}
return Array.from(new Set(urls));
}
const chatId =
params.conversationId?.trim() ||
readNestedString(params.channelData, ["chatId"]);
if (!chatId) return [];
if (messageIdCandidates.size === 0 && replyToId) {
messageIdCandidates.add(replyToId);
}
const urls = Array.from(messageIdCandidates).map(
(candidate) =>
`${GRAPH_ROOT}/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(candidate)}`,
);
return Array.from(new Set(urls));
}
async function fetchGraphCollection<T>(params: {
url: string;
accessToken: string;
fetchFn?: typeof fetch;
}): Promise<{ status: number; items: T[] }> {
const fetchFn = params.fetchFn ?? fetch;
const res = await fetchFn(params.url, {
headers: { Authorization: `Bearer ${params.accessToken}` },
});
const status = res.status;
if (!res.ok) return { status, items: [] };
try {
const data = (await res.json()) as { value?: T[] };
return { status, items: Array.isArray(data.value) ? data.value : [] };
} catch {
return { status, items: [] };
}
}
function normalizeGraphAttachment(att: GraphAttachment): MSTeamsAttachmentLike {
let content: unknown = att.content;
if (typeof content === "string") {
try {
content = JSON.parse(content);
} catch {
// Keep as raw string if it's not JSON.
}
}
return {
contentType: att.contentType ?? undefined,
contentUrl: att.contentUrl ?? undefined,
name: att.name ?? undefined,
thumbnailUrl: att.thumbnailUrl ?? undefined,
content,
};
}
async function downloadGraphHostedImages(params: {
accessToken: string;
messageUrl: string;
maxBytes: number;
fetchFn?: typeof fetch;
}): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> {
const hosted = await fetchGraphCollection<GraphHostedContent>({
url: `${params.messageUrl}/hostedContents`,
accessToken: params.accessToken,
fetchFn: params.fetchFn,
});
if (hosted.items.length === 0) {
return { media: [], status: hosted.status, count: 0 };
}
const out: MSTeamsInboundMedia[] = [];
for (const item of hosted.items) {
const contentBytes =
typeof item.contentBytes === "string" ? item.contentBytes : "";
if (!contentBytes) continue;
let buffer: Buffer;
try {
buffer = Buffer.from(contentBytes, "base64");
} catch {
continue;
}
if (buffer.byteLength > params.maxBytes) continue;
const mime = await detectMime({
buffer,
headerMime: item.contentType ?? undefined,
});
if (mime && !mime.startsWith("image/")) continue;
try {
const saved = await saveMediaBuffer(
buffer,
mime ?? item.contentType ?? undefined,
"inbound",
params.maxBytes,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: "<media:image>",
});
} catch {
// Ignore save failures.
}
}
return { media: out, status: hosted.status, count: hosted.items.length };
}
export async function downloadMSTeamsGraphMedia(params: {
messageUrl?: string | null;
tokenProvider?: MSTeamsAccessTokenProvider;
maxBytes: number;
allowHosts?: string[];
fetchFn?: typeof fetch;
}): Promise<MSTeamsGraphMediaResult> {
if (!params.messageUrl || !params.tokenProvider) {
return { media: [] };
}
const allowHosts = resolveAllowedHosts(params.allowHosts);
const messageUrl = params.messageUrl;
let accessToken: string;
try {
accessToken = await params.tokenProvider.getAccessToken(
"https://graph.microsoft.com/.default",
);
} catch {
return { media: [], messageUrl, tokenError: true };
}
const hosted = await downloadGraphHostedImages({
accessToken,
messageUrl,
maxBytes: params.maxBytes,
fetchFn: params.fetchFn,
});
const attachments = await fetchGraphCollection<GraphAttachment>({
url: `${messageUrl}/attachments`,
accessToken,
fetchFn: params.fetchFn,
});
const normalizedAttachments = attachments.items.map(normalizeGraphAttachment);
const attachmentMedia = await downloadMSTeamsImageAttachments({
attachments: normalizedAttachments,
maxBytes: params.maxBytes,
tokenProvider: params.tokenProvider,
allowHosts,
fetchFn: params.fetchFn,
});
return {
media: [...hosted.media, ...attachmentMedia],
hostedCount: hosted.count,
attachmentCount: attachments.items.length,
hostedStatus: hosted.status,
attachmentStatus: attachments.status,
messageUrl,
};
}
export function buildMSTeamsAttachmentPlaceholder(
attachments: MSTeamsAttachmentLike[] | undefined,
): string {
const list = Array.isArray(attachments) ? attachments : [];
if (list.length === 0) return "";
const imageCount = list.filter(isLikelyImageAttachment).length;
const inlineCount = extractInlineImageCandidates(list).length;
const totalImages = imageCount + inlineCount;
if (totalImages > 0) {
return `<media:image>${totalImages > 1 ? ` (${totalImages} images)` : ""}`;
}
const count = list.length;
return `<media:document>${count > 1 ? ` (${count} files)` : ""}`;
}
function resolveDownloadCandidate(
att: MSTeamsAttachmentLike,
): DownloadCandidate | null {
const contentType = normalizeContentType(att.contentType);
const name = typeof att.name === "string" ? att.name.trim() : "";
if (contentType === "application/vnd.microsoft.teams.file.download.info") {
if (!isRecord(att.content)) return null;
const downloadUrl =
typeof att.content.downloadUrl === "string"
? att.content.downloadUrl.trim()
: "";
if (!downloadUrl) return null;
const fileType =
typeof att.content.fileType === "string"
? att.content.fileType.trim()
: "";
const uniqueId =
typeof att.content.uniqueId === "string"
? att.content.uniqueId.trim()
: "";
const fileName =
typeof att.content.fileName === "string"
? att.content.fileName.trim()
: "";
const fileHint =
name ||
fileName ||
(uniqueId && fileType ? `${uniqueId}.${fileType}` : "");
return {
url: downloadUrl,
fileHint: fileHint || undefined,
contentTypeHint: undefined,
placeholder: inferPlaceholder({
contentType,
fileName: fileHint,
fileType,
}),
};
}
const contentUrl =
typeof att.contentUrl === "string" ? att.contentUrl.trim() : "";
if (!contentUrl) return null;
return {
url: contentUrl,
fileHint: name || undefined,
contentTypeHint: contentType,
placeholder: inferPlaceholder({ contentType, fileName: name }),
};
}
function scopeCandidatesForUrl(url: string): string[] {
try {
const host = new URL(url).hostname.toLowerCase();
const looksLikeGraph =
host.endsWith("graph.microsoft.com") ||
host.endsWith("sharepoint.com") ||
host.endsWith("1drv.ms") ||
host.includes("sharepoint");
return looksLikeGraph
? [
"https://graph.microsoft.com/.default",
"https://api.botframework.com/.default",
]
: [
"https://api.botframework.com/.default",
"https://graph.microsoft.com/.default",
];
} catch {
return [
"https://api.botframework.com/.default",
"https://graph.microsoft.com/.default",
];
}
}
async function fetchWithAuthFallback(params: {
url: string;
tokenProvider?: MSTeamsAccessTokenProvider;
fetchFn?: typeof fetch;
}): Promise<Response> {
const fetchFn = params.fetchFn ?? fetch;
const firstAttempt = await fetchFn(params.url);
if (firstAttempt.ok) return firstAttempt;
if (!params.tokenProvider) return firstAttempt;
if (firstAttempt.status !== 401 && firstAttempt.status !== 403)
return firstAttempt;
const scopes = scopeCandidatesForUrl(params.url);
for (const scope of scopes) {
try {
const token = await params.tokenProvider.getAccessToken(scope);
const res = await fetchFn(params.url, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) return res;
} catch {
// Try the next scope.
}
}
return firstAttempt;
}
export async function downloadMSTeamsImageAttachments(params: {
attachments: MSTeamsAttachmentLike[] | undefined;
maxBytes: number;
tokenProvider?: MSTeamsAccessTokenProvider;
allowHosts?: string[];
fetchFn?: typeof fetch;
}): Promise<MSTeamsInboundMedia[]> {
const list = Array.isArray(params.attachments) ? params.attachments : [];
if (list.length === 0) return [];
const allowHosts = resolveAllowedHosts(params.allowHosts);
const candidates: DownloadCandidate[] = list
.filter(isLikelyImageAttachment)
.map(resolveDownloadCandidate)
.filter(Boolean) as DownloadCandidate[];
const inlineCandidates = extractInlineImageCandidates(list);
const seenUrls = new Set<string>();
for (const inline of inlineCandidates) {
if (inline.kind === "url") {
if (!isUrlAllowed(inline.url, allowHosts)) {
continue;
}
if (seenUrls.has(inline.url)) continue;
seenUrls.add(inline.url);
candidates.push({
url: inline.url,
fileHint: inline.fileHint,
contentTypeHint: inline.contentType,
placeholder: inline.placeholder,
});
}
}
if (candidates.length === 0 && inlineCandidates.length === 0) return [];
const out: MSTeamsInboundMedia[] = [];
for (const inline of inlineCandidates) {
if (inline.kind !== "data") continue;
if (inline.data.byteLength > params.maxBytes) continue;
try {
const saved = await saveMediaBuffer(
inline.data,
inline.contentType,
"inbound",
params.maxBytes,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inline.placeholder,
});
} catch {
// Ignore decode failures and continue.
}
}
for (const candidate of candidates) {
if (!isUrlAllowed(candidate.url, allowHosts)) continue;
try {
const res = await fetchWithAuthFallback({
url: candidate.url,
tokenProvider: params.tokenProvider,
fetchFn: params.fetchFn,
});
if (!res.ok) continue;
const buffer = Buffer.from(await res.arrayBuffer());
if (buffer.byteLength > params.maxBytes) continue;
const mime = await detectMime({
buffer,
headerMime: res.headers.get("content-type"),
filePath: candidate.fileHint ?? candidate.url,
});
const saved = await saveMediaBuffer(
buffer,
mime ?? candidate.contentTypeHint,
"inbound",
params.maxBytes,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: candidate.placeholder,
});
} catch {
// Ignore download failures and continue.
}
}
return out;
}
export function buildMSTeamsMediaPayload(
mediaList: Array<{ path: string; contentType?: string }>,
): {
MediaPath?: string;
MediaType?: string;
MediaUrl?: string;
MediaPaths?: string[];
MediaUrls?: string[];
MediaTypes?: string[];
} {
const first = mediaList[0];
const mediaPaths = mediaList.map((media) => media.path);
const mediaTypes = mediaList.map((media) => media.contentType ?? "");
return {
MediaPath: first?.path,
MediaType: first?.contentType,
MediaUrl: first?.path,
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaTypes: mediaPaths.length > 0 ? mediaTypes : undefined,
};
}
export { downloadMSTeamsImageAttachments } from "./attachments/download.js";
export {
buildMSTeamsGraphMessageUrls,
downloadMSTeamsGraphMedia,
} from "./attachments/graph.js";
export {
buildMSTeamsAttachmentPlaceholder,
summarizeMSTeamsHtmlAttachments,
} from "./attachments/html.js";
export { buildMSTeamsMediaPayload } from "./attachments/payload.js";
export type {
MSTeamsAccessTokenProvider,
MSTeamsAttachmentLike,
MSTeamsGraphMediaResult,
MSTeamsHtmlAttachmentSummary,
MSTeamsInboundMedia,
} from "./attachments/types.js";

View File

@@ -0,0 +1,219 @@
import { detectMime } from "../../media/mime.js";
import { saveMediaBuffer } from "../../media/store.js";
import {
extractInlineImageCandidates,
inferPlaceholder,
isLikelyImageAttachment,
isRecord,
isUrlAllowed,
normalizeContentType,
resolveAllowedHosts,
} from "./shared.js";
import type {
MSTeamsAccessTokenProvider,
MSTeamsAttachmentLike,
MSTeamsInboundMedia,
} from "./types.js";
type DownloadCandidate = {
url: string;
fileHint?: string;
contentTypeHint?: string;
placeholder: string;
};
function resolveDownloadCandidate(
att: MSTeamsAttachmentLike,
): DownloadCandidate | null {
const contentType = normalizeContentType(att.contentType);
const name = typeof att.name === "string" ? att.name.trim() : "";
if (contentType === "application/vnd.microsoft.teams.file.download.info") {
if (!isRecord(att.content)) return null;
const downloadUrl =
typeof att.content.downloadUrl === "string"
? att.content.downloadUrl.trim()
: "";
if (!downloadUrl) return null;
const fileType =
typeof att.content.fileType === "string"
? att.content.fileType.trim()
: "";
const uniqueId =
typeof att.content.uniqueId === "string"
? att.content.uniqueId.trim()
: "";
const fileName =
typeof att.content.fileName === "string"
? att.content.fileName.trim()
: "";
const fileHint =
name ||
fileName ||
(uniqueId && fileType ? `${uniqueId}.${fileType}` : "");
return {
url: downloadUrl,
fileHint: fileHint || undefined,
contentTypeHint: undefined,
placeholder: inferPlaceholder({
contentType,
fileName: fileHint,
fileType,
}),
};
}
const contentUrl =
typeof att.contentUrl === "string" ? att.contentUrl.trim() : "";
if (!contentUrl) return null;
return {
url: contentUrl,
fileHint: name || undefined,
contentTypeHint: contentType,
placeholder: inferPlaceholder({ contentType, fileName: name }),
};
}
function scopeCandidatesForUrl(url: string): string[] {
try {
const host = new URL(url).hostname.toLowerCase();
const looksLikeGraph =
host.endsWith("graph.microsoft.com") ||
host.endsWith("sharepoint.com") ||
host.endsWith("1drv.ms") ||
host.includes("sharepoint");
return looksLikeGraph
? [
"https://graph.microsoft.com/.default",
"https://api.botframework.com/.default",
]
: [
"https://api.botframework.com/.default",
"https://graph.microsoft.com/.default",
];
} catch {
return [
"https://api.botframework.com/.default",
"https://graph.microsoft.com/.default",
];
}
}
async function fetchWithAuthFallback(params: {
url: string;
tokenProvider?: MSTeamsAccessTokenProvider;
fetchFn?: typeof fetch;
}): Promise<Response> {
const fetchFn = params.fetchFn ?? fetch;
const firstAttempt = await fetchFn(params.url);
if (firstAttempt.ok) return firstAttempt;
if (!params.tokenProvider) return firstAttempt;
if (firstAttempt.status !== 401 && firstAttempt.status !== 403)
return firstAttempt;
const scopes = scopeCandidatesForUrl(params.url);
for (const scope of scopes) {
try {
const token = await params.tokenProvider.getAccessToken(scope);
const res = await fetchFn(params.url, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) return res;
} catch {
// Try the next scope.
}
}
return firstAttempt;
}
export async function downloadMSTeamsImageAttachments(params: {
attachments: MSTeamsAttachmentLike[] | undefined;
maxBytes: number;
tokenProvider?: MSTeamsAccessTokenProvider;
allowHosts?: string[];
fetchFn?: typeof fetch;
}): Promise<MSTeamsInboundMedia[]> {
const list = Array.isArray(params.attachments) ? params.attachments : [];
if (list.length === 0) return [];
const allowHosts = resolveAllowedHosts(params.allowHosts);
const candidates: DownloadCandidate[] = list
.filter(isLikelyImageAttachment)
.map(resolveDownloadCandidate)
.filter(Boolean) as DownloadCandidate[];
const inlineCandidates = extractInlineImageCandidates(list);
const seenUrls = new Set<string>();
for (const inline of inlineCandidates) {
if (inline.kind === "url") {
if (!isUrlAllowed(inline.url, allowHosts)) continue;
if (seenUrls.has(inline.url)) continue;
seenUrls.add(inline.url);
candidates.push({
url: inline.url,
fileHint: inline.fileHint,
contentTypeHint: inline.contentType,
placeholder: inline.placeholder,
});
}
}
if (candidates.length === 0 && inlineCandidates.length === 0) return [];
const out: MSTeamsInboundMedia[] = [];
for (const inline of inlineCandidates) {
if (inline.kind !== "data") continue;
if (inline.data.byteLength > params.maxBytes) continue;
try {
const saved = await saveMediaBuffer(
inline.data,
inline.contentType,
"inbound",
params.maxBytes,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inline.placeholder,
});
} catch {
// Ignore decode failures and continue.
}
}
for (const candidate of candidates) {
if (!isUrlAllowed(candidate.url, allowHosts)) continue;
try {
const res = await fetchWithAuthFallback({
url: candidate.url,
tokenProvider: params.tokenProvider,
fetchFn: params.fetchFn,
});
if (!res.ok) continue;
const buffer = Buffer.from(await res.arrayBuffer());
if (buffer.byteLength > params.maxBytes) continue;
const mime = await detectMime({
buffer,
headerMime: res.headers.get("content-type"),
filePath: candidate.fileHint ?? candidate.url,
});
const saved = await saveMediaBuffer(
buffer,
mime ?? candidate.contentTypeHint,
"inbound",
params.maxBytes,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: candidate.placeholder,
});
} catch {
// Ignore download failures and continue.
}
}
return out;
}

View File

@@ -0,0 +1,248 @@
import { detectMime } from "../../media/mime.js";
import { saveMediaBuffer } from "../../media/store.js";
import { downloadMSTeamsImageAttachments } from "./download.js";
import {
GRAPH_ROOT,
isRecord,
normalizeContentType,
resolveAllowedHosts,
} from "./shared.js";
import type {
MSTeamsAccessTokenProvider,
MSTeamsAttachmentLike,
MSTeamsGraphMediaResult,
MSTeamsInboundMedia,
} from "./types.js";
type GraphHostedContent = {
id?: string | null;
contentType?: string | null;
contentBytes?: string | null;
};
type GraphAttachment = {
id?: string | null;
contentType?: string | null;
contentUrl?: string | null;
name?: string | null;
thumbnailUrl?: string | null;
content?: unknown;
};
function readNestedString(
value: unknown,
keys: Array<string | number>,
): string | undefined {
let current: unknown = value;
for (const key of keys) {
if (!isRecord(current)) return undefined;
current = current[key as keyof typeof current];
}
return typeof current === "string" && current.trim()
? current.trim()
: undefined;
}
export function buildMSTeamsGraphMessageUrls(params: {
conversationType?: string | null;
conversationId?: string | null;
messageId?: string | null;
replyToId?: string | null;
conversationMessageId?: string | null;
channelData?: unknown;
}): string[] {
const conversationType = params.conversationType?.trim().toLowerCase() ?? "";
const messageIdCandidates = new Set<string>();
const pushCandidate = (value: string | null | undefined) => {
const trimmed = typeof value === "string" ? value.trim() : "";
if (trimmed) messageIdCandidates.add(trimmed);
};
pushCandidate(params.messageId);
pushCandidate(params.conversationMessageId);
pushCandidate(readNestedString(params.channelData, ["messageId"]));
pushCandidate(readNestedString(params.channelData, ["teamsMessageId"]));
const replyToId =
typeof params.replyToId === "string" ? params.replyToId.trim() : "";
if (conversationType === "channel") {
const teamId =
readNestedString(params.channelData, ["team", "id"]) ??
readNestedString(params.channelData, ["teamId"]);
const channelId =
readNestedString(params.channelData, ["channel", "id"]) ??
readNestedString(params.channelData, ["channelId"]) ??
readNestedString(params.channelData, ["teamsChannelId"]);
if (!teamId || !channelId) return [];
const urls: string[] = [];
if (replyToId) {
for (const candidate of messageIdCandidates) {
if (candidate === replyToId) continue;
urls.push(
`${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(replyToId)}/replies/${encodeURIComponent(candidate)}`,
);
}
}
if (messageIdCandidates.size === 0 && replyToId)
messageIdCandidates.add(replyToId);
for (const candidate of messageIdCandidates) {
urls.push(
`${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(candidate)}`,
);
}
return Array.from(new Set(urls));
}
const chatId =
params.conversationId?.trim() ||
readNestedString(params.channelData, ["chatId"]);
if (!chatId) return [];
if (messageIdCandidates.size === 0 && replyToId)
messageIdCandidates.add(replyToId);
const urls = Array.from(messageIdCandidates).map(
(candidate) =>
`${GRAPH_ROOT}/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(candidate)}`,
);
return Array.from(new Set(urls));
}
async function fetchGraphCollection<T>(params: {
url: string;
accessToken: string;
fetchFn?: typeof fetch;
}): Promise<{ status: number; items: T[] }> {
const fetchFn = params.fetchFn ?? fetch;
const res = await fetchFn(params.url, {
headers: { Authorization: `Bearer ${params.accessToken}` },
});
const status = res.status;
if (!res.ok) return { status, items: [] };
try {
const data = (await res.json()) as { value?: T[] };
return { status, items: Array.isArray(data.value) ? data.value : [] };
} catch {
return { status, items: [] };
}
}
function normalizeGraphAttachment(att: GraphAttachment): MSTeamsAttachmentLike {
let content: unknown = att.content;
if (typeof content === "string") {
try {
content = JSON.parse(content);
} catch {
// Keep as raw string if it's not JSON.
}
}
return {
contentType: normalizeContentType(att.contentType) ?? undefined,
contentUrl: att.contentUrl ?? undefined,
name: att.name ?? undefined,
thumbnailUrl: att.thumbnailUrl ?? undefined,
content,
};
}
async function downloadGraphHostedImages(params: {
accessToken: string;
messageUrl: string;
maxBytes: number;
fetchFn?: typeof fetch;
}): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> {
const hosted = await fetchGraphCollection<GraphHostedContent>({
url: `${params.messageUrl}/hostedContents`,
accessToken: params.accessToken,
fetchFn: params.fetchFn,
});
if (hosted.items.length === 0) {
return { media: [], status: hosted.status, count: 0 };
}
const out: MSTeamsInboundMedia[] = [];
for (const item of hosted.items) {
const contentBytes =
typeof item.contentBytes === "string" ? item.contentBytes : "";
if (!contentBytes) continue;
let buffer: Buffer;
try {
buffer = Buffer.from(contentBytes, "base64");
} catch {
continue;
}
if (buffer.byteLength > params.maxBytes) continue;
const mime = await detectMime({
buffer,
headerMime: item.contentType ?? undefined,
});
if (mime && !mime.startsWith("image/")) continue;
try {
const saved = await saveMediaBuffer(
buffer,
mime ?? item.contentType ?? undefined,
"inbound",
params.maxBytes,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: "<media:image>",
});
} catch {
// Ignore save failures.
}
}
return { media: out, status: hosted.status, count: hosted.items.length };
}
export async function downloadMSTeamsGraphMedia(params: {
messageUrl?: string | null;
tokenProvider?: MSTeamsAccessTokenProvider;
maxBytes: number;
allowHosts?: string[];
fetchFn?: typeof fetch;
}): Promise<MSTeamsGraphMediaResult> {
if (!params.messageUrl || !params.tokenProvider) return { media: [] };
const allowHosts = resolveAllowedHosts(params.allowHosts);
const messageUrl = params.messageUrl;
let accessToken: string;
try {
accessToken = await params.tokenProvider.getAccessToken(
"https://graph.microsoft.com/.default",
);
} catch {
return { media: [], messageUrl, tokenError: true };
}
const hosted = await downloadGraphHostedImages({
accessToken,
messageUrl,
maxBytes: params.maxBytes,
fetchFn: params.fetchFn,
});
const attachments = await fetchGraphCollection<GraphAttachment>({
url: `${messageUrl}/attachments`,
accessToken,
fetchFn: params.fetchFn,
});
const normalizedAttachments = attachments.items.map(normalizeGraphAttachment);
const attachmentMedia = await downloadMSTeamsImageAttachments({
attachments: normalizedAttachments,
maxBytes: params.maxBytes,
tokenProvider: params.tokenProvider,
allowHosts,
fetchFn: params.fetchFn,
});
return {
media: [...hosted.media, ...attachmentMedia],
hostedCount: hosted.count,
attachmentCount: attachments.items.length,
hostedStatus: hosted.status,
attachmentStatus: attachments.status,
messageUrl,
};
}

View File

@@ -0,0 +1,79 @@
import {
ATTACHMENT_TAG_RE,
extractHtmlFromAttachment,
extractInlineImageCandidates,
IMG_SRC_RE,
isLikelyImageAttachment,
safeHostForUrl,
} from "./shared.js";
import type {
MSTeamsAttachmentLike,
MSTeamsHtmlAttachmentSummary,
} from "./types.js";
export function summarizeMSTeamsHtmlAttachments(
attachments: MSTeamsAttachmentLike[] | undefined,
): MSTeamsHtmlAttachmentSummary | undefined {
const list = Array.isArray(attachments) ? attachments : [];
if (list.length === 0) return undefined;
let htmlAttachments = 0;
let imgTags = 0;
let dataImages = 0;
let cidImages = 0;
const srcHosts = new Set<string>();
let attachmentTags = 0;
const attachmentIds = new Set<string>();
for (const att of list) {
const html = extractHtmlFromAttachment(att);
if (!html) continue;
htmlAttachments += 1;
IMG_SRC_RE.lastIndex = 0;
let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
while (match) {
imgTags += 1;
const src = match[1]?.trim();
if (src) {
if (src.startsWith("data:")) dataImages += 1;
else if (src.startsWith("cid:")) cidImages += 1;
else srcHosts.add(safeHostForUrl(src));
}
match = IMG_SRC_RE.exec(html);
}
ATTACHMENT_TAG_RE.lastIndex = 0;
let attachmentMatch: RegExpExecArray | null = ATTACHMENT_TAG_RE.exec(html);
while (attachmentMatch) {
attachmentTags += 1;
const id = attachmentMatch[1]?.trim();
if (id) attachmentIds.add(id);
attachmentMatch = ATTACHMENT_TAG_RE.exec(html);
}
}
if (htmlAttachments === 0) return undefined;
return {
htmlAttachments,
imgTags,
dataImages,
cidImages,
srcHosts: Array.from(srcHosts).slice(0, 5),
attachmentTags,
attachmentIds: Array.from(attachmentIds).slice(0, 5),
};
}
export function buildMSTeamsAttachmentPlaceholder(
attachments: MSTeamsAttachmentLike[] | undefined,
): string {
const list = Array.isArray(attachments) ? attachments : [];
if (list.length === 0) return "";
const imageCount = list.filter(isLikelyImageAttachment).length;
const inlineCount = extractInlineImageCandidates(list).length;
const totalImages = imageCount + inlineCount;
if (totalImages > 0) {
return `<media:image>${totalImages > 1 ? ` (${totalImages} images)` : ""}`;
}
const count = list.length;
return `<media:document>${count > 1 ? ` (${count} files)` : ""}`;
}

View File

@@ -0,0 +1,22 @@
export function buildMSTeamsMediaPayload(
mediaList: Array<{ path: string; contentType?: string }>,
): {
MediaPath?: string;
MediaType?: string;
MediaUrl?: string;
MediaPaths?: string[];
MediaUrls?: string[];
MediaTypes?: string[];
} {
const first = mediaList[0];
const mediaPaths = mediaList.map((media) => media.path);
const mediaTypes = mediaList.map((media) => media.contentType ?? "");
return {
MediaPath: first?.path,
MediaType: first?.contentType,
MediaUrl: first?.path,
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaTypes: mediaPaths.length > 0 ? mediaTypes : undefined,
};
}

View File

@@ -0,0 +1,210 @@
import type { MSTeamsAttachmentLike } from "./types.js";
type InlineImageCandidate =
| {
kind: "data";
data: Buffer;
contentType?: string;
placeholder: string;
}
| {
kind: "url";
url: string;
contentType?: string;
fileHint?: string;
placeholder: string;
};
export const IMAGE_EXT_RE = /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/i;
export const IMG_SRC_RE = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
export const ATTACHMENT_TAG_RE = /<attachment[^>]+id=["']([^"']+)["'][^>]*>/gi;
export const DEFAULT_MEDIA_HOST_ALLOWLIST = [
"graph.microsoft.com",
"graph.microsoft.us",
"graph.microsoft.de",
"graph.microsoft.cn",
"sharepoint.com",
"sharepoint.us",
"sharepoint.de",
"sharepoint.cn",
"sharepoint-df.com",
"1drv.ms",
"onedrive.com",
"teams.microsoft.com",
"teams.cdn.office.net",
"statics.teams.cdn.office.net",
"office.com",
"office.net",
] as const;
export const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
export function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function normalizeContentType(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
export function inferPlaceholder(params: {
contentType?: string;
fileName?: string;
fileType?: string;
}): string {
const mime = params.contentType?.toLowerCase() ?? "";
const name = params.fileName?.toLowerCase() ?? "";
const fileType = params.fileType?.toLowerCase() ?? "";
const looksLikeImage =
mime.startsWith("image/") ||
IMAGE_EXT_RE.test(name) ||
IMAGE_EXT_RE.test(`x.${fileType}`);
return looksLikeImage ? "<media:image>" : "<media:document>";
}
export function isLikelyImageAttachment(att: MSTeamsAttachmentLike): boolean {
const contentType = normalizeContentType(att.contentType) ?? "";
const name = typeof att.name === "string" ? att.name : "";
if (contentType.startsWith("image/")) return true;
if (IMAGE_EXT_RE.test(name)) return true;
if (
contentType === "application/vnd.microsoft.teams.file.download.info" &&
isRecord(att.content)
) {
const fileType =
typeof att.content.fileType === "string" ? att.content.fileType : "";
if (fileType && IMAGE_EXT_RE.test(`x.${fileType}`)) return true;
const fileName =
typeof att.content.fileName === "string" ? att.content.fileName : "";
if (fileName && IMAGE_EXT_RE.test(fileName)) return true;
}
return false;
}
function isHtmlAttachment(att: MSTeamsAttachmentLike): boolean {
const contentType = normalizeContentType(att.contentType) ?? "";
return contentType.startsWith("text/html");
}
export function extractHtmlFromAttachment(
att: MSTeamsAttachmentLike,
): string | undefined {
if (!isHtmlAttachment(att)) return undefined;
if (typeof att.content === "string") return att.content;
if (!isRecord(att.content)) return undefined;
const text =
typeof att.content.text === "string"
? att.content.text
: typeof att.content.body === "string"
? att.content.body
: typeof att.content.content === "string"
? att.content.content
: undefined;
return text;
}
function decodeDataImage(src: string): InlineImageCandidate | null {
const match = /^data:(image\/[a-z0-9.+-]+)?(;base64)?,(.*)$/i.exec(src);
if (!match) return null;
const contentType = match[1]?.toLowerCase();
const isBase64 = Boolean(match[2]);
if (!isBase64) return null;
const payload = match[3] ?? "";
if (!payload) return null;
try {
const data = Buffer.from(payload, "base64");
return { kind: "data", data, contentType, placeholder: "<media:image>" };
} catch {
return null;
}
}
function fileHintFromUrl(src: string): string | undefined {
try {
const url = new URL(src);
const name = url.pathname.split("/").pop();
return name || undefined;
} catch {
return undefined;
}
}
export function extractInlineImageCandidates(
attachments: MSTeamsAttachmentLike[],
): InlineImageCandidate[] {
const out: InlineImageCandidate[] = [];
for (const att of attachments) {
const html = extractHtmlFromAttachment(att);
if (!html) continue;
IMG_SRC_RE.lastIndex = 0;
let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
while (match) {
const src = match[1]?.trim();
if (src && !src.startsWith("cid:")) {
if (src.startsWith("data:")) {
const decoded = decodeDataImage(src);
if (decoded) out.push(decoded);
} else {
out.push({
kind: "url",
url: src,
fileHint: fileHintFromUrl(src),
placeholder: "<media:image>",
});
}
}
match = IMG_SRC_RE.exec(html);
}
}
return out;
}
export function safeHostForUrl(url: string): string {
try {
return new URL(url).hostname.toLowerCase();
} catch {
return "invalid-url";
}
}
function normalizeAllowHost(value: string): string {
const trimmed = value.trim().toLowerCase();
if (!trimmed) return "";
if (trimmed === "*") return "*";
return trimmed.replace(/^\*\.?/, "");
}
export function resolveAllowedHosts(input?: string[]): string[] {
if (!Array.isArray(input) || input.length === 0) {
return DEFAULT_MEDIA_HOST_ALLOWLIST.slice();
}
const normalized = input.map(normalizeAllowHost).filter(Boolean);
if (normalized.includes("*")) return ["*"];
return normalized;
}
function isHostAllowed(host: string, allowlist: string[]): boolean {
if (allowlist.includes("*")) return true;
const normalized = host.toLowerCase();
return allowlist.some(
(entry) => normalized === entry || normalized.endsWith(`.${entry}`),
);
}
export function isUrlAllowed(url: string, allowlist: string[]): boolean {
try {
const parsed = new URL(url);
if (parsed.protocol !== "https:") return false;
return isHostAllowed(parsed.hostname, allowlist);
} catch {
return false;
}
}

View File

@@ -0,0 +1,37 @@
export type MSTeamsAttachmentLike = {
contentType?: string | null;
contentUrl?: string | null;
name?: string | null;
thumbnailUrl?: string | null;
content?: unknown;
};
export type MSTeamsAccessTokenProvider = {
getAccessToken: (scope: string) => Promise<string>;
};
export type MSTeamsInboundMedia = {
path: string;
contentType?: string;
placeholder: string;
};
export type MSTeamsHtmlAttachmentSummary = {
htmlAttachments: number;
imgTags: number;
dataImages: number;
cidImages: number;
srcHosts: string[];
attachmentTags: number;
attachmentIds: string[];
};
export type MSTeamsGraphMediaResult = {
media: MSTeamsInboundMedia[];
hostedCount?: number;
attachmentCount?: number;
hostedStatus?: number;
attachmentStatus?: number;
messageUrl?: string;
tokenError?: boolean;
};

View File

@@ -1,50 +1,11 @@
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
import {
buildHistoryContextFromMap,
clearHistoryEntries,
DEFAULT_GROUP_HISTORY_LIMIT,
type HistoryEntry,
} from "../auto-reply/reply/history.js";
import type { ClawdbotConfig } from "../config/types.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../pairing/pairing-store.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { danger } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import {
buildMSTeamsAttachmentPlaceholder,
buildMSTeamsGraphMessageUrls,
buildMSTeamsMediaPayload,
downloadMSTeamsGraphMedia,
downloadMSTeamsImageAttachments,
type MSTeamsAttachmentLike,
summarizeMSTeamsHtmlAttachments,
} from "./attachments.js";
import type {
MSTeamsConversationStore,
StoredConversationReference,
} from "./conversation-store.js";
import { formatUnknownError } from "./errors.js";
import {
extractMSTeamsConversationMessageId,
normalizeMSTeamsConversationId,
parseMSTeamsActivityTimestamp,
stripMSTeamsMentionTags,
wasMSTeamsBotMentioned,
} from "./inbound.js";
import type { MSTeamsConversationStore } from "./conversation-store.js";
import type { MSTeamsAdapter } from "./messenger.js";
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
import {
isMSTeamsGroupAllowed,
resolveMSTeamsReplyPolicy,
resolveMSTeamsRouteConfig,
} from "./policy.js";
import { extractMSTeamsPollVote, type MSTeamsPollStore } from "./polls.js";
import { createMSTeamsReplyDispatcher } from "./reply-dispatcher.js";
import type { MSTeamsPollStore } from "./polls.js";
import type { MSTeamsTurnContext } from "./sdk-types.js";
export type MSTeamsAccessTokenProvider = {
@@ -103,495 +64,3 @@ export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
return handler;
}
function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const {
cfg,
runtime,
appId,
adapter,
tokenProvider,
textLimit,
mediaMaxBytes,
conversationStore,
pollStore,
log,
} = deps;
const msteamsCfg = cfg.channels?.msteams;
const historyLimit = Math.max(
0,
msteamsCfg?.historyLimit ??
cfg.messages?.groupChat?.historyLimit ??
DEFAULT_GROUP_HISTORY_LIMIT,
);
const conversationHistories = new Map<string, HistoryEntry[]>();
return async function handleTeamsMessage(context: MSTeamsTurnContext) {
const activity = context.activity;
const rawText = activity.text?.trim() ?? "";
const text = stripMSTeamsMentionTags(rawText);
const attachments = Array.isArray(activity.attachments)
? (activity.attachments as unknown as MSTeamsAttachmentLike[])
: [];
const attachmentPlaceholder =
buildMSTeamsAttachmentPlaceholder(attachments);
const rawBody = text || attachmentPlaceholder;
const from = activity.from;
const conversation = activity.conversation;
const attachmentTypes = attachments
.map((att) =>
typeof att.contentType === "string" ? att.contentType : undefined,
)
.filter(Boolean)
.slice(0, 3);
const htmlSummary = summarizeMSTeamsHtmlAttachments(attachments);
log.info("received message", {
rawText: rawText.slice(0, 50),
text: text.slice(0, 50),
attachments: attachments.length,
attachmentTypes,
from: from?.id,
conversation: conversation?.id,
});
if (htmlSummary) {
log.debug("html attachment summary", htmlSummary);
}
if (!from?.id) {
log.debug("skipping message without from.id");
return;
}
// Teams conversation.id may include ";messageid=..." suffix - strip it for session key
const rawConversationId = conversation?.id ?? "";
const conversationId = normalizeMSTeamsConversationId(rawConversationId);
const conversationMessageId =
extractMSTeamsConversationMessageId(rawConversationId);
const conversationType = conversation?.conversationType ?? "personal";
const isGroupChat =
conversationType === "groupChat" || conversation?.isGroup === true;
const isChannel = conversationType === "channel";
const isDirectMessage = !isGroupChat && !isChannel;
const senderName = from.name ?? from.id;
const senderId = from.aadObjectId ?? from.id;
const storedAllowFrom = await readChannelAllowFromStore("msteams").catch(
() => [],
);
// Check DM policy for direct messages
if (isDirectMessage && msteamsCfg) {
const dmPolicy = msteamsCfg.dmPolicy ?? "pairing";
const allowFrom = msteamsCfg.allowFrom ?? [];
if (dmPolicy === "disabled") {
log.debug("dropping dm (dms disabled)");
return;
}
if (dmPolicy !== "open") {
// Check allowlist - look up from config and pairing store
const effectiveAllowFrom = [
...allowFrom.map((v) => String(v).toLowerCase()),
...storedAllowFrom,
];
const senderLower = senderId.toLowerCase();
const senderNameLower = senderName.toLowerCase();
const allowed =
effectiveAllowFrom.includes("*") ||
effectiveAllowFrom.includes(senderLower) ||
effectiveAllowFrom.includes(senderNameLower);
if (!allowed) {
if (dmPolicy === "pairing") {
const request = await upsertChannelPairingRequest({
channel: "msteams",
id: senderId,
meta: { name: senderName },
});
if (request) {
log.info("msteams pairing request created", {
sender: senderId,
label: senderName,
});
}
}
log.debug("dropping dm (not allowlisted)", {
sender: senderId,
label: senderName,
});
return;
}
}
}
if (!isDirectMessage && msteamsCfg) {
const groupPolicy = msteamsCfg.groupPolicy ?? "allowlist";
const groupAllowFrom =
msteamsCfg.groupAllowFrom ??
(msteamsCfg.allowFrom && msteamsCfg.allowFrom.length > 0
? msteamsCfg.allowFrom
: []);
const effectiveGroupAllowFrom = [
...groupAllowFrom.map((v) => String(v)),
...storedAllowFrom,
];
if (groupPolicy === "disabled") {
log.debug("dropping group message (groupPolicy: disabled)", {
conversationId,
});
return;
}
if (groupPolicy === "allowlist") {
if (effectiveGroupAllowFrom.length === 0) {
log.debug(
"dropping group message (groupPolicy: allowlist, no groupAllowFrom)",
{ conversationId },
);
return;
}
const allowed = isMSTeamsGroupAllowed({
groupPolicy,
allowFrom: effectiveGroupAllowFrom,
senderId,
senderName,
});
if (!allowed) {
log.debug("dropping group message (not in groupAllowFrom)", {
sender: senderId,
label: senderName,
});
return;
}
}
}
// 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 },
agent,
bot: agent ? { id: agent.id, name: agent.name } : undefined,
conversation: {
id: conversationId,
conversationType,
tenantId: conversation?.tenantId,
},
teamId,
channelId: activity.channelId,
serviceUrl: activity.serviceUrl,
locale: activity.locale,
};
conversationStore.upsert(conversationId, conversationRef).catch((err) => {
log.debug("failed to save conversation reference", {
error: formatUnknownError(err),
});
});
const pollVote = extractMSTeamsPollVote(activity);
if (pollVote) {
try {
const poll = await pollStore.recordVote({
pollId: pollVote.pollId,
voterId: senderId,
selections: pollVote.selections,
});
if (!poll) {
log.debug("poll vote ignored (poll not found)", {
pollId: pollVote.pollId,
});
} else {
log.info("recorded poll vote", {
pollId: pollVote.pollId,
voter: senderId,
selections: pollVote.selections,
});
}
} catch (err) {
log.error("failed to record poll vote", {
pollId: pollVote.pollId,
error: formatUnknownError(err),
});
}
return;
}
if (!rawBody) {
log.debug("skipping empty message after stripping mentions");
return;
}
// Build Teams-specific identifiers
const teamsFrom = isDirectMessage
? `msteams:${senderId}`
: isChannel
? `msteams:channel:${conversationId}`
: `msteams:group:${conversationId}`;
const teamsTo = isDirectMessage
? `user:${senderId}`
: `conversation:${conversationId}`;
// Resolve routing
const route = resolveAgentRoute({
cfg,
channel: "msteams",
peer: {
kind: isDirectMessage ? "dm" : isChannel ? "channel" : "group",
id: isDirectMessage ? senderId : conversationId,
},
});
const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
const inboundLabel = isDirectMessage
? `Teams DM from ${senderName}`
: `Teams message in ${conversationType} from ${senderName}`;
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
sessionKey: route.sessionKey,
contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`,
});
// Resolve team/channel config for channels and group chats
const channelId = conversationId;
const { teamConfig, channelConfig } = resolveMSTeamsRouteConfig({
cfg: msteamsCfg,
teamId,
conversationId: channelId,
});
const { requireMention, replyStyle } = resolveMSTeamsReplyPolicy({
isDirectMessage,
globalConfig: msteamsCfg,
teamConfig,
channelConfig,
});
// Check requireMention for channels and group chats
if (!isDirectMessage) {
const mentioned = wasMSTeamsBotMentioned(activity);
if (requireMention && !mentioned) {
log.debug("skipping message (mention required)", {
teamId,
channelId,
requireMention,
mentioned,
});
return;
}
}
// Format the message body with envelope
const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp);
let mediaList = await downloadMSTeamsImageAttachments({
attachments,
maxBytes: mediaMaxBytes,
tokenProvider: {
getAccessToken: (scope) => tokenProvider.getAccessToken(scope),
},
allowHosts: msteamsCfg?.mediaAllowHosts,
});
if (mediaList.length === 0) {
const onlyHtmlAttachments =
attachments.length > 0 &&
attachments.every((att) =>
String(att.contentType ?? "").startsWith("text/html"),
);
if (onlyHtmlAttachments) {
const messageUrls = buildMSTeamsGraphMessageUrls({
conversationType,
conversationId,
messageId: activity.id ?? undefined,
replyToId: activity.replyToId ?? undefined,
conversationMessageId,
channelData: activity.channelData,
});
if (messageUrls.length === 0) {
log.debug("graph message url unavailable", {
conversationType,
hasChannelData: Boolean(activity.channelData),
messageId: activity.id ?? undefined,
replyToId: activity.replyToId ?? undefined,
});
} else {
const attempts: Array<{
url: string;
hostedStatus?: number;
attachmentStatus?: number;
hostedCount?: number;
attachmentCount?: number;
tokenError?: boolean;
}> = [];
for (const messageUrl of messageUrls) {
const graphMedia = await downloadMSTeamsGraphMedia({
messageUrl,
tokenProvider: {
getAccessToken: (scope) => tokenProvider.getAccessToken(scope),
},
maxBytes: mediaMaxBytes,
allowHosts: msteamsCfg?.mediaAllowHosts,
});
attempts.push({
url: messageUrl,
hostedStatus: graphMedia.hostedStatus,
attachmentStatus: graphMedia.attachmentStatus,
hostedCount: graphMedia.hostedCount,
attachmentCount: graphMedia.attachmentCount,
tokenError: graphMedia.tokenError,
});
if (graphMedia.media.length > 0) {
mediaList = graphMedia.media;
break;
}
if (graphMedia.tokenError) break;
}
if (mediaList.length === 0) {
log.debug("graph media fetch empty", { attempts });
}
}
}
}
if (mediaList.length > 0) {
log.debug("downloaded image attachments", { count: mediaList.length });
} else if (htmlSummary?.imgTags) {
log.debug("inline images detected but none downloaded", {
imgTags: htmlSummary.imgTags,
srcHosts: htmlSummary.srcHosts,
dataImages: htmlSummary.dataImages,
cidImages: htmlSummary.cidImages,
});
}
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
const body = formatAgentEnvelope({
channel: "Teams",
from: senderName,
timestamp,
body: rawBody,
});
let combinedBody = body;
const isRoomish = !isDirectMessage;
const historyKey = isRoomish ? conversationId : undefined;
if (isRoomish && historyKey && historyLimit > 0) {
combinedBody = buildHistoryContextFromMap({
historyMap: conversationHistories,
historyKey,
limit: historyLimit,
entry: {
sender: senderName,
body: rawBody,
timestamp: timestamp?.getTime(),
messageId: activity.id ?? undefined,
},
currentMessage: combinedBody,
formatEntry: (entry) =>
formatAgentEnvelope({
channel: "Teams",
from: conversationType,
timestamp: entry.timestamp,
body: `${entry.sender}: ${entry.body}${
entry.messageId ? ` [id:${entry.messageId}]` : ""
}`,
}),
});
}
// Build context payload for agent
const ctxPayload = {
Body: combinedBody,
RawBody: rawBody,
CommandBody: rawBody,
From: teamsFrom,
To: teamsTo,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : isChannel ? "room" : "group",
GroupSubject: !isDirectMessage ? conversationType : undefined,
SenderName: senderName,
SenderId: senderId,
Provider: "msteams" as const,
Surface: "msteams" as const,
MessageSid: activity.id,
Timestamp: timestamp?.getTime() ?? Date.now(),
WasMentioned: isDirectMessage || wasMSTeamsBotMentioned(activity),
CommandAuthorized: true,
OriginatingChannel: "msteams" as const,
OriginatingTo: teamsTo,
...mediaPayload,
};
if (shouldLogVerbose()) {
logVerbose(
`msteams inbound: from=${ctxPayload.From} preview="${preview}"`,
);
}
// Create reply dispatcher
const { dispatcher, replyOptions, markDispatchIdle } =
createMSTeamsReplyDispatcher({
cfg,
agentId: route.agentId,
runtime,
log,
adapter,
appId,
conversationRef,
context,
replyStyle,
textLimit,
});
// Dispatch to agent
log.info("dispatching to agent", { sessionKey: route.sessionKey });
try {
const { queuedFinal, counts } = await dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions,
});
markDispatchIdle();
log.info("dispatch complete", { queuedFinal, counts });
const didSendReply = counts.final + counts.tool + counts.block > 0;
if (!queuedFinal) {
if (isRoomish && historyKey && historyLimit > 0 && didSendReply) {
clearHistoryEntries({
historyMap: conversationHistories,
historyKey,
});
}
return;
}
if (shouldLogVerbose()) {
const finalCount = counts.final;
logVerbose(
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
);
}
if (isRoomish && historyKey && historyLimit > 0 && didSendReply) {
clearHistoryEntries({
historyMap: conversationHistories,
historyKey,
});
}
} catch (err) {
log.error("dispatch failed", { error: String(err) });
runtime.error?.(danger(`msteams dispatch failed: ${String(err)}`));
// Try to send error message back to Teams.
try {
await context.sendActivity(
`⚠️ Agent failed: ${err instanceof Error ? err.message : String(err)}`,
);
} catch {
// Best effort.
}
}
};
}

View File

@@ -0,0 +1,123 @@
import {
buildMSTeamsGraphMessageUrls,
downloadMSTeamsGraphMedia,
downloadMSTeamsImageAttachments,
type MSTeamsAccessTokenProvider,
type MSTeamsAttachmentLike,
type MSTeamsHtmlAttachmentSummary,
type MSTeamsInboundMedia,
} from "../attachments.js";
import type { MSTeamsTurnContext } from "../sdk-types.js";
type MSTeamsLogger = {
debug: (message: string, meta?: Record<string, unknown>) => void;
};
export async function resolveMSTeamsInboundMedia(params: {
attachments: MSTeamsAttachmentLike[];
htmlSummary?: MSTeamsHtmlAttachmentSummary;
maxBytes: number;
allowHosts?: string[];
tokenProvider: MSTeamsAccessTokenProvider;
conversationType: string;
conversationId: string;
conversationMessageId?: string;
activity: Pick<
MSTeamsTurnContext["activity"],
"id" | "replyToId" | "channelData"
>;
log: MSTeamsLogger;
}): Promise<MSTeamsInboundMedia[]> {
const {
attachments,
htmlSummary,
maxBytes,
tokenProvider,
allowHosts,
conversationType,
conversationId,
conversationMessageId,
activity,
log,
} = params;
let mediaList = await downloadMSTeamsImageAttachments({
attachments,
maxBytes,
tokenProvider,
allowHosts,
});
if (mediaList.length === 0) {
const onlyHtmlAttachments =
attachments.length > 0 &&
attachments.every((att) =>
String(att.contentType ?? "").startsWith("text/html"),
);
if (onlyHtmlAttachments) {
const messageUrls = buildMSTeamsGraphMessageUrls({
conversationType,
conversationId,
messageId: activity.id ?? undefined,
replyToId: activity.replyToId ?? undefined,
conversationMessageId,
channelData: activity.channelData,
});
if (messageUrls.length === 0) {
log.debug("graph message url unavailable", {
conversationType,
hasChannelData: Boolean(activity.channelData),
messageId: activity.id ?? undefined,
replyToId: activity.replyToId ?? undefined,
});
} else {
const attempts: Array<{
url: string;
hostedStatus?: number;
attachmentStatus?: number;
hostedCount?: number;
attachmentCount?: number;
tokenError?: boolean;
}> = [];
for (const messageUrl of messageUrls) {
const graphMedia = await downloadMSTeamsGraphMedia({
messageUrl,
tokenProvider,
maxBytes,
allowHosts,
});
attempts.push({
url: messageUrl,
hostedStatus: graphMedia.hostedStatus,
attachmentStatus: graphMedia.attachmentStatus,
hostedCount: graphMedia.hostedCount,
attachmentCount: graphMedia.attachmentCount,
tokenError: graphMedia.tokenError,
});
if (graphMedia.media.length > 0) {
mediaList = graphMedia.media;
break;
}
if (graphMedia.tokenError) break;
}
if (mediaList.length === 0) {
log.debug("graph media fetch empty", { attempts });
}
}
}
}
if (mediaList.length > 0) {
log.debug("downloaded image attachments", { count: mediaList.length });
} else if (htmlSummary?.imgTags) {
log.debug("inline images detected but none downloaded", {
imgTags: htmlSummary.imgTags,
srcHosts: htmlSummary.srcHosts,
dataImages: htmlSummary.dataImages,
cidImages: htmlSummary.cidImages,
});
}
return mediaList;
}

View File

@@ -0,0 +1,456 @@
import { formatAgentEnvelope } from "../../auto-reply/envelope.js";
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
import {
buildHistoryContextFromMap,
clearHistoryEntries,
DEFAULT_GROUP_HISTORY_LIMIT,
type HistoryEntry,
} from "../../auto-reply/reply/history.js";
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import {
buildMSTeamsAttachmentPlaceholder,
buildMSTeamsMediaPayload,
type MSTeamsAttachmentLike,
summarizeMSTeamsHtmlAttachments,
} from "../attachments.js";
import type { StoredConversationReference } from "../conversation-store.js";
import { formatUnknownError } from "../errors.js";
import {
extractMSTeamsConversationMessageId,
normalizeMSTeamsConversationId,
parseMSTeamsActivityTimestamp,
stripMSTeamsMentionTags,
wasMSTeamsBotMentioned,
} from "../inbound.js";
import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.js";
import {
isMSTeamsGroupAllowed,
resolveMSTeamsReplyPolicy,
resolveMSTeamsRouteConfig,
} from "../policy.js";
import { extractMSTeamsPollVote } from "../polls.js";
import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js";
import type { MSTeamsTurnContext } from "../sdk-types.js";
import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const {
cfg,
runtime,
appId,
adapter,
tokenProvider,
textLimit,
mediaMaxBytes,
conversationStore,
pollStore,
log,
} = deps;
const msteamsCfg = cfg.channels?.msteams;
const historyLimit = Math.max(
0,
msteamsCfg?.historyLimit ??
cfg.messages?.groupChat?.historyLimit ??
DEFAULT_GROUP_HISTORY_LIMIT,
);
const conversationHistories = new Map<string, HistoryEntry[]>();
return async function handleTeamsMessage(context: MSTeamsTurnContext) {
const activity = context.activity;
const rawText = activity.text?.trim() ?? "";
const text = stripMSTeamsMentionTags(rawText);
const attachments = Array.isArray(activity.attachments)
? (activity.attachments as unknown as MSTeamsAttachmentLike[])
: [];
const attachmentPlaceholder =
buildMSTeamsAttachmentPlaceholder(attachments);
const rawBody = text || attachmentPlaceholder;
const from = activity.from;
const conversation = activity.conversation;
const attachmentTypes = attachments
.map((att) =>
typeof att.contentType === "string" ? att.contentType : undefined,
)
.filter(Boolean)
.slice(0, 3);
const htmlSummary = summarizeMSTeamsHtmlAttachments(attachments);
log.info("received message", {
rawText: rawText.slice(0, 50),
text: text.slice(0, 50),
attachments: attachments.length,
attachmentTypes,
from: from?.id,
conversation: conversation?.id,
});
if (htmlSummary) log.debug("html attachment summary", htmlSummary);
if (!from?.id) {
log.debug("skipping message without from.id");
return;
}
// Teams conversation.id may include ";messageid=..." suffix - strip it for session key.
const rawConversationId = conversation?.id ?? "";
const conversationId = normalizeMSTeamsConversationId(rawConversationId);
const conversationMessageId =
extractMSTeamsConversationMessageId(rawConversationId);
const conversationType = conversation?.conversationType ?? "personal";
const isGroupChat =
conversationType === "groupChat" || conversation?.isGroup === true;
const isChannel = conversationType === "channel";
const isDirectMessage = !isGroupChat && !isChannel;
const senderName = from.name ?? from.id;
const senderId = from.aadObjectId ?? from.id;
const storedAllowFrom = await readChannelAllowFromStore("msteams").catch(
() => [],
);
// Check DM policy for direct messages.
if (isDirectMessage && msteamsCfg) {
const dmPolicy = msteamsCfg.dmPolicy ?? "pairing";
const allowFrom = msteamsCfg.allowFrom ?? [];
if (dmPolicy === "disabled") {
log.debug("dropping dm (dms disabled)");
return;
}
if (dmPolicy !== "open") {
const effectiveAllowFrom = [
...allowFrom.map((v) => String(v).toLowerCase()),
...storedAllowFrom,
];
const senderLower = senderId.toLowerCase();
const senderNameLower = senderName.toLowerCase();
const allowed =
effectiveAllowFrom.includes("*") ||
effectiveAllowFrom.includes(senderLower) ||
effectiveAllowFrom.includes(senderNameLower);
if (!allowed) {
if (dmPolicy === "pairing") {
const request = await upsertChannelPairingRequest({
channel: "msteams",
id: senderId,
meta: { name: senderName },
});
if (request) {
log.info("msteams pairing request created", {
sender: senderId,
label: senderName,
});
}
}
log.debug("dropping dm (not allowlisted)", {
sender: senderId,
label: senderName,
});
return;
}
}
}
if (!isDirectMessage && msteamsCfg) {
const groupPolicy = msteamsCfg.groupPolicy ?? "allowlist";
const groupAllowFrom =
msteamsCfg.groupAllowFrom ??
(msteamsCfg.allowFrom && msteamsCfg.allowFrom.length > 0
? msteamsCfg.allowFrom
: []);
const effectiveGroupAllowFrom = [
...groupAllowFrom.map((v) => String(v)),
...storedAllowFrom,
];
if (groupPolicy === "disabled") {
log.debug("dropping group message (groupPolicy: disabled)", {
conversationId,
});
return;
}
if (groupPolicy === "allowlist") {
if (effectiveGroupAllowFrom.length === 0) {
log.debug(
"dropping group message (groupPolicy: allowlist, no groupAllowFrom)",
{
conversationId,
},
);
return;
}
const allowed = isMSTeamsGroupAllowed({
groupPolicy,
allowFrom: effectiveGroupAllowFrom,
senderId,
senderName,
});
if (!allowed) {
log.debug("dropping group message (not in groupAllowFrom)", {
sender: senderId,
label: senderName,
});
return;
}
}
}
// 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 },
agent,
bot: agent ? { id: agent.id, name: agent.name } : undefined,
conversation: {
id: conversationId,
conversationType,
tenantId: conversation?.tenantId,
},
teamId,
channelId: activity.channelId,
serviceUrl: activity.serviceUrl,
locale: activity.locale,
};
conversationStore.upsert(conversationId, conversationRef).catch((err) => {
log.debug("failed to save conversation reference", {
error: formatUnknownError(err),
});
});
const pollVote = extractMSTeamsPollVote(activity);
if (pollVote) {
try {
const poll = await pollStore.recordVote({
pollId: pollVote.pollId,
voterId: senderId,
selections: pollVote.selections,
});
if (!poll) {
log.debug("poll vote ignored (poll not found)", {
pollId: pollVote.pollId,
});
} else {
log.info("recorded poll vote", {
pollId: pollVote.pollId,
voter: senderId,
selections: pollVote.selections,
});
}
} catch (err) {
log.error("failed to record poll vote", {
pollId: pollVote.pollId,
error: formatUnknownError(err),
});
}
return;
}
if (!rawBody) {
log.debug("skipping empty message after stripping mentions");
return;
}
const teamsFrom = isDirectMessage
? `msteams:${senderId}`
: isChannel
? `msteams:channel:${conversationId}`
: `msteams:group:${conversationId}`;
const teamsTo = isDirectMessage
? `user:${senderId}`
: `conversation:${conversationId}`;
const route = resolveAgentRoute({
cfg,
channel: "msteams",
peer: {
kind: isDirectMessage ? "dm" : isChannel ? "channel" : "group",
id: isDirectMessage ? senderId : conversationId,
},
});
const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
const inboundLabel = isDirectMessage
? `Teams DM from ${senderName}`
: `Teams message in ${conversationType} from ${senderName}`;
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
sessionKey: route.sessionKey,
contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`,
});
const channelId = conversationId;
const { teamConfig, channelConfig } = resolveMSTeamsRouteConfig({
cfg: msteamsCfg,
teamId,
conversationId: channelId,
});
const { requireMention, replyStyle } = resolveMSTeamsReplyPolicy({
isDirectMessage,
globalConfig: msteamsCfg,
teamConfig,
channelConfig,
});
if (!isDirectMessage) {
const mentioned = wasMSTeamsBotMentioned(activity);
if (requireMention && !mentioned) {
log.debug("skipping message (mention required)", {
teamId,
channelId,
requireMention,
mentioned,
});
return;
}
}
const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp);
const mediaList = await resolveMSTeamsInboundMedia({
attachments,
htmlSummary: htmlSummary ?? undefined,
maxBytes: mediaMaxBytes,
tokenProvider,
allowHosts: msteamsCfg?.mediaAllowHosts,
conversationType,
conversationId,
conversationMessageId: conversationMessageId ?? undefined,
activity: {
id: activity.id,
replyToId: activity.replyToId,
channelData: activity.channelData,
},
log,
});
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
const body = formatAgentEnvelope({
channel: "Teams",
from: senderName,
timestamp,
body: rawBody,
});
let combinedBody = body;
const isRoomish = !isDirectMessage;
const historyKey = isRoomish ? conversationId : undefined;
if (isRoomish && historyKey && historyLimit > 0) {
combinedBody = buildHistoryContextFromMap({
historyMap: conversationHistories,
historyKey,
limit: historyLimit,
entry: {
sender: senderName,
body: rawBody,
timestamp: timestamp?.getTime(),
messageId: activity.id ?? undefined,
},
currentMessage: combinedBody,
formatEntry: (entry) =>
formatAgentEnvelope({
channel: "Teams",
from: conversationType,
timestamp: entry.timestamp,
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
}),
});
}
const ctxPayload = {
Body: combinedBody,
RawBody: rawBody,
CommandBody: rawBody,
From: teamsFrom,
To: teamsTo,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : isChannel ? "room" : "group",
GroupSubject: !isDirectMessage ? conversationType : undefined,
SenderName: senderName,
SenderId: senderId,
Provider: "msteams" as const,
Surface: "msteams" as const,
MessageSid: activity.id,
Timestamp: timestamp?.getTime() ?? Date.now(),
WasMentioned: isDirectMessage || wasMSTeamsBotMentioned(activity),
CommandAuthorized: true,
OriginatingChannel: "msteams" as const,
OriginatingTo: teamsTo,
...mediaPayload,
};
if (shouldLogVerbose()) {
logVerbose(
`msteams inbound: from=${ctxPayload.From} preview="${preview}"`,
);
}
const { dispatcher, replyOptions, markDispatchIdle } =
createMSTeamsReplyDispatcher({
cfg,
agentId: route.agentId,
runtime,
log,
adapter,
appId,
conversationRef,
context,
replyStyle,
textLimit,
});
log.info("dispatching to agent", { sessionKey: route.sessionKey });
try {
const { queuedFinal, counts } = await dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions,
});
markDispatchIdle();
log.info("dispatch complete", { queuedFinal, counts });
const didSendReply = counts.final + counts.tool + counts.block > 0;
if (!queuedFinal) {
if (isRoomish && historyKey && historyLimit > 0 && didSendReply) {
clearHistoryEntries({
historyMap: conversationHistories,
historyKey,
});
}
return;
}
if (shouldLogVerbose()) {
const finalCount = counts.final;
logVerbose(
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
);
}
if (isRoomish && historyKey && historyLimit > 0 && didSendReply) {
clearHistoryEntries({ historyMap: conversationHistories, historyKey });
}
} catch (err) {
log.error("dispatch failed", { error: String(err) });
runtime.error?.(danger(`msteams dispatch failed: ${String(err)}`));
try {
await context.sendActivity(
`⚠️ Agent failed: ${err instanceof Error ? err.message : String(err)}`,
);
} catch {
// Best effort.
}
}
};
}