fix: msteams attachments + plugin prompt hints
Co-authored-by: Christof <10854026+Evizero@users.noreply.github.com>
This commit is contained in:
@@ -101,9 +101,9 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadMSTeamsImageAttachments", () => {
|
||||
describe("downloadMSTeamsAttachments", () => {
|
||||
it("downloads and stores image contentUrl attachments", async () => {
|
||||
const { downloadMSTeamsImageAttachments } = await load();
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response(Buffer.from("png"), {
|
||||
status: 200,
|
||||
@@ -111,7 +111,7 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const media = await downloadMSTeamsImageAttachments({
|
||||
const media = await downloadMSTeamsAttachments({
|
||||
attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
|
||||
maxBytes: 1024 * 1024,
|
||||
allowHosts: ["x"],
|
||||
@@ -125,7 +125,7 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("supports Teams file.download.info downloadUrl attachments", async () => {
|
||||
const { downloadMSTeamsImageAttachments } = await load();
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response(Buffer.from("png"), {
|
||||
status: 200,
|
||||
@@ -133,7 +133,7 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const media = await downloadMSTeamsImageAttachments({
|
||||
const media = await downloadMSTeamsAttachments({
|
||||
attachments: [
|
||||
{
|
||||
contentType: "application/vnd.microsoft.teams.file.download.info",
|
||||
@@ -149,8 +149,35 @@ describe("msteams attachments", () => {
|
||||
expect(media).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("downloads non-image file attachments (PDF)", async () => {
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response(Buffer.from("pdf"), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/pdf" },
|
||||
});
|
||||
});
|
||||
detectMimeMock.mockResolvedValueOnce("application/pdf");
|
||||
saveMediaBufferMock.mockResolvedValueOnce({
|
||||
path: "/tmp/saved.pdf",
|
||||
contentType: "application/pdf",
|
||||
});
|
||||
|
||||
const media = await downloadMSTeamsAttachments({
|
||||
attachments: [{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }],
|
||||
maxBytes: 1024 * 1024,
|
||||
allowHosts: ["x"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("https://x/doc.pdf");
|
||||
expect(media).toHaveLength(1);
|
||||
expect(media[0]?.path).toBe("/tmp/saved.pdf");
|
||||
expect(media[0]?.placeholder).toBe("<media:document>");
|
||||
});
|
||||
|
||||
it("downloads inline image URLs from html attachments", async () => {
|
||||
const { downloadMSTeamsImageAttachments } = await load();
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response(Buffer.from("png"), {
|
||||
status: 200,
|
||||
@@ -158,7 +185,7 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const media = await downloadMSTeamsImageAttachments({
|
||||
const media = await downloadMSTeamsAttachments({
|
||||
attachments: [
|
||||
{
|
||||
contentType: "text/html",
|
||||
@@ -175,9 +202,9 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("stores inline data:image base64 payloads", async () => {
|
||||
const { downloadMSTeamsImageAttachments } = await load();
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const base64 = Buffer.from("png").toString("base64");
|
||||
const media = await downloadMSTeamsImageAttachments({
|
||||
const media = await downloadMSTeamsAttachments({
|
||||
attachments: [
|
||||
{
|
||||
contentType: "text/html",
|
||||
@@ -193,7 +220,7 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("retries with auth when the first request is unauthorized", async () => {
|
||||
const { downloadMSTeamsImageAttachments } = await load();
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
|
||||
const hasAuth = Boolean(
|
||||
opts &&
|
||||
@@ -210,7 +237,7 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const media = await downloadMSTeamsImageAttachments({
|
||||
const media = await downloadMSTeamsAttachments({
|
||||
attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
|
||||
maxBytes: 1024 * 1024,
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
||||
@@ -224,9 +251,9 @@ describe("msteams attachments", () => {
|
||||
});
|
||||
|
||||
it("skips urls outside the allowlist", async () => {
|
||||
const { downloadMSTeamsImageAttachments } = await load();
|
||||
const { downloadMSTeamsAttachments } = await load();
|
||||
const fetchMock = vi.fn();
|
||||
const media = await downloadMSTeamsImageAttachments({
|
||||
const media = await downloadMSTeamsAttachments({
|
||||
attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }],
|
||||
maxBytes: 1024 * 1024,
|
||||
allowHosts: ["graph.microsoft.com"],
|
||||
@@ -236,20 +263,6 @@ describe("msteams attachments", () => {
|
||||
expect(media).toHaveLength(0);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores non-image attachments", async () => {
|
||||
const { downloadMSTeamsImageAttachments } = await load();
|
||||
const fetchMock = vi.fn();
|
||||
const media = await downloadMSTeamsImageAttachments({
|
||||
attachments: [{ contentType: "application/pdf", contentUrl: "https://x/x.pdf" }],
|
||||
maxBytes: 1024 * 1024,
|
||||
allowHosts: ["x"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(media).toHaveLength(0);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMSTeamsGraphMessageUrls", () => {
|
||||
@@ -324,6 +337,74 @@ describe("msteams attachments", () => {
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
expect(saveMediaBufferMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("merges SharePoint reference attachments with hosted content", async () => {
|
||||
const { downloadMSTeamsGraphMedia } = await load();
|
||||
const hostedBase64 = Buffer.from("png").toString("base64");
|
||||
const shareUrl = "https://contoso.sharepoint.com/site/file";
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url.endsWith("/hostedContents")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
value: [
|
||||
{
|
||||
id: "hosted-1",
|
||||
contentType: "image/png",
|
||||
contentBytes: hostedBase64,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url.endsWith("/attachments")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
value: [
|
||||
{
|
||||
id: "ref-1",
|
||||
contentType: "reference",
|
||||
contentUrl: shareUrl,
|
||||
name: "report.pdf",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url.startsWith("https://graph.microsoft.com/v1.0/shares/")) {
|
||||
return new Response(Buffer.from("pdf"), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/pdf" },
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/messages/123")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
attachments: [
|
||||
{
|
||||
id: "ref-1",
|
||||
contentType: "reference",
|
||||
contentUrl: shareUrl,
|
||||
name: "report.pdf",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ 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(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMSTeamsMediaPayload", () => {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export { downloadMSTeamsImageAttachments } from "./attachments/download.js";
|
||||
export {
|
||||
downloadMSTeamsAttachments,
|
||||
/** @deprecated Use `downloadMSTeamsAttachments` instead. */
|
||||
downloadMSTeamsImageAttachments,
|
||||
} from "./attachments/download.js";
|
||||
export { buildMSTeamsGraphMessageUrls, downloadMSTeamsGraphMedia } from "./attachments/graph.js";
|
||||
export {
|
||||
buildMSTeamsAttachmentPlaceholder,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import {
|
||||
extractInlineImageCandidates,
|
||||
inferPlaceholder,
|
||||
isLikelyImageAttachment,
|
||||
isDownloadableAttachment,
|
||||
isRecord,
|
||||
isUrlAllowed,
|
||||
normalizeContentType,
|
||||
@@ -102,23 +102,31 @@ async function fetchWithAuthFallback(params: {
|
||||
return firstAttempt;
|
||||
}
|
||||
|
||||
export async function downloadMSTeamsImageAttachments(params: {
|
||||
/**
|
||||
* Download all file attachments from a Teams message (images, documents, etc.).
|
||||
* Renamed from downloadMSTeamsImageAttachments to support all file types.
|
||||
*/
|
||||
export async function downloadMSTeamsAttachments(params: {
|
||||
attachments: MSTeamsAttachmentLike[] | undefined;
|
||||
maxBytes: number;
|
||||
tokenProvider?: MSTeamsAccessTokenProvider;
|
||||
allowHosts?: string[];
|
||||
fetchFn?: typeof fetch;
|
||||
/** When true, embeds original filename in stored path for later extraction. */
|
||||
preserveFilenames?: boolean;
|
||||
}): 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)
|
||||
// Download ANY downloadable attachment (not just images)
|
||||
const downloadable = list.filter(isDownloadableAttachment);
|
||||
const candidates: DownloadCandidate[] = downloadable
|
||||
.map(resolveDownloadCandidate)
|
||||
.filter(Boolean) as DownloadCandidate[];
|
||||
|
||||
const inlineCandidates = extractInlineImageCandidates(list);
|
||||
|
||||
const seenUrls = new Set<string>();
|
||||
for (const inline of inlineCandidates) {
|
||||
if (inline.kind === "url") {
|
||||
@@ -133,7 +141,6 @@ export async function downloadMSTeamsImageAttachments(params: {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0 && inlineCandidates.length === 0) return [];
|
||||
|
||||
const out: MSTeamsInboundMedia[] = [];
|
||||
@@ -141,6 +148,7 @@ export async function downloadMSTeamsImageAttachments(params: {
|
||||
if (inline.kind !== "data") continue;
|
||||
if (inline.data.byteLength > params.maxBytes) continue;
|
||||
try {
|
||||
// Data inline candidates (base64 data URLs) don't have original filenames
|
||||
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||
inline.data,
|
||||
inline.contentType,
|
||||
@@ -172,11 +180,13 @@ export async function downloadMSTeamsImageAttachments(params: {
|
||||
headerMime: res.headers.get("content-type"),
|
||||
filePath: candidate.fileHint ?? candidate.url,
|
||||
});
|
||||
const originalFilename = params.preserveFilenames ? candidate.fileHint : undefined;
|
||||
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||
buffer,
|
||||
mime ?? candidate.contentTypeHint,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
originalFilename,
|
||||
);
|
||||
out.push({
|
||||
path: saved.path,
|
||||
@@ -184,8 +194,13 @@ export async function downloadMSTeamsImageAttachments(params: {
|
||||
placeholder: candidate.placeholder,
|
||||
});
|
||||
} catch {
|
||||
// Ignore download failures and continue.
|
||||
// Ignore download failures and continue with next candidate.
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `downloadMSTeamsAttachments` instead (supports all file types).
|
||||
*/
|
||||
export const downloadMSTeamsImageAttachments = downloadMSTeamsAttachments;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import { downloadMSTeamsImageAttachments } from "./download.js";
|
||||
import { GRAPH_ROOT, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
|
||||
import { downloadMSTeamsAttachments } from "./download.js";
|
||||
import { GRAPH_ROOT, inferPlaceholder, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
|
||||
import type {
|
||||
MSTeamsAccessTokenProvider,
|
||||
MSTeamsAttachmentLike,
|
||||
@@ -128,11 +128,16 @@ function normalizeGraphAttachment(att: GraphAttachment): MSTeamsAttachmentLike {
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadGraphHostedImages(params: {
|
||||
/**
|
||||
* Download all hosted content from a Teams message (images, documents, etc.).
|
||||
* Renamed from downloadGraphHostedImages to support all file types.
|
||||
*/
|
||||
async function downloadGraphHostedContent(params: {
|
||||
accessToken: string;
|
||||
messageUrl: string;
|
||||
maxBytes: number;
|
||||
fetchFn?: typeof fetch;
|
||||
preserveFilenames?: boolean;
|
||||
}): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> {
|
||||
const hosted = await fetchGraphCollection<GraphHostedContent>({
|
||||
url: `${params.messageUrl}/hostedContents`,
|
||||
@@ -158,7 +163,7 @@ async function downloadGraphHostedImages(params: {
|
||||
buffer,
|
||||
headerMime: item.contentType ?? undefined,
|
||||
});
|
||||
if (mime && !mime.startsWith("image/")) continue;
|
||||
// Download any file type, not just images
|
||||
try {
|
||||
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||
buffer,
|
||||
@@ -169,7 +174,7 @@ async function downloadGraphHostedImages(params: {
|
||||
out.push({
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: "<media:image>",
|
||||
placeholder: inferPlaceholder({ contentType: saved.contentType }),
|
||||
});
|
||||
} catch {
|
||||
// Ignore save failures.
|
||||
@@ -185,6 +190,8 @@ export async function downloadMSTeamsGraphMedia(params: {
|
||||
maxBytes: number;
|
||||
allowHosts?: string[];
|
||||
fetchFn?: typeof fetch;
|
||||
/** When true, embeds original filename in stored path for later extraction. */
|
||||
preserveFilenames?: boolean;
|
||||
}): Promise<MSTeamsGraphMediaResult> {
|
||||
if (!params.messageUrl || !params.tokenProvider) return { media: [] };
|
||||
const allowHosts = resolveAllowedHosts(params.allowHosts);
|
||||
@@ -196,11 +203,83 @@ export async function downloadMSTeamsGraphMedia(params: {
|
||||
return { media: [], messageUrl, tokenError: true };
|
||||
}
|
||||
|
||||
const hosted = await downloadGraphHostedImages({
|
||||
// Fetch the full message to get SharePoint file attachments (for group chats)
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const sharePointMedia: MSTeamsInboundMedia[] = [];
|
||||
const downloadedReferenceUrls = new Set<string>();
|
||||
try {
|
||||
const msgRes = await fetchFn(messageUrl, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (msgRes.ok) {
|
||||
const msgData = (await msgRes.json()) as {
|
||||
body?: { content?: string; contentType?: string };
|
||||
attachments?: Array<{
|
||||
id?: string;
|
||||
contentUrl?: string;
|
||||
contentType?: string;
|
||||
name?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
// Extract SharePoint file attachments (contentType: "reference")
|
||||
// Download any file type, not just images
|
||||
const spAttachments = (msgData.attachments ?? []).filter(
|
||||
(a) => a.contentType === "reference" && a.contentUrl && a.name,
|
||||
);
|
||||
for (const att of spAttachments) {
|
||||
const name = att.name ?? "file";
|
||||
|
||||
try {
|
||||
// SharePoint URLs need to be accessed via Graph shares API
|
||||
const shareUrl = att.contentUrl!;
|
||||
const encodedUrl = Buffer.from(shareUrl).toString("base64url");
|
||||
const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`;
|
||||
|
||||
const spRes = await fetchFn(sharesUrl, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
if (spRes.ok) {
|
||||
const buffer = Buffer.from(await spRes.arrayBuffer());
|
||||
if (buffer.byteLength <= params.maxBytes) {
|
||||
const mime = await getMSTeamsRuntime().media.detectMime({
|
||||
buffer,
|
||||
headerMime: spRes.headers.get("content-type") ?? undefined,
|
||||
filePath: name,
|
||||
});
|
||||
const originalFilename = params.preserveFilenames ? name : undefined;
|
||||
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||
buffer,
|
||||
mime ?? "application/octet-stream",
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
originalFilename,
|
||||
);
|
||||
sharePointMedia.push({
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: inferPlaceholder({ contentType: saved.contentType, fileName: name }),
|
||||
});
|
||||
downloadedReferenceUrls.add(shareUrl);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore SharePoint download failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore message fetch failures.
|
||||
}
|
||||
|
||||
const hosted = await downloadGraphHostedContent({
|
||||
accessToken,
|
||||
messageUrl,
|
||||
maxBytes: params.maxBytes,
|
||||
fetchFn: params.fetchFn,
|
||||
preserveFilenames: params.preserveFilenames,
|
||||
});
|
||||
|
||||
const attachments = await fetchGraphCollection<GraphAttachment>({
|
||||
@@ -210,18 +289,29 @@ export async function downloadMSTeamsGraphMedia(params: {
|
||||
});
|
||||
|
||||
const normalizedAttachments = attachments.items.map(normalizeGraphAttachment);
|
||||
const attachmentMedia = await downloadMSTeamsImageAttachments({
|
||||
attachments: normalizedAttachments,
|
||||
const filteredAttachments =
|
||||
sharePointMedia.length > 0
|
||||
? normalizedAttachments.filter((att) => {
|
||||
const contentType = att.contentType?.toLowerCase();
|
||||
if (contentType !== "reference") return true;
|
||||
const url = typeof att.contentUrl === "string" ? att.contentUrl : "";
|
||||
if (!url) return true;
|
||||
return !downloadedReferenceUrls.has(url);
|
||||
})
|
||||
: normalizedAttachments;
|
||||
const attachmentMedia = await downloadMSTeamsAttachments({
|
||||
attachments: filteredAttachments,
|
||||
maxBytes: params.maxBytes,
|
||||
tokenProvider: params.tokenProvider,
|
||||
allowHosts,
|
||||
fetchFn: params.fetchFn,
|
||||
preserveFilenames: params.preserveFilenames,
|
||||
});
|
||||
|
||||
return {
|
||||
media: [...hosted.media, ...attachmentMedia],
|
||||
media: [...sharePointMedia, ...hosted.media, ...attachmentMedia],
|
||||
hostedCount: hosted.count,
|
||||
attachmentCount: attachments.items.length,
|
||||
attachmentCount: filteredAttachments.length + sharePointMedia.length,
|
||||
hostedStatus: hosted.status,
|
||||
attachmentStatus: attachments.status,
|
||||
messageUrl,
|
||||
|
||||
@@ -37,6 +37,15 @@ export const DEFAULT_MEDIA_HOST_ALLOWLIST = [
|
||||
"statics.teams.cdn.office.net",
|
||||
"office.com",
|
||||
"office.net",
|
||||
// Azure Media Services / Skype CDN for clipboard-pasted images
|
||||
"asm.skype.com",
|
||||
"ams.skype.com",
|
||||
"media.ams.skype.com",
|
||||
// Bot Framework attachment URLs
|
||||
"trafficmanager.net",
|
||||
"blob.core.windows.net",
|
||||
"azureedge.net",
|
||||
"microsoft.com",
|
||||
] as const;
|
||||
|
||||
export const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
|
||||
@@ -85,6 +94,30 @@ export function isLikelyImageAttachment(att: MSTeamsAttachmentLike): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the attachment can be downloaded (any file type).
|
||||
* Used when downloading all files, not just images.
|
||||
*/
|
||||
export function isDownloadableAttachment(att: MSTeamsAttachmentLike): boolean {
|
||||
const contentType = normalizeContentType(att.contentType) ?? "";
|
||||
|
||||
// Teams file download info always has a downloadUrl
|
||||
if (
|
||||
contentType === "application/vnd.microsoft.teams.file.download.info" &&
|
||||
isRecord(att.content) &&
|
||||
typeof att.content.downloadUrl === "string"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Any attachment with a contentUrl can be downloaded
|
||||
if (typeof att.contentUrl === "string" && att.contentUrl.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isHtmlAttachment(att: MSTeamsAttachmentLike): boolean {
|
||||
const contentType = normalizeContentType(att.contentType) ?? "";
|
||||
return contentType.startsWith("text/html");
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
resolveMSTeamsChannelAllowlist,
|
||||
resolveMSTeamsUserAllowlist,
|
||||
} from "./resolve-allowlist.js";
|
||||
import { sendMessageMSTeams } from "./send.js";
|
||||
import { sendAdaptiveCardMSTeams, sendMessageMSTeams } from "./send.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
import {
|
||||
listMSTeamsDirectoryGroupsLive,
|
||||
@@ -64,6 +64,19 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
threads: true,
|
||||
media: true,
|
||||
},
|
||||
agentPrompt: {
|
||||
messageToolHints: () => [
|
||||
"- Adaptive Cards supported. Use `action=send` with `card={type,version,body}` to send rich cards.",
|
||||
"- MSTeams targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:ID` or `user:Display Name` (requires Graph API) for DMs, `conversation:19:...@thread.tacv2` for groups/channels. Prefer IDs over display names for speed.",
|
||||
],
|
||||
},
|
||||
threading: {
|
||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
||||
currentChannelId: context.To?.trim() || undefined,
|
||||
currentThreadTs: context.ReplyToId,
|
||||
hasRepliedRef,
|
||||
}),
|
||||
},
|
||||
reload: { configPrefixes: ["channels.msteams"] },
|
||||
configSchema: buildChannelConfigSchema(MSTeamsConfigSchema),
|
||||
config: {
|
||||
@@ -137,7 +150,12 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
looksLikeId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^(conversation:|user:)/i.test(trimmed)) return true;
|
||||
if (/^conversation:/i.test(trimmed)) return true;
|
||||
if (/^user:/i.test(trimmed)) {
|
||||
// Only treat as ID if the value after user: looks like a UUID
|
||||
const id = trimmed.slice("user:".length).trim();
|
||||
return /^[0-9a-fA-F-]{16,}$/.test(id);
|
||||
}
|
||||
return trimmed.includes("@thread");
|
||||
},
|
||||
hint: "<conversationId|user:ID|conversation:ID>",
|
||||
@@ -320,6 +338,50 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
if (!enabled) return [];
|
||||
return ["poll"] satisfies ChannelMessageActionName[];
|
||||
},
|
||||
supportsCards: ({ cfg }) => {
|
||||
return (
|
||||
cfg.channels?.msteams?.enabled !== false &&
|
||||
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams))
|
||||
);
|
||||
},
|
||||
handleAction: async (ctx) => {
|
||||
// Handle send action with card parameter
|
||||
if (ctx.action === "send" && ctx.params.card) {
|
||||
const card = ctx.params.card as Record<string, unknown>;
|
||||
const to =
|
||||
typeof ctx.params.to === "string"
|
||||
? ctx.params.to.trim()
|
||||
: typeof ctx.params.target === "string"
|
||||
? ctx.params.target.trim()
|
||||
: "";
|
||||
if (!to) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "Card send requires a target (to)." }],
|
||||
};
|
||||
}
|
||||
const result = await sendAdaptiveCardMSTeams({
|
||||
cfg: ctx.cfg,
|
||||
to,
|
||||
card,
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
ok: true,
|
||||
channel: "msteams",
|
||||
messageId: result.messageId,
|
||||
conversationId: result.conversationId,
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
// Return null to fall through to default handler
|
||||
return null as never;
|
||||
},
|
||||
},
|
||||
outbound: msteamsOutbound,
|
||||
status: {
|
||||
|
||||
234
extensions/msteams/src/file-consent-helpers.test.ts
Normal file
234
extensions/msteams/src/file-consent-helpers.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.js";
|
||||
import * as pendingUploads from "./pending-uploads.js";
|
||||
|
||||
describe("requiresFileConsent", () => {
|
||||
const thresholdBytes = 4 * 1024 * 1024; // 4MB
|
||||
|
||||
it("returns true for personal chat with non-image", () => {
|
||||
expect(
|
||||
requiresFileConsent({
|
||||
conversationType: "personal",
|
||||
contentType: "application/pdf",
|
||||
bufferSize: 1000,
|
||||
thresholdBytes,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for personal chat with large image", () => {
|
||||
expect(
|
||||
requiresFileConsent({
|
||||
conversationType: "personal",
|
||||
contentType: "image/png",
|
||||
bufferSize: 5 * 1024 * 1024, // 5MB
|
||||
thresholdBytes,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for personal chat with small image", () => {
|
||||
expect(
|
||||
requiresFileConsent({
|
||||
conversationType: "personal",
|
||||
contentType: "image/png",
|
||||
bufferSize: 1000,
|
||||
thresholdBytes,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for group chat with large non-image", () => {
|
||||
expect(
|
||||
requiresFileConsent({
|
||||
conversationType: "groupChat",
|
||||
contentType: "application/pdf",
|
||||
bufferSize: 5 * 1024 * 1024,
|
||||
thresholdBytes,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for channel with large non-image", () => {
|
||||
expect(
|
||||
requiresFileConsent({
|
||||
conversationType: "channel",
|
||||
contentType: "application/pdf",
|
||||
bufferSize: 5 * 1024 * 1024,
|
||||
thresholdBytes,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("handles case-insensitive conversation type", () => {
|
||||
expect(
|
||||
requiresFileConsent({
|
||||
conversationType: "Personal",
|
||||
contentType: "application/pdf",
|
||||
bufferSize: 1000,
|
||||
thresholdBytes,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
requiresFileConsent({
|
||||
conversationType: "PERSONAL",
|
||||
contentType: "application/pdf",
|
||||
bufferSize: 1000,
|
||||
thresholdBytes,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when conversationType is undefined", () => {
|
||||
expect(
|
||||
requiresFileConsent({
|
||||
conversationType: undefined,
|
||||
contentType: "application/pdf",
|
||||
bufferSize: 1000,
|
||||
thresholdBytes,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for personal chat when contentType is undefined (non-image)", () => {
|
||||
expect(
|
||||
requiresFileConsent({
|
||||
conversationType: "personal",
|
||||
contentType: undefined,
|
||||
bufferSize: 1000,
|
||||
thresholdBytes,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for personal chat with file exactly at threshold", () => {
|
||||
expect(
|
||||
requiresFileConsent({
|
||||
conversationType: "personal",
|
||||
contentType: "image/jpeg",
|
||||
bufferSize: thresholdBytes, // exactly 4MB
|
||||
thresholdBytes,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for personal chat with file just below threshold", () => {
|
||||
expect(
|
||||
requiresFileConsent({
|
||||
conversationType: "personal",
|
||||
contentType: "image/jpeg",
|
||||
bufferSize: thresholdBytes - 1, // 4MB - 1 byte
|
||||
thresholdBytes,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareFileConsentActivity", () => {
|
||||
const mockUploadId = "test-upload-id-123";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(pendingUploads, "storePendingUpload").mockReturnValue(mockUploadId);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("creates activity with consent card attachment", () => {
|
||||
const result = prepareFileConsentActivity({
|
||||
media: {
|
||||
buffer: Buffer.from("test content"),
|
||||
filename: "test.pdf",
|
||||
contentType: "application/pdf",
|
||||
},
|
||||
conversationId: "conv123",
|
||||
description: "My file",
|
||||
});
|
||||
|
||||
expect(result.uploadId).toBe(mockUploadId);
|
||||
expect(result.activity.type).toBe("message");
|
||||
expect(result.activity.attachments).toHaveLength(1);
|
||||
|
||||
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, unknown>;
|
||||
expect(attachment.contentType).toBe("application/vnd.microsoft.teams.card.file.consent");
|
||||
expect(attachment.name).toBe("test.pdf");
|
||||
});
|
||||
|
||||
it("stores pending upload with correct data", () => {
|
||||
const buffer = Buffer.from("test content");
|
||||
prepareFileConsentActivity({
|
||||
media: {
|
||||
buffer,
|
||||
filename: "test.pdf",
|
||||
contentType: "application/pdf",
|
||||
},
|
||||
conversationId: "conv123",
|
||||
description: "My file",
|
||||
});
|
||||
|
||||
expect(pendingUploads.storePendingUpload).toHaveBeenCalledWith({
|
||||
buffer,
|
||||
filename: "test.pdf",
|
||||
contentType: "application/pdf",
|
||||
conversationId: "conv123",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses default description when not provided", () => {
|
||||
const result = prepareFileConsentActivity({
|
||||
media: {
|
||||
buffer: Buffer.from("test"),
|
||||
filename: "document.docx",
|
||||
contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
},
|
||||
conversationId: "conv456",
|
||||
});
|
||||
|
||||
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, { description: string }>;
|
||||
expect(attachment.content.description).toBe("File: document.docx");
|
||||
});
|
||||
|
||||
it("uses provided description", () => {
|
||||
const result = prepareFileConsentActivity({
|
||||
media: {
|
||||
buffer: Buffer.from("test"),
|
||||
filename: "report.pdf",
|
||||
contentType: "application/pdf",
|
||||
},
|
||||
conversationId: "conv789",
|
||||
description: "Q4 Financial Report",
|
||||
});
|
||||
|
||||
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, { description: string }>;
|
||||
expect(attachment.content.description).toBe("Q4 Financial Report");
|
||||
});
|
||||
|
||||
it("includes uploadId in consent card context", () => {
|
||||
const result = prepareFileConsentActivity({
|
||||
media: {
|
||||
buffer: Buffer.from("test"),
|
||||
filename: "file.txt",
|
||||
contentType: "text/plain",
|
||||
},
|
||||
conversationId: "conv000",
|
||||
});
|
||||
|
||||
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, { acceptContext: { uploadId: string } }>;
|
||||
expect(attachment.content.acceptContext.uploadId).toBe(mockUploadId);
|
||||
});
|
||||
|
||||
it("handles media without contentType", () => {
|
||||
const result = prepareFileConsentActivity({
|
||||
media: {
|
||||
buffer: Buffer.from("binary data"),
|
||||
filename: "unknown.bin",
|
||||
},
|
||||
conversationId: "conv111",
|
||||
});
|
||||
|
||||
expect(result.uploadId).toBe(mockUploadId);
|
||||
expect(result.activity.type).toBe("message");
|
||||
});
|
||||
});
|
||||
73
extensions/msteams/src/file-consent-helpers.ts
Normal file
73
extensions/msteams/src/file-consent-helpers.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Shared helpers for FileConsentCard flow in MSTeams.
|
||||
*
|
||||
* FileConsentCard is required for:
|
||||
* - Personal (1:1) chats with large files (>=4MB)
|
||||
* - Personal chats with non-image files (PDFs, documents, etc.)
|
||||
*
|
||||
* This module consolidates the logic used by both send.ts (proactive sends)
|
||||
* and messenger.ts (reply path) to avoid duplication.
|
||||
*/
|
||||
|
||||
import { buildFileConsentCard } from "./file-consent.js";
|
||||
import { storePendingUpload } from "./pending-uploads.js";
|
||||
|
||||
export type FileConsentMedia = {
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export type FileConsentActivityResult = {
|
||||
activity: Record<string, unknown>;
|
||||
uploadId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prepare a FileConsentCard activity for large files or non-images in personal chats.
|
||||
* Returns the activity object and uploadId - caller is responsible for sending.
|
||||
*/
|
||||
export function prepareFileConsentActivity(params: {
|
||||
media: FileConsentMedia;
|
||||
conversationId: string;
|
||||
description?: string;
|
||||
}): FileConsentActivityResult {
|
||||
const { media, conversationId, description } = params;
|
||||
|
||||
const uploadId = storePendingUpload({
|
||||
buffer: media.buffer,
|
||||
filename: media.filename,
|
||||
contentType: media.contentType,
|
||||
conversationId,
|
||||
});
|
||||
|
||||
const consentCard = buildFileConsentCard({
|
||||
filename: media.filename,
|
||||
description: description || `File: ${media.filename}`,
|
||||
sizeInBytes: media.buffer.length,
|
||||
context: { uploadId },
|
||||
});
|
||||
|
||||
const activity: Record<string, unknown> = {
|
||||
type: "message",
|
||||
attachments: [consentCard],
|
||||
};
|
||||
|
||||
return { activity, uploadId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file requires FileConsentCard flow.
|
||||
* True for: personal chat AND (large file OR non-image)
|
||||
*/
|
||||
export function requiresFileConsent(params: {
|
||||
conversationType: string | undefined;
|
||||
contentType: string | undefined;
|
||||
bufferSize: number;
|
||||
thresholdBytes: number;
|
||||
}): boolean {
|
||||
const isPersonal = params.conversationType?.toLowerCase() === "personal";
|
||||
const isImage = params.contentType?.startsWith("image/") ?? false;
|
||||
const isLargeFile = params.bufferSize >= params.thresholdBytes;
|
||||
return isPersonal && (isLargeFile || !isImage);
|
||||
}
|
||||
122
extensions/msteams/src/file-consent.ts
Normal file
122
extensions/msteams/src/file-consent.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* FileConsentCard utilities for MS Teams large file uploads (>4MB) in personal chats.
|
||||
*
|
||||
* Teams requires user consent before the bot can upload large files. This module provides
|
||||
* utilities for:
|
||||
* - Building FileConsentCard attachments (to request upload permission)
|
||||
* - Building FileInfoCard attachments (to confirm upload completion)
|
||||
* - Parsing fileConsent/invoke activities
|
||||
*/
|
||||
|
||||
export interface FileConsentCardParams {
|
||||
filename: string;
|
||||
description?: string;
|
||||
sizeInBytes: number;
|
||||
/** Custom context data to include in the card (passed back in the invoke) */
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface FileInfoCardParams {
|
||||
filename: string;
|
||||
contentUrl: string;
|
||||
uniqueId: string;
|
||||
fileType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a FileConsentCard attachment for requesting upload permission.
|
||||
* Use this for files >= 4MB in personal (1:1) chats.
|
||||
*/
|
||||
export function buildFileConsentCard(params: FileConsentCardParams) {
|
||||
return {
|
||||
contentType: "application/vnd.microsoft.teams.card.file.consent",
|
||||
name: params.filename,
|
||||
content: {
|
||||
description: params.description ?? `File: ${params.filename}`,
|
||||
sizeInBytes: params.sizeInBytes,
|
||||
acceptContext: { filename: params.filename, ...params.context },
|
||||
declineContext: { filename: params.filename, ...params.context },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a FileInfoCard attachment for confirming upload completion.
|
||||
* Send this after successfully uploading the file to the consent URL.
|
||||
*/
|
||||
export function buildFileInfoCard(params: FileInfoCardParams) {
|
||||
return {
|
||||
contentType: "application/vnd.microsoft.teams.card.file.info",
|
||||
contentUrl: params.contentUrl,
|
||||
name: params.filename,
|
||||
content: {
|
||||
uniqueId: params.uniqueId,
|
||||
fileType: params.fileType,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface FileConsentUploadInfo {
|
||||
name: string;
|
||||
uploadUrl: string;
|
||||
contentUrl: string;
|
||||
uniqueId: string;
|
||||
fileType: string;
|
||||
}
|
||||
|
||||
export interface FileConsentResponse {
|
||||
action: "accept" | "decline";
|
||||
uploadInfo?: FileConsentUploadInfo;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a fileConsent/invoke activity.
|
||||
* Returns null if the activity is not a file consent invoke.
|
||||
*/
|
||||
export function parseFileConsentInvoke(activity: {
|
||||
name?: string;
|
||||
value?: unknown;
|
||||
}): FileConsentResponse | null {
|
||||
if (activity.name !== "fileConsent/invoke") return null;
|
||||
|
||||
const value = activity.value as {
|
||||
type?: string;
|
||||
action?: string;
|
||||
uploadInfo?: FileConsentUploadInfo;
|
||||
context?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
if (value?.type !== "fileUpload") return null;
|
||||
|
||||
return {
|
||||
action: value.action === "accept" ? "accept" : "decline",
|
||||
uploadInfo: value.uploadInfo,
|
||||
context: value.context,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to the consent URL provided by Teams.
|
||||
* The URL is provided in the fileConsent/invoke response after user accepts.
|
||||
*/
|
||||
export async function uploadToConsentUrl(params: {
|
||||
url: string;
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<void> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const res = await fetchFn(params.url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": params.contentType ?? "application/octet-stream",
|
||||
"Content-Range": `bytes 0-${params.buffer.length - 1}/${params.buffer.length}`,
|
||||
},
|
||||
body: new Uint8Array(params.buffer),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`File upload to consent URL failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
52
extensions/msteams/src/graph-chat.ts
Normal file
52
extensions/msteams/src/graph-chat.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Native Teams file card attachments for Bot Framework.
|
||||
*
|
||||
* The Bot Framework SDK supports `application/vnd.microsoft.teams.card.file.info`
|
||||
* content type which produces native Teams file cards.
|
||||
*
|
||||
* @see https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4
|
||||
*/
|
||||
|
||||
import type { DriveItemProperties } from "./graph-upload.js";
|
||||
|
||||
/**
|
||||
* Build a native Teams file card attachment for Bot Framework.
|
||||
*
|
||||
* This uses the `application/vnd.microsoft.teams.card.file.info` content type
|
||||
* which is supported by Bot Framework and produces native Teams file cards
|
||||
* (the same display as when a user manually shares a file).
|
||||
*
|
||||
* @param file - DriveItem properties from getDriveItemProperties()
|
||||
* @returns Attachment object for Bot Framework sendActivity()
|
||||
*/
|
||||
export function buildTeamsFileInfoCard(file: DriveItemProperties): {
|
||||
contentType: string;
|
||||
contentUrl: string;
|
||||
name: string;
|
||||
content: {
|
||||
uniqueId: string;
|
||||
fileType: string;
|
||||
};
|
||||
} {
|
||||
// Extract unique ID from eTag (remove quotes, braces, and version suffix)
|
||||
// Example eTag formats: "{GUID},version" or "\"{GUID},version\""
|
||||
const rawETag = file.eTag;
|
||||
const uniqueId = rawETag
|
||||
.replace(/^["']|["']$/g, "") // Remove outer quotes
|
||||
.replace(/[{}]/g, "") // Remove curly braces
|
||||
.split(",")[0] ?? rawETag; // Take the GUID part before comma
|
||||
|
||||
// Extract file extension from filename
|
||||
const lastDot = file.name.lastIndexOf(".");
|
||||
const fileType = lastDot >= 0 ? file.name.slice(lastDot + 1).toLowerCase() : "";
|
||||
|
||||
return {
|
||||
contentType: "application/vnd.microsoft.teams.card.file.info",
|
||||
contentUrl: file.webDavUrl,
|
||||
name: file.name,
|
||||
content: {
|
||||
uniqueId,
|
||||
fileType,
|
||||
},
|
||||
};
|
||||
}
|
||||
445
extensions/msteams/src/graph-upload.ts
Normal file
445
extensions/msteams/src/graph-upload.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* OneDrive/SharePoint upload utilities for MS Teams file sending.
|
||||
*
|
||||
* For group chats and channels, files are uploaded to SharePoint and shared via a link.
|
||||
* This module provides utilities for:
|
||||
* - Uploading files to OneDrive (personal scope - now deprecated for bot use)
|
||||
* - Uploading files to SharePoint (group/channel scope)
|
||||
* - Creating sharing links (organization-wide or per-user)
|
||||
* - Getting chat members for per-user sharing
|
||||
*/
|
||||
|
||||
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
|
||||
|
||||
const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
|
||||
const GRAPH_BETA = "https://graph.microsoft.com/beta";
|
||||
const GRAPH_SCOPE = "https://graph.microsoft.com/.default";
|
||||
|
||||
export interface OneDriveUploadResult {
|
||||
id: string;
|
||||
webUrl: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to the user's OneDrive root folder.
|
||||
* For larger files, this uses the simple upload endpoint (up to 4MB).
|
||||
* TODO: For files >4MB, implement resumable upload session.
|
||||
*/
|
||||
export async function uploadToOneDrive(params: {
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
contentType?: string;
|
||||
tokenProvider: MSTeamsAccessTokenProvider;
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<OneDriveUploadResult> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
||||
|
||||
// Use "ClawdbotShared" folder to organize bot-uploaded files
|
||||
const uploadPath = `/ClawdbotShared/${encodeURIComponent(params.filename)}`;
|
||||
|
||||
const res = await fetchFn(`${GRAPH_ROOT}/me/drive/root:${uploadPath}:/content`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": params.contentType ?? "application/octet-stream",
|
||||
},
|
||||
body: new Uint8Array(params.buffer),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`OneDrive upload failed: ${res.status} ${res.statusText} - ${body}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
id?: string;
|
||||
webUrl?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
if (!data.id || !data.webUrl || !data.name) {
|
||||
throw new Error("OneDrive upload response missing required fields");
|
||||
}
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
webUrl: data.webUrl,
|
||||
name: data.name,
|
||||
};
|
||||
}
|
||||
|
||||
export interface OneDriveSharingLink {
|
||||
webUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a sharing link for a OneDrive file.
|
||||
* The link allows organization members to view the file.
|
||||
*/
|
||||
export async function createSharingLink(params: {
|
||||
itemId: string;
|
||||
tokenProvider: MSTeamsAccessTokenProvider;
|
||||
/** Sharing scope: "organization" (default) or "anonymous" */
|
||||
scope?: "organization" | "anonymous";
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<OneDriveSharingLink> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
||||
|
||||
const res = await fetchFn(`${GRAPH_ROOT}/me/drive/items/${params.itemId}/createLink`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: "view",
|
||||
scope: params.scope ?? "organization",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`Create sharing link failed: ${res.status} ${res.statusText} - ${body}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
link?: { webUrl?: string };
|
||||
};
|
||||
|
||||
if (!data.link?.webUrl) {
|
||||
throw new Error("Create sharing link response missing webUrl");
|
||||
}
|
||||
|
||||
return {
|
||||
webUrl: data.link.webUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to OneDrive and create a sharing link.
|
||||
* Convenience function for the common case.
|
||||
*/
|
||||
export async function uploadAndShareOneDrive(params: {
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
contentType?: string;
|
||||
tokenProvider: MSTeamsAccessTokenProvider;
|
||||
scope?: "organization" | "anonymous";
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<{
|
||||
itemId: string;
|
||||
webUrl: string;
|
||||
shareUrl: string;
|
||||
name: string;
|
||||
}> {
|
||||
const uploaded = await uploadToOneDrive({
|
||||
buffer: params.buffer,
|
||||
filename: params.filename,
|
||||
contentType: params.contentType,
|
||||
tokenProvider: params.tokenProvider,
|
||||
fetchFn: params.fetchFn,
|
||||
});
|
||||
|
||||
const shareLink = await createSharingLink({
|
||||
itemId: uploaded.id,
|
||||
tokenProvider: params.tokenProvider,
|
||||
scope: params.scope,
|
||||
fetchFn: params.fetchFn,
|
||||
});
|
||||
|
||||
return {
|
||||
itemId: uploaded.id,
|
||||
webUrl: uploaded.webUrl,
|
||||
shareUrl: shareLink.webUrl,
|
||||
name: uploaded.name,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SharePoint upload functions for group chats and channels
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Upload a file to a SharePoint site.
|
||||
* This is used for group chats and channels where /me/drive doesn't work for bots.
|
||||
*
|
||||
* @param params.siteId - SharePoint site ID (e.g., "contoso.sharepoint.com,guid1,guid2")
|
||||
*/
|
||||
export async function uploadToSharePoint(params: {
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
contentType?: string;
|
||||
tokenProvider: MSTeamsAccessTokenProvider;
|
||||
siteId: string;
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<OneDriveUploadResult> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
||||
|
||||
// Use "ClawdbotShared" folder to organize bot-uploaded files
|
||||
const uploadPath = `/ClawdbotShared/${encodeURIComponent(params.filename)}`;
|
||||
|
||||
const res = await fetchFn(`${GRAPH_ROOT}/sites/${params.siteId}/drive/root:${uploadPath}:/content`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": params.contentType ?? "application/octet-stream",
|
||||
},
|
||||
body: new Uint8Array(params.buffer),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`SharePoint upload failed: ${res.status} ${res.statusText} - ${body}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
id?: string;
|
||||
webUrl?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
if (!data.id || !data.webUrl || !data.name) {
|
||||
throw new Error("SharePoint upload response missing required fields");
|
||||
}
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
webUrl: data.webUrl,
|
||||
name: data.name,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatMember {
|
||||
aadObjectId: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties needed for native Teams file card attachments.
|
||||
* The eTag is used as the attachment ID and webDavUrl as the contentUrl.
|
||||
*/
|
||||
export interface DriveItemProperties {
|
||||
/** The eTag of the driveItem (used as attachment ID) */
|
||||
eTag: string;
|
||||
/** The WebDAV URL of the driveItem (used as contentUrl for reference attachment) */
|
||||
webDavUrl: string;
|
||||
/** The filename */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get driveItem properties needed for native Teams file card attachments.
|
||||
* This fetches the eTag and webDavUrl which are required for "reference" type attachments.
|
||||
*
|
||||
* @param params.siteId - SharePoint site ID
|
||||
* @param params.itemId - The driveItem ID (returned from upload)
|
||||
*/
|
||||
export async function getDriveItemProperties(params: {
|
||||
siteId: string;
|
||||
itemId: string;
|
||||
tokenProvider: MSTeamsAccessTokenProvider;
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<DriveItemProperties> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
||||
|
||||
const res = await fetchFn(
|
||||
`${GRAPH_ROOT}/sites/${params.siteId}/drive/items/${params.itemId}?$select=eTag,webDavUrl,name`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`Get driveItem properties failed: ${res.status} ${res.statusText} - ${body}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
eTag?: string;
|
||||
webDavUrl?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
if (!data.eTag || !data.webDavUrl || !data.name) {
|
||||
throw new Error("DriveItem response missing required properties (eTag, webDavUrl, or name)");
|
||||
}
|
||||
|
||||
return {
|
||||
eTag: data.eTag,
|
||||
webDavUrl: data.webDavUrl,
|
||||
name: data.name,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get members of a Teams chat for per-user sharing.
|
||||
* Used to create sharing links scoped to only the chat participants.
|
||||
*/
|
||||
export async function getChatMembers(params: {
|
||||
chatId: string;
|
||||
tokenProvider: MSTeamsAccessTokenProvider;
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<ChatMember[]> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
||||
|
||||
const res = await fetchFn(`${GRAPH_ROOT}/chats/${params.chatId}/members`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`Get chat members failed: ${res.status} ${res.statusText} - ${body}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
value?: Array<{
|
||||
userId?: string;
|
||||
displayName?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
return (data.value ?? [])
|
||||
.map((m) => ({
|
||||
aadObjectId: m.userId ?? "",
|
||||
displayName: m.displayName,
|
||||
}))
|
||||
.filter((m) => m.aadObjectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a sharing link for a SharePoint drive item.
|
||||
* For organization scope (default), uses v1.0 API.
|
||||
* For per-user scope, uses beta API with recipients.
|
||||
*/
|
||||
export async function createSharePointSharingLink(params: {
|
||||
siteId: string;
|
||||
itemId: string;
|
||||
tokenProvider: MSTeamsAccessTokenProvider;
|
||||
/** Sharing scope: "organization" (default) or "users" (per-user with recipients) */
|
||||
scope?: "organization" | "users";
|
||||
/** Required when scope is "users": AAD object IDs of recipients */
|
||||
recipientObjectIds?: string[];
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<OneDriveSharingLink> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
||||
const scope = params.scope ?? "organization";
|
||||
|
||||
// Per-user sharing requires beta API
|
||||
const apiRoot = scope === "users" ? GRAPH_BETA : GRAPH_ROOT;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
type: "view",
|
||||
scope: scope === "users" ? "users" : "organization",
|
||||
};
|
||||
|
||||
// Add recipients for per-user sharing
|
||||
if (scope === "users" && params.recipientObjectIds?.length) {
|
||||
body.recipients = params.recipientObjectIds.map((id) => ({ objectId: id }));
|
||||
}
|
||||
|
||||
const res = await fetchFn(`${apiRoot}/sites/${params.siteId}/drive/items/${params.itemId}/createLink`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const respBody = await res.text().catch(() => "");
|
||||
throw new Error(`Create SharePoint sharing link failed: ${res.status} ${res.statusText} - ${respBody}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
link?: { webUrl?: string };
|
||||
};
|
||||
|
||||
if (!data.link?.webUrl) {
|
||||
throw new Error("Create SharePoint sharing link response missing webUrl");
|
||||
}
|
||||
|
||||
return {
|
||||
webUrl: data.link.webUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to SharePoint and create a sharing link.
|
||||
*
|
||||
* For group chats, this creates a per-user sharing link scoped to chat members.
|
||||
* For channels, this creates an organization-wide sharing link.
|
||||
*
|
||||
* @param params.siteId - SharePoint site ID
|
||||
* @param params.chatId - Optional chat ID for per-user sharing (group chats)
|
||||
* @param params.usePerUserSharing - Whether to use per-user sharing (requires beta API + Chat.Read.All)
|
||||
*/
|
||||
export async function uploadAndShareSharePoint(params: {
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
contentType?: string;
|
||||
tokenProvider: MSTeamsAccessTokenProvider;
|
||||
siteId: string;
|
||||
chatId?: string;
|
||||
usePerUserSharing?: boolean;
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<{
|
||||
itemId: string;
|
||||
webUrl: string;
|
||||
shareUrl: string;
|
||||
name: string;
|
||||
}> {
|
||||
// 1. Upload file to SharePoint
|
||||
const uploaded = await uploadToSharePoint({
|
||||
buffer: params.buffer,
|
||||
filename: params.filename,
|
||||
contentType: params.contentType,
|
||||
tokenProvider: params.tokenProvider,
|
||||
siteId: params.siteId,
|
||||
fetchFn: params.fetchFn,
|
||||
});
|
||||
|
||||
// 2. Determine sharing scope
|
||||
let scope: "organization" | "users" = "organization";
|
||||
let recipientObjectIds: string[] | undefined;
|
||||
|
||||
if (params.usePerUserSharing && params.chatId) {
|
||||
try {
|
||||
const members = await getChatMembers({
|
||||
chatId: params.chatId,
|
||||
tokenProvider: params.tokenProvider,
|
||||
fetchFn: params.fetchFn,
|
||||
});
|
||||
|
||||
if (members.length > 0) {
|
||||
scope = "users";
|
||||
recipientObjectIds = members.map((m) => m.aadObjectId);
|
||||
}
|
||||
} catch {
|
||||
// Fall back to organization scope if we can't get chat members
|
||||
// (e.g., missing Chat.Read.All permission)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create sharing link
|
||||
const shareLink = await createSharePointSharingLink({
|
||||
siteId: params.siteId,
|
||||
itemId: uploaded.id,
|
||||
tokenProvider: params.tokenProvider,
|
||||
scope,
|
||||
recipientObjectIds,
|
||||
fetchFn: params.fetchFn,
|
||||
});
|
||||
|
||||
return {
|
||||
itemId: uploaded.id,
|
||||
webUrl: uploaded.webUrl,
|
||||
shareUrl: shareLink.webUrl,
|
||||
name: uploaded.name,
|
||||
};
|
||||
}
|
||||
186
extensions/msteams/src/media-helpers.test.ts
Normal file
186
extensions/msteams/src/media-helpers.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js";
|
||||
|
||||
describe("msteams media-helpers", () => {
|
||||
describe("getMimeType", () => {
|
||||
it("detects png from URL", async () => {
|
||||
expect(await getMimeType("https://example.com/image.png")).toBe("image/png");
|
||||
});
|
||||
|
||||
it("detects jpeg from URL (both extensions)", async () => {
|
||||
expect(await getMimeType("https://example.com/photo.jpg")).toBe("image/jpeg");
|
||||
expect(await getMimeType("https://example.com/photo.jpeg")).toBe("image/jpeg");
|
||||
});
|
||||
|
||||
it("detects gif from URL", async () => {
|
||||
expect(await getMimeType("https://example.com/anim.gif")).toBe("image/gif");
|
||||
});
|
||||
|
||||
it("detects webp from URL", async () => {
|
||||
expect(await getMimeType("https://example.com/modern.webp")).toBe("image/webp");
|
||||
});
|
||||
|
||||
it("handles URLs with query strings", async () => {
|
||||
expect(await getMimeType("https://example.com/image.png?v=123")).toBe("image/png");
|
||||
});
|
||||
|
||||
it("handles data URLs", async () => {
|
||||
expect(await getMimeType("data:image/png;base64,iVBORw0KGgo=")).toBe("image/png");
|
||||
expect(await getMimeType("data:image/jpeg;base64,/9j/4AAQ")).toBe("image/jpeg");
|
||||
expect(await getMimeType("data:image/gif;base64,R0lGOD")).toBe("image/gif");
|
||||
});
|
||||
|
||||
it("handles data URLs without base64", async () => {
|
||||
expect(await getMimeType("data:image/svg+xml,%3Csvg")).toBe("image/svg+xml");
|
||||
});
|
||||
|
||||
it("handles local paths", async () => {
|
||||
expect(await getMimeType("/tmp/image.png")).toBe("image/png");
|
||||
expect(await getMimeType("/Users/test/photo.jpg")).toBe("image/jpeg");
|
||||
});
|
||||
|
||||
it("handles tilde paths", async () => {
|
||||
expect(await getMimeType("~/Downloads/image.gif")).toBe("image/gif");
|
||||
});
|
||||
|
||||
it("defaults to application/octet-stream for unknown extensions", async () => {
|
||||
expect(await getMimeType("https://example.com/image")).toBe("application/octet-stream");
|
||||
expect(await getMimeType("https://example.com/image.unknown")).toBe("application/octet-stream");
|
||||
});
|
||||
|
||||
it("is case-insensitive", async () => {
|
||||
expect(await getMimeType("https://example.com/IMAGE.PNG")).toBe("image/png");
|
||||
expect(await getMimeType("https://example.com/Photo.JPEG")).toBe("image/jpeg");
|
||||
});
|
||||
|
||||
it("detects document types", async () => {
|
||||
expect(await getMimeType("https://example.com/doc.pdf")).toBe("application/pdf");
|
||||
expect(await getMimeType("https://example.com/doc.docx")).toBe(
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
);
|
||||
expect(await getMimeType("https://example.com/spreadsheet.xlsx")).toBe(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFilename", () => {
|
||||
it("extracts filename from URL with extension", async () => {
|
||||
expect(await extractFilename("https://example.com/photo.jpg")).toBe("photo.jpg");
|
||||
});
|
||||
|
||||
it("extracts filename from URL with path", async () => {
|
||||
expect(await extractFilename("https://example.com/images/2024/photo.png")).toBe("photo.png");
|
||||
});
|
||||
|
||||
it("handles URLs without extension by deriving from MIME", async () => {
|
||||
// Now defaults to application/octet-stream → .bin fallback
|
||||
expect(await extractFilename("https://example.com/images/photo")).toBe("photo.bin");
|
||||
});
|
||||
|
||||
it("handles data URLs", async () => {
|
||||
expect(await extractFilename("data:image/png;base64,iVBORw0KGgo=")).toBe("image.png");
|
||||
expect(await extractFilename("data:image/jpeg;base64,/9j/4AAQ")).toBe("image.jpg");
|
||||
});
|
||||
|
||||
it("handles document data URLs", async () => {
|
||||
expect(await extractFilename("data:application/pdf;base64,JVBERi0")).toBe("file.pdf");
|
||||
});
|
||||
|
||||
it("handles local paths", async () => {
|
||||
expect(await extractFilename("/tmp/screenshot.png")).toBe("screenshot.png");
|
||||
expect(await extractFilename("/Users/test/photo.jpg")).toBe("photo.jpg");
|
||||
});
|
||||
|
||||
it("handles tilde paths", async () => {
|
||||
expect(await extractFilename("~/Downloads/image.gif")).toBe("image.gif");
|
||||
});
|
||||
|
||||
it("returns fallback for empty URL", async () => {
|
||||
expect(await extractFilename("")).toBe("file.bin");
|
||||
});
|
||||
|
||||
it("extracts original filename from embedded pattern", async () => {
|
||||
// Pattern: {original}---{uuid}.{ext}
|
||||
expect(
|
||||
await extractFilename("/media/inbound/report---a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf"),
|
||||
).toBe("report.pdf");
|
||||
});
|
||||
|
||||
it("extracts original filename with uppercase UUID", async () => {
|
||||
expect(
|
||||
await extractFilename("/media/inbound/Document---A1B2C3D4-E5F6-7890-ABCD-EF1234567890.docx"),
|
||||
).toBe("Document.docx");
|
||||
});
|
||||
|
||||
it("falls back to UUID filename for legacy paths", async () => {
|
||||
// UUID-only filename (legacy format, no embedded name)
|
||||
expect(
|
||||
await extractFilename("/media/inbound/a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf"),
|
||||
).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf");
|
||||
});
|
||||
|
||||
it("handles --- in filename without valid UUID pattern", async () => {
|
||||
// foo---bar.txt (bar is not a valid UUID)
|
||||
expect(await extractFilename("/media/inbound/foo---bar.txt")).toBe("foo---bar.txt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLocalPath", () => {
|
||||
it("returns true for file:// URLs", () => {
|
||||
expect(isLocalPath("file:///tmp/image.png")).toBe(true);
|
||||
expect(isLocalPath("file://localhost/tmp/image.png")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for absolute paths", () => {
|
||||
expect(isLocalPath("/tmp/image.png")).toBe(true);
|
||||
expect(isLocalPath("/Users/test/photo.jpg")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for tilde paths", () => {
|
||||
expect(isLocalPath("~/Downloads/image.png")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for http URLs", () => {
|
||||
expect(isLocalPath("http://example.com/image.png")).toBe(false);
|
||||
expect(isLocalPath("https://example.com/image.png")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for data URLs", () => {
|
||||
expect(isLocalPath("data:image/png;base64,iVBORw0KGgo=")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMessageId", () => {
|
||||
it("extracts id from valid response", () => {
|
||||
expect(extractMessageId({ id: "msg123" })).toBe("msg123");
|
||||
});
|
||||
|
||||
it("returns null for missing id", () => {
|
||||
expect(extractMessageId({ foo: "bar" })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty id", () => {
|
||||
expect(extractMessageId({ id: "" })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-string id", () => {
|
||||
expect(extractMessageId({ id: 123 })).toBeNull();
|
||||
expect(extractMessageId({ id: null })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for null response", () => {
|
||||
expect(extractMessageId(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for undefined response", () => {
|
||||
expect(extractMessageId(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-object response", () => {
|
||||
expect(extractMessageId("string")).toBeNull();
|
||||
expect(extractMessageId(123)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
77
extensions/msteams/src/media-helpers.ts
Normal file
77
extensions/msteams/src/media-helpers.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* MIME type detection and filename extraction for MSTeams media attachments.
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
detectMime,
|
||||
extensionForMime,
|
||||
extractOriginalFilename,
|
||||
getFileExtension,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
/**
|
||||
* Detect MIME type from URL extension or data URL.
|
||||
* Uses shared MIME detection for consistency with core handling.
|
||||
*/
|
||||
export async function getMimeType(url: string): Promise<string> {
|
||||
// Handle data URLs: data:image/png;base64,...
|
||||
if (url.startsWith("data:")) {
|
||||
const match = url.match(/^data:([^;,]+)/);
|
||||
if (match?.[1]) return match[1];
|
||||
}
|
||||
|
||||
// Use shared MIME detection (extension-based for URLs)
|
||||
const detected = await detectMime({ filePath: url });
|
||||
return detected ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract filename from URL or local path.
|
||||
* For local paths, extracts original filename if stored with embedded name pattern.
|
||||
* Falls back to deriving the extension from MIME type when no extension present.
|
||||
*/
|
||||
export async function extractFilename(url: string): Promise<string> {
|
||||
// Handle data URLs: derive extension from MIME
|
||||
if (url.startsWith("data:")) {
|
||||
const mime = await getMimeType(url);
|
||||
const ext = extensionForMime(mime) ?? ".bin";
|
||||
const prefix = mime.startsWith("image/") ? "image" : "file";
|
||||
return `${prefix}${ext}`;
|
||||
}
|
||||
|
||||
// Try to extract from URL pathname
|
||||
try {
|
||||
const pathname = new URL(url).pathname;
|
||||
const basename = path.basename(pathname);
|
||||
const existingExt = getFileExtension(pathname);
|
||||
if (basename && existingExt) return basename;
|
||||
// No extension in URL, derive from MIME
|
||||
const mime = await getMimeType(url);
|
||||
const ext = extensionForMime(mime) ?? ".bin";
|
||||
const prefix = mime.startsWith("image/") ? "image" : "file";
|
||||
return basename ? `${basename}${ext}` : `${prefix}${ext}`;
|
||||
} catch {
|
||||
// Local paths - use extractOriginalFilename to extract embedded original name
|
||||
return extractOriginalFilename(url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL refers to a local file path.
|
||||
*/
|
||||
export function isLocalPath(url: string): boolean {
|
||||
return url.startsWith("file://") || url.startsWith("/") || url.startsWith("~");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the message ID from a Bot Framework response.
|
||||
*/
|
||||
export function extractMessageId(response: unknown): string | null {
|
||||
if (!response || typeof response !== "object") return null;
|
||||
if (!("id" in response)) return null;
|
||||
const { id } = response as { id?: unknown };
|
||||
if (typeof id !== "string" || !id) return null;
|
||||
return id;
|
||||
}
|
||||
@@ -51,7 +51,7 @@ describe("msteams messenger", () => {
|
||||
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
||||
{ textChunkLimit: 4000 },
|
||||
);
|
||||
expect(messages).toEqual(["hi", "https://example.com/a.png"]);
|
||||
expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]);
|
||||
});
|
||||
|
||||
it("supports inline media mode", () => {
|
||||
@@ -59,7 +59,7 @@ describe("msteams messenger", () => {
|
||||
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
||||
{ textChunkLimit: 4000, mediaMode: "inline" },
|
||||
);
|
||||
expect(messages).toEqual(["hi\n\nhttps://example.com/a.png"]);
|
||||
expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]);
|
||||
});
|
||||
|
||||
it("chunks long text when enabled", () => {
|
||||
@@ -101,7 +101,7 @@ describe("msteams messenger", () => {
|
||||
appId: "app123",
|
||||
conversationRef: baseRef,
|
||||
context: ctx,
|
||||
messages: ["one", "two"],
|
||||
messages: [{ text: "one" }, { text: "two" }],
|
||||
});
|
||||
|
||||
expect(sent).toEqual(["one", "two"]);
|
||||
@@ -129,7 +129,7 @@ describe("msteams messenger", () => {
|
||||
adapter,
|
||||
appId: "app123",
|
||||
conversationRef: baseRef,
|
||||
messages: ["hello"],
|
||||
messages: [{ text: "hello" }],
|
||||
});
|
||||
|
||||
expect(seen.texts).toEqual(["hello"]);
|
||||
@@ -168,7 +168,7 @@ describe("msteams messenger", () => {
|
||||
appId: "app123",
|
||||
conversationRef: baseRef,
|
||||
context: ctx,
|
||||
messages: ["one"],
|
||||
messages: [{ text: "one" }],
|
||||
retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
|
||||
onRetry: (e) => retryEvents.push({ nextAttempt: e.nextAttempt, delayMs: e.delayMs }),
|
||||
});
|
||||
@@ -196,7 +196,7 @@ describe("msteams messenger", () => {
|
||||
appId: "app123",
|
||||
conversationRef: baseRef,
|
||||
context: ctx,
|
||||
messages: ["one"],
|
||||
messages: [{ text: "one" }],
|
||||
retry: { maxAttempts: 3, baseDelayMs: 0, maxDelayMs: 0 },
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
@@ -227,7 +227,7 @@ describe("msteams messenger", () => {
|
||||
adapter,
|
||||
appId: "app123",
|
||||
conversationRef: baseRef,
|
||||
messages: ["hello"],
|
||||
messages: [{ text: "hello" }],
|
||||
retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,35 @@
|
||||
import {
|
||||
isSilentReplyText,
|
||||
loadWebMedia,
|
||||
type MSTeamsReplyStyle,
|
||||
type ReplyPayload,
|
||||
SILENT_REPLY_TOKEN,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
|
||||
import type { StoredConversationReference } from "./conversation-store.js";
|
||||
import { classifyMSTeamsSendError } from "./errors.js";
|
||||
import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.js";
|
||||
import { buildTeamsFileInfoCard } from "./graph-chat.js";
|
||||
import {
|
||||
getDriveItemProperties,
|
||||
uploadAndShareOneDrive,
|
||||
uploadAndShareSharePoint,
|
||||
} from "./graph-upload.js";
|
||||
import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
/**
|
||||
* MSTeams-specific media size limit (100MB).
|
||||
* Higher than the default because OneDrive upload handles large files well.
|
||||
*/
|
||||
const MSTEAMS_MAX_MEDIA_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Threshold for large files that require FileConsentCard flow in personal chats.
|
||||
* Files >= 4MB use consent flow; smaller images can use inline base64.
|
||||
*/
|
||||
const FILE_CONSENT_THRESHOLD_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
type SendContext = {
|
||||
sendActivity: (textOrActivity: string | object) => Promise<unknown>;
|
||||
};
|
||||
@@ -41,6 +63,15 @@ export type MSTeamsReplyRenderOptions = {
|
||||
mediaMode?: "split" | "inline";
|
||||
};
|
||||
|
||||
/**
|
||||
* A rendered message that preserves media vs text distinction.
|
||||
* When mediaUrl is present, it will be sent as a Bot Framework attachment.
|
||||
*/
|
||||
export type MSTeamsRenderedMessage = {
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
};
|
||||
|
||||
export type MSTeamsSendRetryOptions = {
|
||||
maxAttempts?: number;
|
||||
baseDelayMs?: number;
|
||||
@@ -90,16 +121,8 @@ export function buildConversationReference(
|
||||
};
|
||||
}
|
||||
|
||||
function extractMessageId(response: unknown): string | null {
|
||||
if (!response || typeof response !== "object") return null;
|
||||
if (!("id" in response)) return null;
|
||||
const { id } = response as { id?: unknown };
|
||||
if (typeof id !== "string" || !id) return null;
|
||||
return id;
|
||||
}
|
||||
|
||||
function pushTextMessages(
|
||||
out: string[],
|
||||
out: MSTeamsRenderedMessage[],
|
||||
text: string,
|
||||
opts: {
|
||||
chunkText: boolean;
|
||||
@@ -111,16 +134,17 @@ function pushTextMessages(
|
||||
for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownText(text, opts.chunkLimit)) {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
|
||||
out.push(trimmed);
|
||||
out.push({ text: trimmed });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) return;
|
||||
out.push(trimmed);
|
||||
out.push({ text: trimmed });
|
||||
}
|
||||
|
||||
|
||||
function clampMs(value: number, maxMs: number): number {
|
||||
if (!Number.isFinite(value) || value < 0) return 0;
|
||||
return Math.min(value, maxMs);
|
||||
@@ -167,8 +191,8 @@ function shouldRetry(classification: ReturnType<typeof classifyMSTeamsSendError>
|
||||
export function renderReplyPayloadsToMessages(
|
||||
replies: ReplyPayload[],
|
||||
options: MSTeamsReplyRenderOptions,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
): MSTeamsRenderedMessage[] {
|
||||
const out: MSTeamsRenderedMessage[] = [];
|
||||
const chunkLimit = Math.min(options.textChunkLimit, 4000);
|
||||
const chunkText = options.chunkText !== false;
|
||||
const mediaMode = options.mediaMode ?? "split";
|
||||
@@ -185,8 +209,17 @@ export function renderReplyPayloadsToMessages(
|
||||
}
|
||||
|
||||
if (mediaMode === "inline") {
|
||||
const combined = text ? `${text}\n\n${mediaList.join("\n")}` : mediaList.join("\n");
|
||||
pushTextMessages(out, combined, { chunkText, chunkLimit });
|
||||
// For inline mode, combine text with first media as attachment
|
||||
const firstMedia = mediaList[0];
|
||||
if (firstMedia) {
|
||||
out.push({ text: text || undefined, mediaUrl: firstMedia });
|
||||
// Additional media URLs as separate messages
|
||||
for (let i = 1; i < mediaList.length; i++) {
|
||||
if (mediaList[i]) out.push({ mediaUrl: mediaList[i] });
|
||||
}
|
||||
} else {
|
||||
pushTextMessages(out, text, { chunkText, chunkLimit });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -194,26 +227,142 @@ export function renderReplyPayloadsToMessages(
|
||||
pushTextMessages(out, text, { chunkText, chunkLimit });
|
||||
for (const mediaUrl of mediaList) {
|
||||
if (!mediaUrl) continue;
|
||||
out.push(mediaUrl);
|
||||
out.push({ mediaUrl });
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
async function buildActivity(
|
||||
msg: MSTeamsRenderedMessage,
|
||||
conversationRef: StoredConversationReference,
|
||||
tokenProvider?: MSTeamsAccessTokenProvider,
|
||||
sharePointSiteId?: string,
|
||||
mediaMaxBytes?: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const activity: Record<string, unknown> = { type: "message" };
|
||||
|
||||
if (msg.text) {
|
||||
activity.text = msg.text;
|
||||
}
|
||||
|
||||
if (msg.mediaUrl) {
|
||||
let contentUrl = msg.mediaUrl;
|
||||
let contentType = await getMimeType(msg.mediaUrl);
|
||||
let fileName = await extractFilename(msg.mediaUrl);
|
||||
|
||||
if (isLocalPath(msg.mediaUrl)) {
|
||||
const maxBytes = mediaMaxBytes ?? MSTEAMS_MAX_MEDIA_BYTES;
|
||||
const media = await loadWebMedia(msg.mediaUrl, maxBytes);
|
||||
contentType = media.contentType ?? contentType;
|
||||
fileName = media.fileName ?? fileName;
|
||||
|
||||
// Determine conversation type and file type
|
||||
// Teams only accepts base64 data URLs for images
|
||||
const conversationType = conversationRef.conversation?.conversationType?.toLowerCase();
|
||||
const isPersonal = conversationType === "personal";
|
||||
const isImage = contentType?.startsWith("image/") ?? false;
|
||||
|
||||
if (requiresFileConsent({
|
||||
conversationType,
|
||||
contentType,
|
||||
bufferSize: media.buffer.length,
|
||||
thresholdBytes: FILE_CONSENT_THRESHOLD_BYTES,
|
||||
})) {
|
||||
// Large file or non-image in personal chat: use FileConsentCard flow
|
||||
const conversationId = conversationRef.conversation?.id ?? "unknown";
|
||||
const { activity: consentActivity } = prepareFileConsentActivity({
|
||||
media: { buffer: media.buffer, filename: fileName, contentType },
|
||||
conversationId,
|
||||
description: msg.text || undefined,
|
||||
});
|
||||
|
||||
// Return the consent activity (caller sends it)
|
||||
return consentActivity;
|
||||
}
|
||||
|
||||
if (!isPersonal && !isImage && tokenProvider && sharePointSiteId) {
|
||||
// Non-image in group chat/channel with SharePoint site configured:
|
||||
// Upload to SharePoint and use native file card attachment
|
||||
const chatId = conversationRef.conversation?.id;
|
||||
|
||||
// Upload to SharePoint
|
||||
const uploaded = await uploadAndShareSharePoint({
|
||||
buffer: media.buffer,
|
||||
filename: fileName,
|
||||
contentType,
|
||||
tokenProvider,
|
||||
siteId: sharePointSiteId,
|
||||
chatId: chatId ?? undefined,
|
||||
usePerUserSharing: conversationType === "groupchat",
|
||||
});
|
||||
|
||||
// Get driveItem properties needed for native file card attachment
|
||||
const driveItem = await getDriveItemProperties({
|
||||
siteId: sharePointSiteId,
|
||||
itemId: uploaded.itemId,
|
||||
tokenProvider,
|
||||
});
|
||||
|
||||
// Build native Teams file card attachment
|
||||
const fileCardAttachment = buildTeamsFileInfoCard(driveItem);
|
||||
activity.attachments = [fileCardAttachment];
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
if (!isPersonal && !isImage && tokenProvider) {
|
||||
// Fallback: no SharePoint site configured, try OneDrive upload
|
||||
const uploaded = await uploadAndShareOneDrive({
|
||||
buffer: media.buffer,
|
||||
filename: fileName,
|
||||
contentType,
|
||||
tokenProvider,
|
||||
});
|
||||
|
||||
// Bot Framework doesn't support "reference" attachment type for sending
|
||||
const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`;
|
||||
activity.text = msg.text ? `${msg.text}\n\n${fileLink}` : fileLink;
|
||||
return activity;
|
||||
}
|
||||
|
||||
// Image (any chat): use base64 (works for images in all conversation types)
|
||||
const base64 = media.buffer.toString("base64");
|
||||
contentUrl = `data:${media.contentType};base64,${base64}`;
|
||||
}
|
||||
|
||||
activity.attachments = [
|
||||
{
|
||||
name: fileName,
|
||||
contentType,
|
||||
contentUrl,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
export async function sendMSTeamsMessages(params: {
|
||||
replyStyle: MSTeamsReplyStyle;
|
||||
adapter: MSTeamsAdapter;
|
||||
appId: string;
|
||||
conversationRef: StoredConversationReference;
|
||||
context?: SendContext;
|
||||
messages: string[];
|
||||
messages: MSTeamsRenderedMessage[];
|
||||
retry?: false | MSTeamsSendRetryOptions;
|
||||
onRetry?: (event: MSTeamsSendRetryEvent) => void;
|
||||
/** Token provider for OneDrive/SharePoint uploads in group chats/channels */
|
||||
tokenProvider?: MSTeamsAccessTokenProvider;
|
||||
/** SharePoint site ID for file uploads in group chats/channels */
|
||||
sharePointSiteId?: string;
|
||||
/** Max media size in bytes. Default: 100MB. */
|
||||
mediaMaxBytes?: number;
|
||||
}): Promise<string[]> {
|
||||
const messages = params.messages
|
||||
.map((m) => (typeof m === "string" ? m : String(m)))
|
||||
.filter((m) => m.trim().length > 0);
|
||||
const messages = params.messages.filter(
|
||||
(m) => (m.text && m.text.trim().length > 0) || m.mediaUrl,
|
||||
);
|
||||
if (messages.length === 0) return [];
|
||||
|
||||
const retryOptions = resolveRetryOptions(params.retry);
|
||||
@@ -259,10 +408,9 @@ export async function sendMSTeamsMessages(params: {
|
||||
for (const [idx, message] of messages.entries()) {
|
||||
const response = await sendWithRetry(
|
||||
async () =>
|
||||
await ctx.sendActivity({
|
||||
type: "message",
|
||||
text: message,
|
||||
}),
|
||||
await ctx.sendActivity(
|
||||
await buildActivity(message, params.conversationRef, params.tokenProvider, params.sharePointSiteId, params.mediaMaxBytes),
|
||||
),
|
||||
{ messageIndex: idx, messageCount: messages.length },
|
||||
);
|
||||
messageIds.push(extractMessageId(response) ?? "unknown");
|
||||
@@ -281,10 +429,9 @@ export async function sendMSTeamsMessages(params: {
|
||||
for (const [idx, message] of messages.entries()) {
|
||||
const response = await sendWithRetry(
|
||||
async () =>
|
||||
await ctx.sendActivity({
|
||||
type: "message",
|
||||
text: message,
|
||||
}),
|
||||
await ctx.sendActivity(
|
||||
await buildActivity(message, params.conversationRef, params.tokenProvider, params.sharePointSiteId, params.mediaMaxBytes),
|
||||
),
|
||||
{ messageIndex: idx, messageCount: messages.length },
|
||||
);
|
||||
messageIds.push(extractMessageId(response) ?? "unknown");
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
import type { MSTeamsConversationStore } from "./conversation-store.js";
|
||||
import {
|
||||
buildFileInfoCard,
|
||||
parseFileConsentInvoke,
|
||||
uploadToConsentUrl,
|
||||
} from "./file-consent.js";
|
||||
import type { MSTeamsAdapter } from "./messenger.js";
|
||||
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
|
||||
import type { MSTeamsMonitorLogger } from "./monitor-types.js";
|
||||
import { getPendingUpload, removePendingUpload } from "./pending-uploads.js";
|
||||
import type { MSTeamsPollStore } from "./polls.js";
|
||||
import type { MSTeamsTurnContext } from "./sdk-types.js";
|
||||
|
||||
@@ -17,6 +23,7 @@ export type MSTeamsActivityHandler = {
|
||||
onMembersAdded: (
|
||||
handler: (context: unknown, next: () => Promise<void>) => Promise<void>,
|
||||
) => MSTeamsActivityHandler;
|
||||
run?: (context: unknown) => Promise<void>;
|
||||
};
|
||||
|
||||
export type MSTeamsMessageHandlerDeps = {
|
||||
@@ -32,11 +39,109 @@ export type MSTeamsMessageHandlerDeps = {
|
||||
log: MSTeamsMonitorLogger;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle fileConsent/invoke activities for large file uploads.
|
||||
*/
|
||||
async function handleFileConsentInvoke(
|
||||
context: MSTeamsTurnContext,
|
||||
log: MSTeamsMonitorLogger,
|
||||
): Promise<boolean> {
|
||||
const activity = context.activity;
|
||||
if (activity.type !== "invoke" || activity.name !== "fileConsent/invoke") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const consentResponse = parseFileConsentInvoke(activity);
|
||||
if (!consentResponse) {
|
||||
log.debug("invalid file consent invoke", { value: activity.value });
|
||||
return false;
|
||||
}
|
||||
|
||||
const uploadId =
|
||||
typeof consentResponse.context?.uploadId === "string"
|
||||
? consentResponse.context.uploadId
|
||||
: undefined;
|
||||
|
||||
if (consentResponse.action === "accept" && consentResponse.uploadInfo) {
|
||||
const pendingFile = getPendingUpload(uploadId);
|
||||
if (pendingFile) {
|
||||
log.debug("user accepted file consent, uploading", {
|
||||
uploadId,
|
||||
filename: pendingFile.filename,
|
||||
size: pendingFile.buffer.length,
|
||||
});
|
||||
|
||||
try {
|
||||
// Upload file to the provided URL
|
||||
await uploadToConsentUrl({
|
||||
url: consentResponse.uploadInfo.uploadUrl,
|
||||
buffer: pendingFile.buffer,
|
||||
contentType: pendingFile.contentType,
|
||||
});
|
||||
|
||||
// Send confirmation card
|
||||
const fileInfoCard = buildFileInfoCard({
|
||||
filename: consentResponse.uploadInfo.name,
|
||||
contentUrl: consentResponse.uploadInfo.contentUrl,
|
||||
uniqueId: consentResponse.uploadInfo.uniqueId,
|
||||
fileType: consentResponse.uploadInfo.fileType,
|
||||
});
|
||||
|
||||
await context.sendActivity({
|
||||
type: "message",
|
||||
attachments: [fileInfoCard],
|
||||
});
|
||||
|
||||
log.info("file upload complete", {
|
||||
uploadId,
|
||||
filename: consentResponse.uploadInfo.name,
|
||||
uniqueId: consentResponse.uploadInfo.uniqueId,
|
||||
});
|
||||
} catch (err) {
|
||||
log.debug("file upload failed", { uploadId, error: String(err) });
|
||||
await context.sendActivity(`File upload failed: ${String(err)}`);
|
||||
} finally {
|
||||
removePendingUpload(uploadId);
|
||||
}
|
||||
} else {
|
||||
log.debug("pending file not found for consent", { uploadId });
|
||||
await context.sendActivity(
|
||||
"The file upload request has expired. Please try sending the file again.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// User declined
|
||||
log.debug("user declined file consent", { uploadId });
|
||||
removePendingUpload(uploadId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
|
||||
handler: T,
|
||||
deps: MSTeamsMessageHandlerDeps,
|
||||
): T {
|
||||
const handleTeamsMessage = createMSTeamsMessageHandler(deps);
|
||||
|
||||
// Wrap the original run method to intercept invokes
|
||||
const originalRun = handler.run;
|
||||
if (originalRun) {
|
||||
handler.run = async (context: unknown) => {
|
||||
const ctx = context as MSTeamsTurnContext;
|
||||
// Handle file consent invokes before passing to normal flow
|
||||
if (ctx.activity?.type === "invoke" && ctx.activity?.name === "fileConsent/invoke") {
|
||||
const handled = await handleFileConsentInvoke(ctx, deps.log);
|
||||
if (handled) {
|
||||
// Send invoke response for file consent
|
||||
await ctx.sendActivity({ type: "invokeResponse", value: { status: 200 } });
|
||||
return;
|
||||
}
|
||||
}
|
||||
return originalRun.call(handler, context);
|
||||
};
|
||||
}
|
||||
|
||||
handler.onMessage(async (context, next) => {
|
||||
try {
|
||||
await handleTeamsMessage(context as MSTeamsTurnContext);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
buildMSTeamsGraphMessageUrls,
|
||||
downloadMSTeamsAttachments,
|
||||
downloadMSTeamsGraphMedia,
|
||||
downloadMSTeamsImageAttachments,
|
||||
type MSTeamsAccessTokenProvider,
|
||||
type MSTeamsAttachmentLike,
|
||||
type MSTeamsHtmlAttachmentSummary,
|
||||
@@ -24,6 +24,8 @@ export async function resolveMSTeamsInboundMedia(params: {
|
||||
conversationMessageId?: string;
|
||||
activity: Pick<MSTeamsTurnContext["activity"], "id" | "replyToId" | "channelData">;
|
||||
log: MSTeamsLogger;
|
||||
/** When true, embeds original filename in stored path for later extraction. */
|
||||
preserveFilenames?: boolean;
|
||||
}): Promise<MSTeamsInboundMedia[]> {
|
||||
const {
|
||||
attachments,
|
||||
@@ -36,13 +38,15 @@ export async function resolveMSTeamsInboundMedia(params: {
|
||||
conversationMessageId,
|
||||
activity,
|
||||
log,
|
||||
preserveFilenames,
|
||||
} = params;
|
||||
|
||||
let mediaList = await downloadMSTeamsImageAttachments({
|
||||
let mediaList = await downloadMSTeamsAttachments({
|
||||
attachments,
|
||||
maxBytes,
|
||||
tokenProvider,
|
||||
allowHosts,
|
||||
preserveFilenames,
|
||||
});
|
||||
|
||||
if (mediaList.length === 0) {
|
||||
@@ -81,6 +85,7 @@ export async function resolveMSTeamsInboundMedia(params: {
|
||||
tokenProvider,
|
||||
maxBytes,
|
||||
allowHosts,
|
||||
preserveFilenames,
|
||||
});
|
||||
attempts.push({
|
||||
url: messageUrl,
|
||||
@@ -104,7 +109,7 @@ export async function resolveMSTeamsInboundMedia(params: {
|
||||
}
|
||||
|
||||
if (mediaList.length > 0) {
|
||||
log.debug("downloaded image attachments", { count: mediaList.length });
|
||||
log.debug("downloaded attachments", { count: mediaList.length });
|
||||
} else if (htmlSummary?.imgTags) {
|
||||
log.debug("inline images detected but none downloaded", {
|
||||
imgTags: htmlSummary.imgTags,
|
||||
|
||||
@@ -402,7 +402,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
channelData: activity.channelData,
|
||||
},
|
||||
log,
|
||||
});
|
||||
preserveFilenames: cfg.media?.preserveFilenames,
|
||||
});
|
||||
|
||||
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
||||
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
||||
@@ -476,6 +477,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
|
||||
logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
|
||||
|
||||
const sharePointSiteId = msteamsCfg?.sharePointSiteId;
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
@@ -492,6 +494,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
recordMSTeamsSentMessage(conversationId, id);
|
||||
}
|
||||
},
|
||||
tokenProvider,
|
||||
sharePointSiteId,
|
||||
});
|
||||
|
||||
log.info("dispatching to agent", { sessionKey: route.sessionKey });
|
||||
|
||||
87
extensions/msteams/src/pending-uploads.ts
Normal file
87
extensions/msteams/src/pending-uploads.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* In-memory storage for files awaiting user consent in the FileConsentCard flow.
|
||||
*
|
||||
* When sending large files (>=4MB) in personal chats, Teams requires user consent
|
||||
* before upload. This module stores the file data temporarily until the user
|
||||
* accepts or declines, or until the TTL expires.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export interface PendingUpload {
|
||||
id: string;
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
contentType?: string;
|
||||
conversationId: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const pendingUploads = new Map<string, PendingUpload>();
|
||||
|
||||
/** TTL for pending uploads: 5 minutes */
|
||||
const PENDING_UPLOAD_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Store a file pending user consent.
|
||||
* Returns the upload ID to include in the FileConsentCard context.
|
||||
*/
|
||||
export function storePendingUpload(
|
||||
upload: Omit<PendingUpload, "id" | "createdAt">,
|
||||
): string {
|
||||
const id = crypto.randomUUID();
|
||||
const entry: PendingUpload = {
|
||||
...upload,
|
||||
id,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
pendingUploads.set(id, entry);
|
||||
|
||||
// Auto-cleanup after TTL
|
||||
setTimeout(() => {
|
||||
pendingUploads.delete(id);
|
||||
}, PENDING_UPLOAD_TTL_MS);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a pending upload by ID.
|
||||
* Returns undefined if not found or expired.
|
||||
*/
|
||||
export function getPendingUpload(id?: string): PendingUpload | undefined {
|
||||
if (!id) return undefined;
|
||||
const entry = pendingUploads.get(id);
|
||||
if (!entry) return undefined;
|
||||
|
||||
// Check if expired (in case timeout hasn't fired yet)
|
||||
if (Date.now() - entry.createdAt > PENDING_UPLOAD_TTL_MS) {
|
||||
pendingUploads.delete(id);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a pending upload (after successful upload or user decline).
|
||||
*/
|
||||
export function removePendingUpload(id?: string): void {
|
||||
if (id) {
|
||||
pendingUploads.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of pending uploads (for monitoring/debugging).
|
||||
*/
|
||||
export function getPendingUploadCount(): number {
|
||||
return pendingUploads.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending uploads (for testing).
|
||||
*/
|
||||
export function clearPendingUploads(): void {
|
||||
pendingUploads.clear();
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsRes
|
||||
| undefined;
|
||||
try {
|
||||
const graphToken = await tokenProvider.getAccessToken(
|
||||
"https://graph.microsoft.com/.default",
|
||||
"https://graph.microsoft.com",
|
||||
);
|
||||
const accessToken = readAccessToken(graphToken);
|
||||
const payload = accessToken ? decodeJwtPayload(accessToken) : null;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type {
|
||||
ClawdbotConfig,
|
||||
MSTeamsReplyStyle,
|
||||
RuntimeEnv,
|
||||
import {
|
||||
resolveChannelMediaMaxBytes,
|
||||
type ClawdbotConfig,
|
||||
type MSTeamsReplyStyle,
|
||||
type RuntimeEnv,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
|
||||
import type { StoredConversationReference } from "./conversation-store.js";
|
||||
import {
|
||||
classifyMSTeamsSendError,
|
||||
@@ -30,6 +32,10 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
replyStyle: MSTeamsReplyStyle;
|
||||
textLimit: number;
|
||||
onSentMessageIds?: (ids: string[]) => void;
|
||||
/** Token provider for OneDrive/SharePoint uploads in group chats/channels */
|
||||
tokenProvider?: MSTeamsAccessTokenProvider;
|
||||
/** SharePoint site ID for file uploads in group chats/channels */
|
||||
sharePointSiteId?: string;
|
||||
}) {
|
||||
const core = getMSTeamsRuntime();
|
||||
const sendTypingIndicator = async () => {
|
||||
@@ -52,6 +58,10 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
chunkText: true,
|
||||
mediaMode: "split",
|
||||
});
|
||||
const mediaMaxBytes = resolveChannelMediaMaxBytes({
|
||||
cfg: params.cfg,
|
||||
resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
|
||||
});
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: params.replyStyle,
|
||||
adapter: params.adapter,
|
||||
@@ -67,6 +77,9 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
...event,
|
||||
});
|
||||
},
|
||||
tokenProvider: params.tokenProvider,
|
||||
sharePointSiteId: params.sharePointSiteId,
|
||||
mediaMaxBytes,
|
||||
});
|
||||
if (ids.length > 0) params.onSentMessageIds?.(ids);
|
||||
},
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
import { resolveChannelMediaMaxBytes, type ClawdbotConfig, type PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
|
||||
import type {
|
||||
MSTeamsConversationStore,
|
||||
StoredConversationReference,
|
||||
} from "./conversation-store.js";
|
||||
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
|
||||
import type { MSTeamsAdapter } from "./messenger.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
|
||||
type GetChildLogger = PluginRuntime["logging"]["getChildLogger"];
|
||||
|
||||
let _log: ReturnType<GetChildLogger> | undefined;
|
||||
const getLog = async (): Promise<ReturnType<GetChildLogger>> => {
|
||||
if (_log) return _log;
|
||||
const { getChildLogger } = await import("../logging.js");
|
||||
_log = getChildLogger({ name: "msteams:send" });
|
||||
return _log;
|
||||
};
|
||||
export type MSTeamsConversationType = "personal" | "groupChat" | "channel";
|
||||
|
||||
export type MSTeamsProactiveContext = {
|
||||
appId: string;
|
||||
conversationId: string;
|
||||
ref: StoredConversationReference;
|
||||
adapter: MSTeamsAdapter;
|
||||
log: Awaited<ReturnType<typeof getLog>>;
|
||||
log: ReturnType<PluginRuntime["logging"]["getChildLogger"]>;
|
||||
/** The type of conversation: personal (1:1), groupChat, or channel */
|
||||
conversationType: MSTeamsConversationType;
|
||||
/** Token provider for Graph API / OneDrive operations */
|
||||
tokenProvider: MSTeamsAccessTokenProvider;
|
||||
/** SharePoint site ID for file uploads in group chats/channels */
|
||||
sharePointSiteId?: string;
|
||||
/** Resolved media max bytes from config (default: 100MB) */
|
||||
mediaMaxBytes?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -110,16 +112,45 @@ export async function resolveMSTeamsSendContext(params: {
|
||||
}
|
||||
|
||||
const { conversationId, ref } = found;
|
||||
const log = await getLog();
|
||||
const core = getMSTeamsRuntime();
|
||||
const log = core.logging.getChildLogger({ name: "msteams:send" });
|
||||
|
||||
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
|
||||
const adapter = createMSTeamsAdapter(authConfig, sdk);
|
||||
|
||||
// Create token provider for Graph API / OneDrive operations
|
||||
const tokenProvider = new sdk.MsalTokenProvider(authConfig) as MSTeamsAccessTokenProvider;
|
||||
|
||||
// Determine conversation type from stored reference
|
||||
const storedConversationType = ref.conversation?.conversationType?.toLowerCase() ?? "";
|
||||
let conversationType: MSTeamsConversationType;
|
||||
if (storedConversationType === "personal") {
|
||||
conversationType = "personal";
|
||||
} else if (storedConversationType === "channel") {
|
||||
conversationType = "channel";
|
||||
} else {
|
||||
// groupChat, or unknown defaults to groupChat behavior
|
||||
conversationType = "groupChat";
|
||||
}
|
||||
|
||||
// Get SharePoint site ID from config (required for file uploads in group chats/channels)
|
||||
const sharePointSiteId = msteamsCfg.sharePointSiteId;
|
||||
|
||||
// Resolve media max bytes from config
|
||||
const mediaMaxBytes = resolveChannelMediaMaxBytes({
|
||||
cfg: params.cfg,
|
||||
resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
|
||||
});
|
||||
|
||||
return {
|
||||
appId: creds.appId,
|
||||
conversationId,
|
||||
ref,
|
||||
adapter: adapter as unknown as MSTeamsAdapter,
|
||||
log,
|
||||
conversationType,
|
||||
tokenProvider,
|
||||
sharePointSiteId,
|
||||
mediaMaxBytes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { loadWebMedia, resolveChannelMediaMaxBytes } from "clawdbot/plugin-sdk";
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import type { StoredConversationReference } from "./conversation-store.js";
|
||||
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
|
||||
import {
|
||||
classifyMSTeamsSendError,
|
||||
formatMSTeamsSendErrorHint,
|
||||
formatUnknownError,
|
||||
} from "./errors.js";
|
||||
import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.js";
|
||||
import { buildTeamsFileInfoCard } from "./graph-chat.js";
|
||||
import {
|
||||
buildConversationReference,
|
||||
type MSTeamsAdapter,
|
||||
sendMSTeamsMessages,
|
||||
} from "./messenger.js";
|
||||
getDriveItemProperties,
|
||||
uploadAndShareOneDrive,
|
||||
uploadAndShareSharePoint,
|
||||
} from "./graph-upload.js";
|
||||
import { extractFilename, extractMessageId } from "./media-helpers.js";
|
||||
import { buildConversationReference, sendMSTeamsMessages } from "./messenger.js";
|
||||
import { buildMSTeamsPollCard } from "./polls.js";
|
||||
import { resolveMSTeamsSendContext } from "./send-context.js";
|
||||
import { resolveMSTeamsSendContext, type MSTeamsProactiveContext } from "./send-context.js";
|
||||
|
||||
export type SendMSTeamsMessageParams = {
|
||||
/** Full config (for credentials) */
|
||||
@@ -28,8 +32,19 @@ export type SendMSTeamsMessageParams = {
|
||||
export type SendMSTeamsMessageResult = {
|
||||
messageId: string;
|
||||
conversationId: string;
|
||||
/** If a FileConsentCard was sent instead of the file, this contains the upload ID */
|
||||
pendingUploadId?: string;
|
||||
};
|
||||
|
||||
/** Threshold for large files that require FileConsentCard flow in personal chats */
|
||||
const FILE_CONSENT_THRESHOLD_BYTES = 4 * 1024 * 1024; // 4MB
|
||||
|
||||
/**
|
||||
* MSTeams-specific media size limit (100MB).
|
||||
* Higher than the default because OneDrive upload handles large files well.
|
||||
*/
|
||||
const MSTEAMS_MAX_MEDIA_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
export type SendMSTeamsPollParams = {
|
||||
/** Full config (for credentials) */
|
||||
cfg: ClawdbotConfig;
|
||||
@@ -49,32 +64,19 @@ export type SendMSTeamsPollResult = {
|
||||
conversationId: string;
|
||||
};
|
||||
|
||||
function extractMessageId(response: unknown): string | null {
|
||||
if (!response || typeof response !== "object") return null;
|
||||
if (!("id" in response)) return null;
|
||||
const { id } = response as { id?: unknown };
|
||||
if (typeof id !== "string" || !id) return null;
|
||||
return id;
|
||||
}
|
||||
export type SendMSTeamsCardParams = {
|
||||
/** Full config (for credentials) */
|
||||
cfg: ClawdbotConfig;
|
||||
/** Conversation ID or user ID to send to */
|
||||
to: string;
|
||||
/** Adaptive Card JSON object */
|
||||
card: Record<string, unknown>;
|
||||
};
|
||||
|
||||
async function sendMSTeamsActivity(params: {
|
||||
adapter: MSTeamsAdapter;
|
||||
appId: string;
|
||||
conversationRef: StoredConversationReference;
|
||||
activity: Record<string, unknown>;
|
||||
}): Promise<string> {
|
||||
const baseRef = buildConversationReference(params.conversationRef);
|
||||
const proactiveRef = {
|
||||
...baseRef,
|
||||
activityId: undefined,
|
||||
};
|
||||
let messageId = "unknown";
|
||||
await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => {
|
||||
const response = await ctx.sendActivity(params.activity);
|
||||
messageId = extractMessageId(response) ?? "unknown";
|
||||
});
|
||||
return messageId;
|
||||
}
|
||||
export type SendMSTeamsCardResult = {
|
||||
messageId: string;
|
||||
conversationId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a message to a Teams conversation or user.
|
||||
@@ -82,23 +84,225 @@ async function sendMSTeamsActivity(params: {
|
||||
* Uses the stored ConversationReference from previous interactions.
|
||||
* The bot must have received at least one message from the conversation
|
||||
* before proactive messaging works.
|
||||
*
|
||||
* File handling by conversation type:
|
||||
* - Personal (1:1) chats: small images (<4MB) use base64, large files and non-images use FileConsentCard
|
||||
* - Group chats / channels: files are uploaded to OneDrive and shared via link
|
||||
*/
|
||||
export async function sendMessageMSTeams(
|
||||
params: SendMSTeamsMessageParams,
|
||||
): Promise<SendMSTeamsMessageResult> {
|
||||
const { cfg, to, text, mediaUrl } = params;
|
||||
const { adapter, appId, conversationId, ref, log } = await resolveMSTeamsSendContext({
|
||||
cfg,
|
||||
to,
|
||||
});
|
||||
const ctx = await resolveMSTeamsSendContext({ cfg, to });
|
||||
const { adapter, appId, conversationId, ref, log, conversationType, tokenProvider, sharePointSiteId } = ctx;
|
||||
|
||||
log.debug("sending proactive message", {
|
||||
conversationId,
|
||||
conversationType,
|
||||
textLength: text.length,
|
||||
hasMedia: Boolean(mediaUrl),
|
||||
});
|
||||
|
||||
const message = mediaUrl ? (text ? `${text}\n\n${mediaUrl}` : mediaUrl) : text;
|
||||
// Handle media if present
|
||||
if (mediaUrl) {
|
||||
const mediaMaxBytes = resolveChannelMediaMaxBytes({
|
||||
cfg,
|
||||
resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
|
||||
}) ?? MSTEAMS_MAX_MEDIA_BYTES;
|
||||
const media = await loadWebMedia(mediaUrl, mediaMaxBytes);
|
||||
const isLargeFile = media.buffer.length >= FILE_CONSENT_THRESHOLD_BYTES;
|
||||
const isImage = media.contentType?.startsWith("image/") ?? false;
|
||||
const fallbackFileName = await extractFilename(mediaUrl);
|
||||
const fileName = media.fileName ?? fallbackFileName;
|
||||
|
||||
log.debug("processing media", {
|
||||
fileName,
|
||||
contentType: media.contentType,
|
||||
size: media.buffer.length,
|
||||
isLargeFile,
|
||||
isImage,
|
||||
conversationType,
|
||||
});
|
||||
|
||||
// Personal chats: base64 only works for images; use FileConsentCard for large files or non-images
|
||||
if (requiresFileConsent({
|
||||
conversationType,
|
||||
contentType: media.contentType,
|
||||
bufferSize: media.buffer.length,
|
||||
thresholdBytes: FILE_CONSENT_THRESHOLD_BYTES,
|
||||
})) {
|
||||
const { activity, uploadId } = prepareFileConsentActivity({
|
||||
media: { buffer: media.buffer, filename: fileName, contentType: media.contentType },
|
||||
conversationId,
|
||||
description: text || undefined,
|
||||
});
|
||||
|
||||
log.debug("sending file consent card", { uploadId, fileName, size: media.buffer.length });
|
||||
|
||||
const baseRef = buildConversationReference(ref);
|
||||
const proactiveRef = { ...baseRef, activityId: undefined };
|
||||
|
||||
let messageId = "unknown";
|
||||
try {
|
||||
await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => {
|
||||
const response = await turnCtx.sendActivity(activity);
|
||||
messageId = extractMessageId(response) ?? "unknown";
|
||||
});
|
||||
} catch (err) {
|
||||
const classification = classifyMSTeamsSendError(err);
|
||||
const hint = formatMSTeamsSendErrorHint(classification);
|
||||
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
|
||||
throw new Error(
|
||||
`msteams consent card send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
log.info("sent file consent card", { conversationId, messageId, uploadId });
|
||||
|
||||
return {
|
||||
messageId,
|
||||
conversationId,
|
||||
pendingUploadId: uploadId,
|
||||
};
|
||||
}
|
||||
|
||||
// Personal chat with small image: use base64 (only works for images)
|
||||
if (conversationType === "personal") {
|
||||
// Small image in personal chat: use base64 (only works for images)
|
||||
const base64 = media.buffer.toString("base64");
|
||||
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
|
||||
|
||||
return sendTextWithMedia(ctx, text, finalMediaUrl);
|
||||
}
|
||||
|
||||
if (isImage && !sharePointSiteId) {
|
||||
// Group chat/channel without SharePoint: send image inline (avoids OneDrive failures)
|
||||
const base64 = media.buffer.toString("base64");
|
||||
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
|
||||
return sendTextWithMedia(ctx, text, finalMediaUrl);
|
||||
}
|
||||
|
||||
// Group chat or channel: upload to SharePoint (if siteId configured) or OneDrive
|
||||
try {
|
||||
if (sharePointSiteId) {
|
||||
// Use SharePoint upload + Graph API for native file card
|
||||
log.debug("uploading to SharePoint for native file card", {
|
||||
fileName,
|
||||
conversationType,
|
||||
siteId: sharePointSiteId,
|
||||
});
|
||||
|
||||
const uploaded = await uploadAndShareSharePoint({
|
||||
buffer: media.buffer,
|
||||
filename: fileName,
|
||||
contentType: media.contentType,
|
||||
tokenProvider,
|
||||
siteId: sharePointSiteId,
|
||||
chatId: conversationId,
|
||||
usePerUserSharing: conversationType === "groupChat",
|
||||
});
|
||||
|
||||
log.debug("SharePoint upload complete", {
|
||||
itemId: uploaded.itemId,
|
||||
shareUrl: uploaded.shareUrl,
|
||||
});
|
||||
|
||||
// Get driveItem properties needed for native file card
|
||||
const driveItem = await getDriveItemProperties({
|
||||
siteId: sharePointSiteId,
|
||||
itemId: uploaded.itemId,
|
||||
tokenProvider,
|
||||
});
|
||||
|
||||
log.debug("driveItem properties retrieved", {
|
||||
eTag: driveItem.eTag,
|
||||
webDavUrl: driveItem.webDavUrl,
|
||||
});
|
||||
|
||||
// Build native Teams file card attachment and send via Bot Framework
|
||||
const fileCardAttachment = buildTeamsFileInfoCard(driveItem);
|
||||
const activity = {
|
||||
type: "message",
|
||||
text: text || undefined,
|
||||
attachments: [fileCardAttachment],
|
||||
};
|
||||
|
||||
const baseRef = buildConversationReference(ref);
|
||||
const proactiveRef = { ...baseRef, activityId: undefined };
|
||||
|
||||
let messageId = "unknown";
|
||||
await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => {
|
||||
const response = await turnCtx.sendActivity(activity);
|
||||
messageId = extractMessageId(response) ?? "unknown";
|
||||
});
|
||||
|
||||
log.info("sent native file card", {
|
||||
conversationId,
|
||||
messageId,
|
||||
fileName: driveItem.name,
|
||||
});
|
||||
|
||||
return { messageId, conversationId };
|
||||
}
|
||||
|
||||
// Fallback: no SharePoint site configured, use OneDrive with markdown link
|
||||
log.debug("uploading to OneDrive (no SharePoint site configured)", { fileName, conversationType });
|
||||
|
||||
const uploaded = await uploadAndShareOneDrive({
|
||||
buffer: media.buffer,
|
||||
filename: fileName,
|
||||
contentType: media.contentType,
|
||||
tokenProvider,
|
||||
});
|
||||
|
||||
log.debug("OneDrive upload complete", {
|
||||
itemId: uploaded.itemId,
|
||||
shareUrl: uploaded.shareUrl,
|
||||
});
|
||||
|
||||
// Send message with file link (Bot Framework doesn't support "reference" attachment type for sending)
|
||||
const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`;
|
||||
const activity = {
|
||||
type: "message",
|
||||
text: text ? `${text}\n\n${fileLink}` : fileLink,
|
||||
};
|
||||
|
||||
const baseRef = buildConversationReference(ref);
|
||||
const proactiveRef = { ...baseRef, activityId: undefined };
|
||||
|
||||
let messageId = "unknown";
|
||||
await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => {
|
||||
const response = await turnCtx.sendActivity(activity);
|
||||
messageId = extractMessageId(response) ?? "unknown";
|
||||
});
|
||||
|
||||
log.info("sent message with OneDrive file link", { conversationId, messageId, shareUrl: uploaded.shareUrl });
|
||||
|
||||
return { messageId, conversationId };
|
||||
} catch (err) {
|
||||
const classification = classifyMSTeamsSendError(err);
|
||||
const hint = formatMSTeamsSendErrorHint(classification);
|
||||
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
|
||||
throw new Error(
|
||||
`msteams file send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// No media: send text only
|
||||
return sendTextWithMedia(ctx, text, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a text message with optional base64 media URL.
|
||||
*/
|
||||
async function sendTextWithMedia(
|
||||
ctx: MSTeamsProactiveContext,
|
||||
text: string,
|
||||
mediaUrl: string | undefined,
|
||||
): Promise<SendMSTeamsMessageResult> {
|
||||
const { adapter, appId, conversationId, ref, log, tokenProvider, sharePointSiteId, mediaMaxBytes } = ctx;
|
||||
|
||||
let messageIds: string[];
|
||||
try {
|
||||
messageIds = await sendMSTeamsMessages({
|
||||
@@ -106,12 +310,14 @@ export async function sendMessageMSTeams(
|
||||
adapter,
|
||||
appId,
|
||||
conversationRef: ref,
|
||||
messages: [message],
|
||||
// Enable default retry/backoff for throttling/transient failures.
|
||||
messages: [{ text: text || undefined, mediaUrl }],
|
||||
retry: {},
|
||||
onRetry: (event) => {
|
||||
log.debug("retrying send", { conversationId, ...event });
|
||||
},
|
||||
tokenProvider,
|
||||
sharePointSiteId,
|
||||
mediaMaxBytes,
|
||||
});
|
||||
} catch (err) {
|
||||
const classification = classifyMSTeamsSendError(err);
|
||||
@@ -121,8 +327,8 @@ export async function sendMessageMSTeams(
|
||||
`msteams send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
|
||||
);
|
||||
}
|
||||
const messageId = messageIds[0] ?? "unknown";
|
||||
|
||||
const messageId = messageIds[0] ?? "unknown";
|
||||
log.info("sent proactive message", { conversationId, messageId });
|
||||
|
||||
return {
|
||||
@@ -157,7 +363,6 @@ export async function sendPollMSTeams(
|
||||
|
||||
const activity = {
|
||||
type: "message",
|
||||
text: pollCard.fallbackText,
|
||||
attachments: [
|
||||
{
|
||||
contentType: "application/vnd.microsoft.card.adaptive",
|
||||
@@ -166,13 +371,18 @@ export async function sendPollMSTeams(
|
||||
],
|
||||
};
|
||||
|
||||
let messageId: string;
|
||||
// Send poll via proactive conversation (Adaptive Cards require direct activity send)
|
||||
const baseRef = buildConversationReference(ref);
|
||||
const proactiveRef = {
|
||||
...baseRef,
|
||||
activityId: undefined,
|
||||
};
|
||||
|
||||
let messageId = "unknown";
|
||||
try {
|
||||
messageId = await sendMSTeamsActivity({
|
||||
adapter,
|
||||
appId,
|
||||
conversationRef: ref,
|
||||
activity,
|
||||
await adapter.continueConversation(appId, proactiveRef, async (ctx) => {
|
||||
const response = await ctx.sendActivity(activity);
|
||||
messageId = extractMessageId(response) ?? "unknown";
|
||||
});
|
||||
} catch (err) {
|
||||
const classification = classifyMSTeamsSendError(err);
|
||||
@@ -192,6 +402,64 @@ export async function sendPollMSTeams(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an arbitrary Adaptive Card to a Teams conversation or user.
|
||||
*/
|
||||
export async function sendAdaptiveCardMSTeams(
|
||||
params: SendMSTeamsCardParams,
|
||||
): Promise<SendMSTeamsCardResult> {
|
||||
const { cfg, to, card } = params;
|
||||
const { adapter, appId, conversationId, ref, log } = await resolveMSTeamsSendContext({
|
||||
cfg,
|
||||
to,
|
||||
});
|
||||
|
||||
log.debug("sending adaptive card", {
|
||||
conversationId,
|
||||
cardType: card.type,
|
||||
cardVersion: card.version,
|
||||
});
|
||||
|
||||
const activity = {
|
||||
type: "message",
|
||||
attachments: [
|
||||
{
|
||||
contentType: "application/vnd.microsoft.card.adaptive",
|
||||
content: card,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Send card via proactive conversation
|
||||
const baseRef = buildConversationReference(ref);
|
||||
const proactiveRef = {
|
||||
...baseRef,
|
||||
activityId: undefined,
|
||||
};
|
||||
|
||||
let messageId = "unknown";
|
||||
try {
|
||||
await adapter.continueConversation(appId, proactiveRef, async (ctx) => {
|
||||
const response = await ctx.sendActivity(activity);
|
||||
messageId = extractMessageId(response) ?? "unknown";
|
||||
});
|
||||
} catch (err) {
|
||||
const classification = classifyMSTeamsSendError(err);
|
||||
const hint = formatMSTeamsSendErrorHint(classification);
|
||||
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
|
||||
throw new Error(
|
||||
`msteams card send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
log.info("sent adaptive card", { conversationId, messageId });
|
||||
|
||||
return {
|
||||
messageId,
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all known conversation references (for debugging/CLI).
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user