image works in DM

This commit is contained in:
Onur
2026-01-08 13:58:19 +03:00
committed by Peter Steinberger
parent 15e6761035
commit 678d704341
6 changed files with 782 additions and 5 deletions

View File

@@ -3,6 +3,7 @@ import type { Command } from "commander";
import { loadConfig } from "../config/config.js";
import { sendMessageDiscord } from "../discord/send.js";
import { sendMessageIMessage } from "../imessage/send.js";
import { sendMessageMSTeams } from "../msteams/send.js";
import { PROVIDER_ID_LABELS } from "../pairing/pairing-labels.js";
import {
approveProviderPairingCode,
@@ -21,6 +22,7 @@ const PROVIDERS: PairingProvider[] = [
"discord",
"slack",
"whatsapp",
"msteams",
];
function parseProvider(raw: unknown): PairingProvider {
@@ -65,6 +67,11 @@ async function notifyApproved(provider: PairingProvider, id: string) {
await sendMessageIMessage(id, message);
return;
}
if (provider === "msteams") {
const cfg = loadConfig();
await sendMessageMSTeams({ cfg, to: id, text: message });
return;
}
// WhatsApp: approval still works (store); notifying requires an active web session.
}

View File

@@ -2,7 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildMSTeamsAttachmentPlaceholder,
buildMSTeamsGraphMessageUrls,
buildMSTeamsMediaPayload,
downloadMSTeamsGraphMedia,
downloadMSTeamsImageAttachments,
} from "./attachments.js";
@@ -70,6 +72,26 @@ describe("msteams attachments", () => {
]),
).toBe("<media:document> (2 files)");
});
it("counts inline images in text/html attachments", () => {
expect(
buildMSTeamsAttachmentPlaceholder([
{
contentType: "text/html",
content: '<p>hi</p><img src="https://x/a.png" />',
},
]),
).toBe("<media:image>");
expect(
buildMSTeamsAttachmentPlaceholder([
{
contentType: "text/html",
content:
'<img src="https://x/a.png" /><img src="https://x/b.png" />',
},
]),
).toBe("<media:image> (2 images)");
});
});
describe("downloadMSTeamsImageAttachments", () => {
@@ -118,6 +140,45 @@ describe("msteams attachments", () => {
expect(fetchMock).toHaveBeenCalledWith("https://x/dl");
});
it("downloads inline image URLs from html attachments", async () => {
const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("png"), {
status: 200,
headers: { "content-type": "image/png" },
});
});
const media = await downloadMSTeamsImageAttachments({
attachments: [
{
contentType: "text/html",
content: '<img src="https://x/inline.png" />',
},
],
maxBytes: 1024 * 1024,
fetchFn: fetchMock as unknown as typeof fetch,
});
expect(media).toHaveLength(1);
expect(fetchMock).toHaveBeenCalledWith("https://x/inline.png");
});
it("stores inline data:image base64 payloads", async () => {
const base64 = Buffer.from("png").toString("base64");
const media = await downloadMSTeamsImageAttachments({
attachments: [
{
contentType: "text/html",
content: `<img src="data:image/png;base64,${base64}" />`,
},
],
maxBytes: 1024 * 1024,
});
expect(media).toHaveLength(1);
expect(saveMediaBufferMock).toHaveBeenCalled();
});
it("retries with auth when the first request is unauthorized", async () => {
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
const hasAuth = Boolean(
@@ -163,6 +224,77 @@ describe("msteams attachments", () => {
});
});
describe("buildMSTeamsGraphMessageUrls", () => {
it("builds channel message urls", () => {
const urls = buildMSTeamsGraphMessageUrls({
conversationType: "channel",
conversationId: "19:thread@thread.tacv2",
messageId: "123",
channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } },
});
expect(urls[0]).toContain("/teams/team-id/channels/chan-id/messages/123");
});
it("builds channel reply urls when replyToId is present", () => {
const urls = buildMSTeamsGraphMessageUrls({
conversationType: "channel",
messageId: "reply-id",
replyToId: "root-id",
channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } },
});
expect(urls[0]).toContain(
"/teams/team-id/channels/chan-id/messages/root-id/replies/reply-id",
);
});
it("builds chat message urls", () => {
const urls = buildMSTeamsGraphMessageUrls({
conversationType: "groupChat",
conversationId: "19:chat@thread.v2",
messageId: "456",
});
expect(urls[0]).toContain("/chats/19%3Achat%40thread.v2/messages/456");
});
});
describe("downloadMSTeamsGraphMedia", () => {
it("downloads hostedContents images", async () => {
const base64 = Buffer.from("png").toString("base64");
const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith("/hostedContents")) {
return new Response(
JSON.stringify({
value: [
{
id: "1",
contentType: "image/png",
contentBytes: base64,
},
],
}),
{ status: 200 },
);
}
if (url.endsWith("/attachments")) {
return new Response(JSON.stringify({ value: [] }), { status: 200 });
}
return new Response("not found", { status: 404 });
});
const media = await downloadMSTeamsGraphMedia({
messageUrl:
"https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123",
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
maxBytes: 1024 * 1024,
fetchFn: fetchMock as unknown as typeof fetch,
});
expect(media.media).toHaveLength(1);
expect(fetchMock).toHaveBeenCalled();
expect(saveMediaBufferMock).toHaveBeenCalled();
});
});
describe("buildMSTeamsMediaPayload", () => {
it("returns single and multi-file fields", () => {
const payload = buildMSTeamsMediaPayload([

View File

@@ -26,8 +26,63 @@ export type MSTeamsInboundMedia = {
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;
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);
}
@@ -76,14 +131,387 @@ function isLikelyImageAttachment(att: MSTeamsAttachmentLike): boolean {
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";
}
}
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;
fetchFn?: typeof fetch;
}): Promise<MSTeamsGraphMediaResult> {
if (!params.messageUrl || !params.tokenProvider) {
return { media: [] };
}
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,
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;
if (imageCount > 0) {
return `<media:image>${imageCount > 1 ? ` (${imageCount} images)` : ""}`;
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)` : ""}`;
@@ -206,14 +634,48 @@ export async function downloadMSTeamsImageAttachments(params: {
const list = Array.isArray(params.attachments) ? params.attachments : [];
if (list.length === 0) return [];
const candidates = list
const candidates: DownloadCandidate[] = list
.filter(isLikelyImageAttachment)
.map(resolveDownloadCandidate)
.filter(Boolean) as DownloadCandidate[];
if (candidates.length === 0) return [];
const inlineCandidates = extractInlineImageCandidates(list);
const seenUrls = new Set<string>();
for (const inline of inlineCandidates) {
if (inline.kind === "url") {
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) {
try {
const res = await fetchWithAuthFallback({

View File

@@ -10,6 +10,15 @@ export function normalizeMSTeamsConversationId(raw: string): string {
return raw.split(";")[0] ?? raw;
}
export function extractMSTeamsConversationMessageId(
raw: string,
): string | undefined {
if (!raw) return undefined;
const match = /(?:^|;)messageid=([^;]+)/i.exec(raw);
const value = match?.[1]?.trim() ?? "";
return value || undefined;
}
export function parseMSTeamsActivityTimestamp(
value: unknown,
): Date | undefined {

View File

@@ -15,9 +15,12 @@ import { resolveAgentRoute } from "../routing/resolve-route.js";
import type { RuntimeEnv } from "../runtime.js";
import {
buildMSTeamsAttachmentPlaceholder,
buildMSTeamsGraphMessageUrls,
buildMSTeamsMediaPayload,
downloadMSTeamsGraphMedia,
downloadMSTeamsImageAttachments,
type MSTeamsAttachmentLike,
summarizeMSTeamsHtmlAttachments,
} from "./attachments.js";
import type {
MSTeamsConversationStore,
@@ -30,6 +33,7 @@ import {
formatUnknownError,
} from "./errors.js";
import {
extractMSTeamsConversationMessageId,
normalizeMSTeamsConversationId,
parseMSTeamsActivityTimestamp,
stripMSTeamsMentionTags,
@@ -139,6 +143,7 @@ export async function monitorMSTeamsProvider(
)
.filter(Boolean)
.slice(0, 3);
const htmlSummary = summarizeMSTeamsHtmlAttachments(attachments);
log.info("received message", {
rawText: rawText.slice(0, 50),
@@ -148,6 +153,9 @@ export async function monitorMSTeamsProvider(
from: from?.id,
conversation: conversation?.id,
});
if (htmlSummary) {
log.debug("html attachment summary", htmlSummary);
}
if (!rawBody) {
log.debug("skipping empty message after stripping mentions");
@@ -161,6 +169,8 @@ export async function monitorMSTeamsProvider(
// 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;
@@ -302,15 +312,81 @@ export async function monitorMSTeamsProvider(
// Format the message body with envelope
const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp);
const mediaList = await downloadMSTeamsImageAttachments({
let mediaList = await downloadMSTeamsImageAttachments({
attachments,
maxBytes: mediaMaxBytes,
tokenProvider: {
getAccessToken: (scope) => tokenProvider.getAccessToken(scope),
},
});
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,
});
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({