diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c24e9e37..6d44b3343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.clawd.bot - Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky. - Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead). - Signal: add typing indicators and DM read receipts via signal-cli. +- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. ### Fixes - Config: avoid stack traces for invalid configs and log the config path. diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md index 4c9279941..3315153e6 100644 --- a/docs/channels/msteams.md +++ b/docs/channels/msteams.md @@ -8,9 +8,9 @@ read_when: > "Abandon all hope, ye who enter here." -Updated: 2026-01-16 +Updated: 2026-01-21 -Status: text + DM attachments are supported; channel/group attachments require Microsoft Graph permissions. Polls are sent via Adaptive Cards. +Status: text + DM attachments are supported; channel/group file sending requires `sharePointSiteId` + Graph permissions (see [Sending files in group chats](#sending-files-in-group-chats)). Polls are sent via Adaptive Cards. ## Plugin required Microsoft Teams ships as a plugin and is not bundled with the core install. @@ -403,7 +403,7 @@ Clawdbot handles this by returning quickly and sending replies proactively, but Teams markdown is more limited than Slack or Discord: - Basic formatting works: **bold**, *italic*, `code`, links - Complex markdown (tables, nested lists) may not render correctly -- Adaptive Cards are used for polls; other card types are not yet supported +- Adaptive Cards are supported for polls and arbitrary card sends (see below) ## Configuration Key settings (see `/gateway/configuration` for shared channel patterns): @@ -422,6 +422,7 @@ Key settings (see `/gateway/configuration` for shared channel patterns): - `channels.msteams.teams..requireMention`: per-team override. - `channels.msteams.teams..channels..replyStyle`: per-channel override. - `channels.msteams.teams..channels..requireMention`: per-channel override. +- `channels.msteams.sharePointSiteId`: SharePoint site ID for file uploads in group chats/channels (see [Sending files in group chats](#sending-files-in-group-chats)). ## Routing & Sessions - Session keys follow the standard agent format (see [/concepts/session](/concepts/session)): @@ -471,6 +472,75 @@ Teams recently introduced two channel UI styles over the same underlying data mo Without Graph permissions, channel messages with images will be received as text-only (the image content is not accessible to the bot). By default, Clawdbot only downloads media from Microsoft/Teams hostnames. Override with `channels.msteams.mediaAllowHosts` (use `["*"]` to allow any host). +## Sending files in group chats + +Bots can send files in DMs using the FileConsentCard flow (built-in). However, **sending files in group chats/channels** requires additional setup: + +| Context | How files are sent | Setup needed | +|---------|-------------------|--------------| +| **DMs** | FileConsentCard → user accepts → bot uploads | Works out of the box | +| **Group chats/channels** | Upload to SharePoint → share link | Requires `sharePointSiteId` + Graph permissions | +| **Images (any context)** | Base64-encoded inline | Works out of the box | + +### Why group chats need SharePoint + +Bots don't have a personal OneDrive drive (the `/me/drive` Graph API endpoint doesn't work for application identities). To send files in group chats/channels, the bot uploads to a **SharePoint site** and creates a sharing link. + +### Setup + +1. **Add Graph API permissions** in Entra ID (Azure AD) → App Registration: + - `Sites.ReadWrite.All` (Application) - upload files to SharePoint + - `Chat.Read.All` (Application) - optional, enables per-user sharing links + +2. **Grant admin consent** for the tenant. + +3. **Get your SharePoint site ID:** + ```bash + # Via Graph Explorer or curl with a valid token: + curl -H "Authorization: Bearer $TOKEN" \ + "https://graph.microsoft.com/v1.0/sites/{hostname}:/{site-path}" + + # Example: for a site at "contoso.sharepoint.com/sites/BotFiles" + curl -H "Authorization: Bearer $TOKEN" \ + "https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/BotFiles" + + # Response includes: "id": "contoso.sharepoint.com,guid1,guid2" + ``` + +4. **Configure Clawdbot:** + ```json5 + { + channels: { + msteams: { + // ... other config ... + sharePointSiteId: "contoso.sharepoint.com,guid1,guid2" + } + } + } + ``` + +### Sharing behavior + +| Permission | Sharing behavior | +|------------|------------------| +| `Sites.ReadWrite.All` only | Organization-wide sharing link (anyone in org can access) | +| `Sites.ReadWrite.All` + `Chat.Read.All` | Per-user sharing link (only chat members can access) | + +Per-user sharing is more secure as only the chat participants can access the file. If `Chat.Read.All` permission is missing, the bot falls back to organization-wide sharing. + +### Fallback behavior + +| Scenario | Result | +|----------|--------| +| Group chat + file + `sharePointSiteId` configured | Upload to SharePoint, send sharing link | +| Group chat + file + no `sharePointSiteId` | Attempt OneDrive upload (may fail), send text only | +| Personal chat + file | FileConsentCard flow (works without SharePoint) | +| Any context + image | Base64-encoded inline (works without SharePoint) | + +### Files stored location + +Uploaded files are stored in a `/ClawdbotShared/` folder in the configured SharePoint site's default document library. + ## Polls (Adaptive Cards) Clawdbot sends Teams polls as Adaptive Cards (there is no native Teams poll API). @@ -479,6 +549,82 @@ Clawdbot sends Teams polls as Adaptive Cards (there is no native Teams poll API) - The gateway must stay online to record votes. - Polls do not auto-post result summaries yet (inspect the store file if needed). +## Adaptive Cards (arbitrary) +Send any Adaptive Card JSON to Teams users or conversations using the `message` tool or CLI. + +The `card` parameter accepts an Adaptive Card JSON object. When `card` is provided, the message text is optional. + +**Agent tool:** +```json +{ + "action": "send", + "channel": "msteams", + "target": "user:", + "card": { + "type": "AdaptiveCard", + "version": "1.5", + "body": [{"type": "TextBlock", "text": "Hello!"}] + } +} +``` + +**CLI:** +```bash +clawdbot message send --channel msteams \ + --target "conversation:19:abc...@thread.tacv2" \ + --card '{"type":"AdaptiveCard","version":"1.5","body":[{"type":"TextBlock","text":"Hello!"}]}' +``` + +See [Adaptive Cards documentation](https://adaptivecards.io/) for card schema and examples. For target format details, see [Target formats](#target-formats) below. + +## Target formats + +MSTeams targets use prefixes to distinguish between users and conversations: + +| Target type | Format | Example | +|-------------|--------|---------| +| User (by ID) | `user:` | `user:40a1a0ed-4ff2-4164-a219-55518990c197` | +| User (by name) | `user:` | `user:John Smith` (requires Graph API) | +| Group/channel | `conversation:` | `conversation:19:abc123...@thread.tacv2` | +| Group/channel (raw) | `` | `19:abc123...@thread.tacv2` (if contains `@thread`) | + +**CLI examples:** +```bash +# Send to a user by ID +clawdbot message send --channel msteams --target "user:40a1a0ed-..." --message "Hello" + +# Send to a user by display name (triggers Graph API lookup) +clawdbot message send --channel msteams --target "user:John Smith" --message "Hello" + +# Send to a group chat or channel +clawdbot message send --channel msteams --target "conversation:19:abc...@thread.tacv2" --message "Hello" + +# Send an Adaptive Card to a conversation +clawdbot message send --channel msteams --target "conversation:19:abc...@thread.tacv2" \ + --card '{"type":"AdaptiveCard","version":"1.5","body":[{"type":"TextBlock","text":"Hello"}]}' +``` + +**Agent tool examples:** +```json +{ + "action": "send", + "channel": "msteams", + "target": "user:John Smith", + "message": "Hello!" +} +``` + +```json +{ + "action": "send", + "channel": "msteams", + "target": "conversation:19:abc...@thread.tacv2", + "card": {"type": "AdaptiveCard", "version": "1.5", "body": [{"type": "TextBlock", "text": "Hello"}]} +} +``` + +Note: Without the `user:` prefix, names default to group/team resolution. Always use `user:` when targeting people by display name. + ## Proactive messaging - Proactive messages are only possible **after** a user has interacted, because we store conversation references at that point. - See `/gateway/configuration` for `dmPolicy` and allowlist gating. diff --git a/docs/tools/index.md b/docs/tools/index.md index 984957c34..9731b4f7d 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -322,7 +322,7 @@ Notes: Send messages and channel actions across Discord/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams. Core actions: -- `send` (text + optional media) +- `send` (text + optional media; MS Teams also supports `card` for Adaptive Cards) - `poll` (WhatsApp/Discord/MS Teams polls) - `react` / `reactions` / `read` / `edit` / `delete` - `pin` / `unpin` / `list-pins` diff --git a/extensions/msteams/src/attachments.test.ts b/extensions/msteams/src/attachments.test.ts index f12787720..a86a476c5 100644 --- a/extensions/msteams/src/attachments.test.ts +++ b/extensions/msteams/src/attachments.test.ts @@ -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(""); + }); + 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", () => { diff --git a/extensions/msteams/src/attachments.ts b/extensions/msteams/src/attachments.ts index 867869c5e..d29a3ef31 100644 --- a/extensions/msteams/src/attachments.ts +++ b/extensions/msteams/src/attachments.ts @@ -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, diff --git a/extensions/msteams/src/attachments/download.ts b/extensions/msteams/src/attachments/download.ts index 7b2c6bf9d..0a44c50d6 100644 --- a/extensions/msteams/src/attachments/download.ts +++ b/extensions/msteams/src/attachments/download.ts @@ -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 { 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(); 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; diff --git a/extensions/msteams/src/attachments/graph.ts b/extensions/msteams/src/attachments/graph.ts index 6050eeebb..bb47d413f 100644 --- a/extensions/msteams/src/attachments/graph.ts +++ b/extensions/msteams/src/attachments/graph.ts @@ -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({ 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: "", + 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 { 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(); + 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({ @@ -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, diff --git a/extensions/msteams/src/attachments/shared.ts b/extensions/msteams/src/attachments/shared.ts index d8c5968b8..8d290d87e 100644 --- a/extensions/msteams/src/attachments/shared.ts +++ b/extensions/msteams/src/attachments/shared.ts @@ -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"); diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index de3e0a8df..521916e34 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -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 = { 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 = { 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: "", @@ -320,6 +338,50 @@ export const msteamsPlugin: ChannelPlugin = { 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; + 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: { diff --git a/extensions/msteams/src/file-consent-helpers.test.ts b/extensions/msteams/src/file-consent-helpers.test.ts new file mode 100644 index 000000000..aa5adc152 --- /dev/null +++ b/extensions/msteams/src/file-consent-helpers.test.ts @@ -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; + 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; + 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; + 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; + 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"); + }); +}); diff --git a/extensions/msteams/src/file-consent-helpers.ts b/extensions/msteams/src/file-consent-helpers.ts new file mode 100644 index 000000000..e8a1bc0c6 --- /dev/null +++ b/extensions/msteams/src/file-consent-helpers.ts @@ -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; + 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 = { + 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); +} diff --git a/extensions/msteams/src/file-consent.ts b/extensions/msteams/src/file-consent.ts new file mode 100644 index 000000000..02183e7a2 --- /dev/null +++ b/extensions/msteams/src/file-consent.ts @@ -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; +} + +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; +} + +/** + * 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; + }; + + 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 { + 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}`); + } +} diff --git a/extensions/msteams/src/graph-chat.ts b/extensions/msteams/src/graph-chat.ts new file mode 100644 index 000000000..ede44725e --- /dev/null +++ b/extensions/msteams/src/graph-chat.ts @@ -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, + }, + }; +} diff --git a/extensions/msteams/src/graph-upload.ts b/extensions/msteams/src/graph-upload.ts new file mode 100644 index 000000000..dd4e28683 --- /dev/null +++ b/extensions/msteams/src/graph-upload.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = { + 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, + }; +} diff --git a/extensions/msteams/src/media-helpers.test.ts b/extensions/msteams/src/media-helpers.test.ts new file mode 100644 index 000000000..09979dfe4 --- /dev/null +++ b/extensions/msteams/src/media-helpers.test.ts @@ -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(); + }); + }); +}); diff --git a/extensions/msteams/src/media-helpers.ts b/extensions/msteams/src/media-helpers.ts new file mode 100644 index 000000000..bf4a6265b --- /dev/null +++ b/extensions/msteams/src/media-helpers.ts @@ -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 { + // 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 { + // 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; +} diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index 5be0fbf2b..04d1f55e1 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -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 }, }); diff --git a/extensions/msteams/src/messenger.ts b/extensions/msteams/src/messenger.ts index 573bb33bc..d6a0b9963 100644 --- a/extensions/msteams/src/messenger.ts +++ b/extensions/msteams/src/messenger.ts @@ -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; }; @@ -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 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> { + const activity: Record = { 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 { - 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"); diff --git a/extensions/msteams/src/monitor-handler.ts b/extensions/msteams/src/monitor-handler.ts index a862729f0..4a023eba7 100644 --- a/extensions/msteams/src/monitor-handler.ts +++ b/extensions/msteams/src/monitor-handler.ts @@ -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) => Promise, ) => MSTeamsActivityHandler; + run?: (context: unknown) => Promise; }; 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 { + 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( 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); diff --git a/extensions/msteams/src/monitor-handler/inbound-media.ts b/extensions/msteams/src/monitor-handler/inbound-media.ts index 9eae27e07..03398b5a0 100644 --- a/extensions/msteams/src/monitor-handler/inbound-media.ts +++ b/extensions/msteams/src/monitor-handler/inbound-media.ts @@ -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; log: MSTeamsLogger; + /** When true, embeds original filename in stored path for later extraction. */ + preserveFilenames?: boolean; }): Promise { 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, diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index ee48b82ca..1a0129180 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -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 }); diff --git a/extensions/msteams/src/pending-uploads.ts b/extensions/msteams/src/pending-uploads.ts new file mode 100644 index 000000000..3c5e4a42b --- /dev/null +++ b/extensions/msteams/src/pending-uploads.ts @@ -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(); + +/** 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, +): 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(); +} diff --git a/extensions/msteams/src/probe.ts b/extensions/msteams/src/probe.ts index 835be6587..2d6dd7429 100644 --- a/extensions/msteams/src/probe.ts +++ b/extensions/msteams/src/probe.ts @@ -76,7 +76,7 @@ export async function probeMSTeams(cfg?: MSTeamsConfig): Promise 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); }, diff --git a/extensions/msteams/src/send-context.ts b/extensions/msteams/src/send-context.ts index 685d356fc..c0f013a9e 100644 --- a/extensions/msteams/src/send-context.ts +++ b/extensions/msteams/src/send-context.ts @@ -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 | undefined; -const getLog = async (): Promise> => { - 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>; + log: ReturnType; + /** 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, }; } diff --git a/extensions/msteams/src/send.ts b/extensions/msteams/src/send.ts index cffba2b6d..83d0cf149 100644 --- a/extensions/msteams/src/send.ts +++ b/extensions/msteams/src/send.ts @@ -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; +}; -async function sendMSTeamsActivity(params: { - adapter: MSTeamsAdapter; - appId: string; - conversationRef: StoredConversationReference; - activity: Record; -}): Promise { - 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 { 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 { + 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 { + 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). */ diff --git a/src/agents/channel-tools.ts b/src/agents/channel-tools.ts index 486e27307..6944e7a26 100644 --- a/src/agents/channel-tools.ts +++ b/src/agents/channel-tools.ts @@ -1,4 +1,6 @@ +import { getChannelDock } from "../channels/dock.js"; import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; +import { normalizeAnyChannelId } from "../channels/registry.js"; import type { ChannelAgentTool, ChannelMessageActionName } from "../channels/plugins/types.js"; import type { ClawdbotConfig } from "../config/config.js"; @@ -46,3 +48,19 @@ export function listChannelAgentTools(params: { cfg?: ClawdbotConfig }): Channel } return tools; } + +export function resolveChannelMessageToolHints(params: { + cfg?: ClawdbotConfig; + channel?: string | null; + accountId?: string | null; +}): string[] { + const channelId = normalizeAnyChannelId(params.channel); + if (!channelId) return []; + const dock = getChannelDock(channelId); + const resolve = dock?.agentPrompt?.messageToolHints; + if (!resolve) return []; + const cfg = params.cfg ?? ({} as ClawdbotConfig); + return (resolve({ cfg, accountId: params.accountId }) ?? []) + .map((entry) => entry.trim()) + .filter(Boolean); +} diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index a3508e5b2..2aad2431a 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -5,7 +5,7 @@ import { createAgentSession, SessionManager, SettingsManager } from "@mariozechn import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js"; import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js"; -import { listChannelSupportedActions } from "../channel-tools.js"; +import { listChannelSupportedActions, resolveChannelMessageToolHints } from "../channel-tools.js"; import { resolveChannelCapabilities } from "../../config/channel-capabilities.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { getMachineDisplayName } from "../../infra/machine-name.js"; @@ -245,6 +245,13 @@ export async function compactEmbeddedPiSession(params: { channel: runtimeChannel, }) : undefined; + const messageToolHints = runtimeChannel + ? resolveChannelMessageToolHints({ + cfg: params.config, + channel: runtimeChannel, + accountId: params.agentAccountId, + }) + : undefined; const runtimeInfo = { host: machineName, @@ -287,6 +294,7 @@ export async function compactEmbeddedPiSession(params: { docsPath: docsPath ?? undefined, promptMode, runtimeInfo, + messageToolHints, sandboxInfo, tools, modelAliasLines: buildModelAliasLines(params.config), diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index e2e3a39dd..19450226c 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -7,7 +7,10 @@ import { streamSimple } from "@mariozechner/pi-ai"; import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent"; import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js"; -import { listChannelSupportedActions } from "../../channel-tools.js"; +import { + listChannelSupportedActions, + resolveChannelMessageToolHints, +} from "../../channel-tools.js"; import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js"; import { getMachineDisplayName } from "../../../infra/machine-name.js"; import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js"; @@ -260,6 +263,13 @@ export async function runEmbeddedAttempt( channel: runtimeChannel, }) : undefined; + const messageToolHints = runtimeChannel + ? resolveChannelMessageToolHints({ + cfg: params.config, + channel: runtimeChannel, + accountId: params.agentAccountId, + }) + : undefined; const defaultModelRef = resolveDefaultModelForAgent({ cfg: params.config ?? {}, @@ -305,6 +315,7 @@ export async function runEmbeddedAttempt( reactionGuidance, promptMode, runtimeInfo, + messageToolHints, sandboxInfo, tools, modelAliasLines: buildModelAliasLines(params.config), diff --git a/src/agents/pi-embedded-runner/system-prompt.ts b/src/agents/pi-embedded-runner/system-prompt.ts index 18ae2438d..196458df9 100644 --- a/src/agents/pi-embedded-runner/system-prompt.ts +++ b/src/agents/pi-embedded-runner/system-prompt.ts @@ -35,6 +35,7 @@ export function buildEmbeddedSystemPrompt(params: { /** Supported message actions for the current channel (e.g., react, edit, unsend) */ channelActions?: string[]; }; + messageToolHints?: string[]; sandboxInfo?: EmbeddedSandboxInfo; tools: AgentTool[]; modelAliasLines: string[]; @@ -56,6 +57,7 @@ export function buildEmbeddedSystemPrompt(params: { reactionGuidance: params.reactionGuidance, promptMode: params.promptMode, runtimeInfo: params.runtimeInfo, + messageToolHints: params.messageToolHints, sandboxInfo: params.sandboxInfo, toolNames: params.tools.map((tool) => tool.name), toolSummaries: buildToolSummaryMap(params.tools), diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 889ae84f4..af5eec98f 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -85,6 +85,7 @@ function buildMessagingSection(params: { messageChannelOptions: string; inlineButtonsEnabled: boolean; runtimeChannel?: string; + messageToolHints?: string[]; }) { if (params.isMinimal) return []; return [ @@ -105,6 +106,7 @@ function buildMessagingSection(params: { : params.runtimeChannel ? `- Inline buttons not enabled for ${params.runtimeChannel}. If you need them, ask to set ${params.runtimeChannel}.capabilities.inlineButtons ("dm"|"group"|"all"|"allowlist").` : "", + ...(params.messageToolHints ?? []), ] .filter(Boolean) .join("\n") @@ -159,6 +161,7 @@ export function buildAgentSystemPrompt(params: { channel?: string; capabilities?: string[]; }; + messageToolHints?: string[]; sandboxInfo?: { enabled: boolean; workspaceDir?: string; @@ -468,6 +471,7 @@ export function buildAgentSystemPrompt(params: { messageChannelOptions, inlineButtonsEnabled, runtimeChannel, + messageToolHints: params.messageToolHints, }), ]; diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 6f1116053..657284b52 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox"; import { listChannelMessageActions, supportsChannelMessageButtons, + supportsChannelMessageCards, } from "../../channels/plugins/message-actions.js"; import { CHANNEL_MESSAGE_ACTION_NAMES, @@ -36,7 +37,7 @@ function buildRoutingSchema() { }; } -function buildSendSchema(options: { includeButtons: boolean }) { +function buildSendSchema(options: { includeButtons: boolean; includeCards: boolean }) { const props: Record = { message: Type.Optional(Type.String()), effectId: Type.Optional( @@ -77,8 +78,18 @@ function buildSendSchema(options: { includeButtons: boolean }) { }, ), ), + card: Type.Optional( + Type.Object( + {}, + { + additionalProperties: true, + description: "Adaptive Card JSON object (when supported by the channel)", + }, + ), + ), }; if (!options.includeButtons) delete props.buttons; + if (!options.includeCards) delete props.card; return props; } @@ -192,7 +203,7 @@ function buildChannelManagementSchema() { }; } -function buildMessageToolSchemaProps(options: { includeButtons: boolean }) { +function buildMessageToolSchemaProps(options: { includeButtons: boolean; includeCards: boolean }) { return { ...buildRoutingSchema(), ...buildSendSchema(options), @@ -211,7 +222,7 @@ function buildMessageToolSchemaProps(options: { includeButtons: boolean }) { function buildMessageToolSchemaFromActions( actions: readonly string[], - options: { includeButtons: boolean }, + options: { includeButtons: boolean; includeCards: boolean }, ) { const props = buildMessageToolSchemaProps(options); return Type.Object({ @@ -222,6 +233,7 @@ function buildMessageToolSchemaFromActions( const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, { includeButtons: true, + includeCards: true, }); type MessageToolOptions = { @@ -238,8 +250,10 @@ type MessageToolOptions = { function buildMessageToolSchema(cfg: ClawdbotConfig) { const actions = listChannelMessageActions(cfg); const includeButtons = supportsChannelMessageButtons(cfg); + const includeCards = supportsChannelMessageCards(cfg); return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], { includeButtons, + includeCards, }); } diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index 0481ce74e..dbde7c3d2 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -1,7 +1,7 @@ import type { NormalizedUsage } from "../../agents/usage.js"; import { getChannelDock } from "../../channels/dock.js"; import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js"; -import { normalizeChannelId } from "../../channels/registry.js"; +import { normalizeAnyChannelId, normalizeChannelId } from "../../channels/registry.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { estimateUsageCost, formatTokenCount, formatUsd } from "../../utils/usage-format.js"; @@ -23,7 +23,7 @@ export function buildThreadingToolContext(params: { if (!config) return {}; const rawProvider = sessionCtx.Provider?.trim().toLowerCase(); if (!rawProvider) return {}; - const provider = normalizeChannelId(rawProvider); + const provider = normalizeChannelId(rawProvider) ?? normalizeAnyChannelId(rawProvider); // Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init) const dock = provider ? getChannelDock(provider) : undefined; if (!dock?.threading?.buildToolContext) { diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 45bca496f..81b07c36a 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -22,6 +22,7 @@ import type { ChannelElevatedAdapter, ChannelGroupAdapter, ChannelId, + ChannelAgentPromptAdapter, ChannelMentionAdapter, ChannelPlugin, ChannelThreadingAdapter, @@ -51,6 +52,7 @@ export type ChannelDock = { groups?: ChannelGroupAdapter; mentions?: ChannelMentionAdapter; threading?: ChannelThreadingAdapter; + agentPrompt?: ChannelAgentPromptAdapter; }; type ChannelDockStreaming = { @@ -319,6 +321,7 @@ function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock { groups: plugin.groups, mentions: plugin.mentions, threading: plugin.threading, + agentPrompt: plugin.agentPrompt, }; } diff --git a/src/channels/plugins/message-actions.ts b/src/channels/plugins/message-actions.ts index 59c712928..076e32a5e 100644 --- a/src/channels/plugins/message-actions.ts +++ b/src/channels/plugins/message-actions.ts @@ -21,6 +21,13 @@ export function supportsChannelMessageButtons(cfg: ClawdbotConfig): boolean { return false; } +export function supportsChannelMessageCards(cfg: ClawdbotConfig): boolean { + for (const plugin of listChannelPlugins()) { + if (plugin.actions?.supportsCards?.({ cfg })) return true; + } + return false; +} + export async function dispatchChannelMessageAction( ctx: ChannelMessageActionContext, ): Promise | null> { diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index af3707712..4e526ec6b 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -240,6 +240,10 @@ export type ChannelMessagingAdapter = { }) => string; }; +export type ChannelAgentPromptAdapter = { + messageToolHints?: (params: { cfg: ClawdbotConfig; accountId?: string | null }) => string[]; +}; + export type ChannelDirectoryEntryKind = "user" | "group" | "channel"; export type ChannelDirectoryEntry = { @@ -281,6 +285,7 @@ export type ChannelMessageActionAdapter = { listActions?: (params: { cfg: ClawdbotConfig }) => ChannelMessageActionName[]; supportsAction?: (params: { action: ChannelMessageActionName }) => boolean; supportsButtons?: (params: { cfg: ClawdbotConfig }) => boolean; + supportsCards?: (params: { cfg: ClawdbotConfig }) => boolean; extractToolSend?: (params: { args: Record }) => ChannelToolSend | null; handleAction?: (ctx: ChannelMessageActionContext) => Promise>; }; diff --git a/src/channels/plugins/types.plugin.ts b/src/channels/plugins/types.plugin.ts index 5aeab17d3..925502376 100644 --- a/src/channels/plugins/types.plugin.ts +++ b/src/channels/plugins/types.plugin.ts @@ -20,6 +20,7 @@ import type { ChannelAgentToolFactory, ChannelCapabilities, ChannelId, + ChannelAgentPromptAdapter, ChannelMentionAdapter, ChannelMessageActionAdapter, ChannelMessagingAdapter, @@ -73,6 +74,7 @@ export type ChannelPlugin = { streaming?: ChannelStreamingAdapter; threading?: ChannelThreadingAdapter; messaging?: ChannelMessagingAdapter; + agentPrompt?: ChannelAgentPromptAdapter; directory?: ChannelDirectoryAdapter; resolver?: ChannelResolverAdapter; actions?: ChannelMessageActionAdapter; diff --git a/src/channels/plugins/types.ts b/src/channels/plugins/types.ts index ef1c4e20d..d7175f176 100644 --- a/src/channels/plugins/types.ts +++ b/src/channels/plugins/types.ts @@ -31,6 +31,7 @@ export type { export type { ChannelAccountSnapshot, ChannelAccountState, + ChannelAgentPromptAdapter, ChannelAgentTool, ChannelAgentToolFactory, ChannelCapabilities, diff --git a/src/cli/program/message/helpers.ts b/src/cli/program/message/helpers.ts index a2cfdc419..2173bb054 100644 --- a/src/cli/program/message/helpers.ts +++ b/src/cli/program/message/helpers.ts @@ -5,6 +5,7 @@ import { CHANNEL_TARGET_DESCRIPTION } from "../../../infra/outbound/channel-targ import { defaultRuntime } from "../../../runtime.js"; import { createDefaultDeps } from "../../deps.js"; import { runCommandWithRuntime } from "../../cli-utils.js"; +import { ensurePluginRegistryLoaded } from "../../plugin-registry.js"; export type MessageCliHelpers = { withMessageBase: (command: Command) => Command; @@ -32,6 +33,7 @@ export function createMessageCliHelpers( const runMessageAction = async (action: string, opts: Record) => { setVerbose(Boolean(opts.verbose)); + ensurePluginRegistryLoaded(); const deps = createDefaultDeps(); await runCommandWithRuntime( defaultRuntime, diff --git a/src/cli/program/message/register.send.ts b/src/cli/program/message/register.send.ts index 014c7395b..8841c3ce8 100644 --- a/src/cli/program/message/register.send.ts +++ b/src/cli/program/message/register.send.ts @@ -19,6 +19,7 @@ export function registerMessageSendCommand(message: Command, helpers: MessageCli "--buttons ", "Telegram inline keyboard buttons as JSON (array of button rows)", ) + .option("--card ", "Adaptive Card JSON object (when supported by the channel)") .option("--reply-to ", "Reply-to message id") .option("--thread-id ", "Thread id (Telegram forum thread)") .option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false), diff --git a/src/config/types.msteams.ts b/src/config/types.msteams.ts index e443225f3..f18dccb14 100644 --- a/src/config/types.msteams.ts +++ b/src/config/types.msteams.ts @@ -78,4 +78,8 @@ export type MSTeamsConfig = { replyStyle?: MSTeamsReplyStyle; /** Per-team config. Key is team ID (from the /team/ URL path segment). */ teams?: Record; + /** Max media size in MB (default: 100MB for OneDrive upload support). */ + mediaMaxMb?: number; + /** SharePoint site ID for file uploads in group chats/channels (e.g., "contoso.sharepoint.com,guid1,guid2"). */ + sharePointSiteId?: string; }; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 906ef5433..68806c61f 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -599,6 +599,10 @@ export const MSTeamsConfigSchema = z dms: z.record(z.string(), DmConfigSchema.optional()).optional(), replyStyle: MSTeamsReplyStyleSchema.optional(), teams: z.record(z.string(), MSTeamsTeamSchema.optional()).optional(), + /** Max media size in MB (default: 100MB for OneDrive upload support). */ + mediaMaxMb: z.number().positive().optional(), + /** SharePoint site ID for file uploads in group chats/channels (e.g., "contoso.sharepoint.com,guid1,guid2") */ + sharePointSiteId: z.string().optional(), }) .strict() .superRefine((value, ctx) => { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index c9754934a..d4d9bdd8d 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -191,6 +191,12 @@ export const ClawdbotSchema = z bindings: BindingsSchema, broadcast: BroadcastSchema, audio: AudioSchema, + media: z + .object({ + preserveFilenames: z.boolean().optional(), + }) + .strict() + .optional(), messages: MessagesSchema, commands: CommandsSchema, session: SessionSchema, diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index e307703be..dc8aeddf3 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -410,6 +410,21 @@ function parseButtonsParam(params: Record): void { } } +function parseCardParam(params: Record): void { + const raw = params.card; + if (typeof raw !== "string") return; + const trimmed = raw.trim(); + if (!trimmed) { + delete params.card; + return; + } + try { + params.card = JSON.parse(trimmed) as unknown; + } catch { + throw new Error("--card must be valid JSON"); + } +} + async function resolveChannel(cfg: ClawdbotConfig, params: Record) { const channelHint = readStringParam(params, "channel"); const selection = await resolveMessageChannelSelection({ @@ -558,10 +573,15 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise = { "text/markdown": ".md", }; -const MIME_BY_EXT: Record = Object.fromEntries( - Object.entries(EXT_BY_MIME).map(([mime, ext]) => [ext, mime]), -); +const MIME_BY_EXT: Record = { + ...Object.fromEntries(Object.entries(EXT_BY_MIME).map(([mime, ext]) => [ext, mime])), + // Additional extension aliases + ".jpeg": "image/jpeg", +}; const AUDIO_FILE_EXTENSIONS = new Set([ ".aac", diff --git a/src/media/store.test.ts b/src/media/store.test.ts index ecf633a39..e89681031 100644 --- a/src/media/store.test.ts +++ b/src/media/store.test.ts @@ -161,4 +161,114 @@ describe("media store", () => { expect(path.extname(saved.path)).toBe(".xlsx"); }); }); + + describe("extractOriginalFilename", () => { + it("extracts original filename from embedded pattern", async () => { + await withTempStore(async (store) => { + // Pattern: {original}---{uuid}.{ext} + const filename = "report---a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf"; + const result = store.extractOriginalFilename(`/path/to/${filename}`); + expect(result).toBe("report.pdf"); + }); + }); + + it("handles uppercase UUID pattern", async () => { + await withTempStore(async (store) => { + const filename = "Document---A1B2C3D4-E5F6-7890-ABCD-EF1234567890.docx"; + const result = store.extractOriginalFilename(`/media/inbound/${filename}`); + expect(result).toBe("Document.docx"); + }); + }); + + it("falls back to basename for non-matching patterns", async () => { + await withTempStore(async (store) => { + // UUID-only filename (legacy format) + const uuidOnly = "a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf"; + expect(store.extractOriginalFilename(`/path/${uuidOnly}`)).toBe(uuidOnly); + + // Regular filename without embedded pattern + expect(store.extractOriginalFilename("/path/to/regular.txt")).toBe("regular.txt"); + + // Filename with --- but invalid UUID part + expect(store.extractOriginalFilename("/path/to/foo---bar.txt")).toBe("foo---bar.txt"); + }); + }); + + it("preserves original name with special characters", async () => { + await withTempStore(async (store) => { + const filename = "报告_2024---a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf"; + const result = store.extractOriginalFilename(`/media/${filename}`); + expect(result).toBe("报告_2024.pdf"); + }); + }); + }); + + describe("saveMediaBuffer with originalFilename", () => { + it("embeds original filename in stored path when provided", async () => { + await withTempStore(async (store) => { + const buf = Buffer.from("test content"); + const saved = await store.saveMediaBuffer( + buf, + "text/plain", + "inbound", + 5 * 1024 * 1024, + "report.txt", + ); + + // Should contain the original name and a UUID pattern + expect(saved.id).toMatch(/^report---[a-f0-9-]{36}\.txt$/); + expect(saved.path).toContain("report---"); + + // Should be able to extract original name + const extracted = store.extractOriginalFilename(saved.path); + expect(extracted).toBe("report.txt"); + }); + }); + + it("sanitizes unsafe characters in original filename", async () => { + await withTempStore(async (store) => { + const buf = Buffer.from("test"); + // Filename with unsafe chars: < > : " / \ | ? * + const saved = await store.saveMediaBuffer( + buf, + "text/plain", + "inbound", + 5 * 1024 * 1024, + "my:test.txt", + ); + + // Unsafe chars should be replaced with underscores + expect(saved.id).toMatch(/^my_file_test---[a-f0-9-]{36}\.txt$/); + }); + }); + + it("truncates long original filenames", async () => { + await withTempStore(async (store) => { + const buf = Buffer.from("test"); + const longName = "a".repeat(100) + ".txt"; + const saved = await store.saveMediaBuffer( + buf, + "text/plain", + "inbound", + 5 * 1024 * 1024, + longName, + ); + + // Original name should be truncated to 60 chars + const baseName = path.parse(saved.id).name.split("---")[0]; + expect(baseName.length).toBeLessThanOrEqual(60); + }); + }); + + it("falls back to UUID-only when originalFilename not provided", async () => { + await withTempStore(async (store) => { + const buf = Buffer.from("test"); + const saved = await store.saveMediaBuffer(buf, "text/plain", "inbound"); + + // Should be UUID-only pattern (legacy behavior) + expect(saved.id).toMatch(/^[a-f0-9-]{36}\.txt$/); + expect(saved.id).not.toContain("---"); + }); + }); + }); }); diff --git a/src/media/store.ts b/src/media/store.ts index d1781966e..cd6c92411 100644 --- a/src/media/store.ts +++ b/src/media/store.ts @@ -11,6 +11,43 @@ const resolveMediaDir = () => path.join(resolveConfigDir(), "media"); const MAX_BYTES = 5 * 1024 * 1024; // 5MB default const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes +/** + * Sanitize a filename for cross-platform safety. + * Removes chars unsafe on Windows/SharePoint/all platforms. + * Keeps: alphanumeric, dots, hyphens, underscores, Unicode letters/numbers. + */ +function sanitizeFilename(name: string): string { + // Remove: < > : " / \ | ? * and control chars (U+0000-U+001F) + // oxlint-disable-next-line no-control-regex -- Intentionally matching control chars + const unsafe = /[<>:"/\\|?*\x00-\x1f]/g; + const sanitized = name.trim().replace(unsafe, "_").replace(/\s+/g, "_"); // Replace whitespace runs with underscore + // Collapse multiple underscores, trim leading/trailing, limit length + return sanitized.replace(/_+/g, "_").replace(/^_|_$/g, "").slice(0, 60); +} + +/** + * Extract original filename from path if it matches the embedded format. + * Pattern: {original}---{uuid}.{ext} → returns "{original}.{ext}" + * Falls back to basename if no pattern match, or "file.bin" if empty. + */ +export function extractOriginalFilename(filePath: string): string { + const basename = path.basename(filePath); + if (!basename) return "file.bin"; // Fallback for empty input + + const ext = path.extname(basename); + const nameWithoutExt = path.basename(basename, ext); + + // Check for ---{uuid} pattern (36 chars: 8-4-4-4-12 with hyphens) + const match = nameWithoutExt.match( + /^(.+)---[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i, + ); + if (match?.[1]) { + return `${match[1]}${ext}`; + } + + return basename; // Fallback: use as-is +} + export function getMediaDir() { return resolveMediaDir(); } @@ -152,17 +189,29 @@ export async function saveMediaBuffer( contentType?: string, subdir = "inbound", maxBytes = MAX_BYTES, + originalFilename?: string, ): Promise { if (buffer.byteLength > maxBytes) { throw new Error(`Media exceeds ${(maxBytes / (1024 * 1024)).toFixed(0)}MB limit`); } const dir = path.join(resolveMediaDir(), subdir); await fs.mkdir(dir, { recursive: true }); - const baseId = crypto.randomUUID(); + const uuid = crypto.randomUUID(); const headerExt = extensionForMime(contentType?.split(";")[0]?.trim() ?? undefined); const mime = await detectMime({ buffer, headerMime: contentType }); - const ext = headerExt ?? extensionForMime(mime); - const id = ext ? `${baseId}${ext}` : baseId; + const ext = headerExt ?? extensionForMime(mime) ?? ""; + + let id: string; + if (originalFilename) { + // Embed original name: {sanitized}---{uuid}.ext + const base = path.parse(originalFilename).name; + const sanitized = sanitizeFilename(base); + id = sanitized ? `${sanitized}---${uuid}${ext}` : `${uuid}${ext}`; + } else { + // Legacy: just UUID + id = ext ? `${uuid}${ext}` : uuid; + } + const dest = path.join(dir, id); await fs.writeFile(dest, buffer); return { id, path: dest, size: buffer.byteLength, contentType: mime }; diff --git a/src/plugin-sdk/index.test.ts b/src/plugin-sdk/index.test.ts index b0e713287..920e2af2a 100644 --- a/src/plugin-sdk/index.test.ts +++ b/src/plugin-sdk/index.test.ts @@ -19,7 +19,6 @@ describe("plugin-sdk exports", () => { "writeConfigFile", "runCommandWithTimeout", "enqueueSystemEvent", - "detectMime", "fetchRemoteMedia", "saveMediaBuffer", "formatAgentEnvelope", diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 1afc00aa4..1da3650fe 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -206,6 +206,8 @@ export type { DiagnosticWebhookProcessedEvent, DiagnosticWebhookReceivedEvent, } from "../infra/diagnostic-events.js"; +export { detectMime, extensionForMime, getFileExtension } from "../media/mime.js"; +export { extractOriginalFilename } from "../media/store.js"; // Channel: Discord export { @@ -282,3 +284,6 @@ export { collectWhatsAppStatusIssues } from "../channels/plugins/status-issues/w // Channel: BlueBubbles export { collectBlueBubblesStatusIssues } from "../channels/plugins/status-issues/bluebubbles.js"; + +// Media utilities +export { loadWebMedia, type WebMediaResult } from "../web/media.js"; diff --git a/src/web/media.ts b/src/web/media.ts index 3f9468b63..509693732 100644 --- a/src/web/media.ts +++ b/src/web/media.ts @@ -4,11 +4,12 @@ import { fileURLToPath } from "node:url"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { type MediaKind, maxBytesForKind, mediaKindFromMime } from "../media/constants.js"; +import { resolveUserPath } from "../utils.js"; import { fetchRemoteMedia } from "../media/fetch.js"; import { convertHeicToJpeg, resizeToJpeg } from "../media/image-ops.js"; import { detectMime, extensionForMime } from "../media/mime.js"; -type WebMediaResult = { +export type WebMediaResult = { buffer: Buffer; contentType?: string; kind: MediaKind; @@ -89,10 +90,9 @@ async function loadWebMediaInternal( kind: MediaKind; fileName?: string; }): Promise => { - const cap = - maxBytes !== undefined - ? Math.min(maxBytes, maxBytesForKind(params.kind)) - : maxBytesForKind(params.kind); + // If caller explicitly provides maxBytes, trust it (for channels that handle large files). + // Otherwise fall back to per-kind defaults. + const cap = maxBytes !== undefined ? maxBytes : maxBytesForKind(params.kind); if (params.kind === "image") { const isGif = params.contentType === "image/gif"; if (isGif || !optimizeImages) { @@ -141,6 +141,11 @@ async function loadWebMediaInternal( return await clampAndFinalize({ buffer, contentType, kind, fileName }); } + // Expand tilde paths to absolute paths (e.g., ~/Downloads/photo.jpg) + if (mediaUrl.startsWith("~")) { + mediaUrl = resolveUserPath(mediaUrl); + } + // Local path const data = await fs.readFile(mediaUrl); const mime = await detectMime({ buffer: data, filePath: mediaUrl });