refactor(src): split oversized modules
This commit is contained in:
BIN
src/msteams/.DS_Store
vendored
Normal file
BIN
src/msteams/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -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";
|
||||
|
||||
219
src/msteams/attachments/download.ts
Normal file
219
src/msteams/attachments/download.ts
Normal 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;
|
||||
}
|
||||
248
src/msteams/attachments/graph.ts
Normal file
248
src/msteams/attachments/graph.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
79
src/msteams/attachments/html.ts
Normal file
79
src/msteams/attachments/html.ts
Normal 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)` : ""}`;
|
||||
}
|
||||
22
src/msteams/attachments/payload.ts
Normal file
22
src/msteams/attachments/payload.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
210
src/msteams/attachments/shared.ts
Normal file
210
src/msteams/attachments/shared.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
37
src/msteams/attachments/types.ts
Normal file
37
src/msteams/attachments/types.ts
Normal 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;
|
||||
};
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
123
src/msteams/monitor-handler/inbound-media.ts
Normal file
123
src/msteams/monitor-handler/inbound-media.ts
Normal 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;
|
||||
}
|
||||
456
src/msteams/monitor-handler/message-handler.ts
Normal file
456
src/msteams/monitor-handler/message-handler.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user