fix: msteams attachments + plugin prompt hints

Co-authored-by: Christof <10854026+Evizero@users.noreply.github.com>
This commit is contained in:
Peter Steinberger 2026-01-22 03:27:26 +00:00
parent 5fe8c4ab8c
commit 0f7f7bb95f
50 changed files with 2739 additions and 174 deletions

View File

@ -11,6 +11,7 @@ Docs: https://docs.clawd.bot
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky. - 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). - 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. - 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 ### Fixes
- Config: avoid stack traces for invalid configs and log the config path. - Config: avoid stack traces for invalid configs and log the config path.

View File

@ -8,9 +8,9 @@ read_when:
> "Abandon all hope, ye who enter here." > "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 ## Plugin required
Microsoft Teams ships as a plugin and is not bundled with the core install. 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: Teams markdown is more limited than Slack or Discord:
- Basic formatting works: **bold**, *italic*, `code`, links - Basic formatting works: **bold**, *italic*, `code`, links
- Complex markdown (tables, nested lists) may not render correctly - 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 ## Configuration
Key settings (see `/gateway/configuration` for shared channel patterns): 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.<teamId>.requireMention`: per-team override. - `channels.msteams.teams.<teamId>.requireMention`: per-team override.
- `channels.msteams.teams.<teamId>.channels.<conversationId>.replyStyle`: per-channel override. - `channels.msteams.teams.<teamId>.channels.<conversationId>.replyStyle`: per-channel override.
- `channels.msteams.teams.<teamId>.channels.<conversationId>.requireMention`: per-channel override. - `channels.msteams.teams.<teamId>.channels.<conversationId>.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 ## Routing & Sessions
- Session keys follow the standard agent format (see [/concepts/session](/concepts/session)): - 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). 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). 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) ## Polls (Adaptive Cards)
Clawdbot sends Teams polls as Adaptive Cards (there is no native Teams poll API). 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. - The gateway must stay online to record votes.
- Polls do not auto-post result summaries yet (inspect the store file if needed). - 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:<id>",
"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:<aad-object-id>` | `user:40a1a0ed-4ff2-4164-a219-55518990c197` |
| User (by name) | `user:<display-name>` | `user:John Smith` (requires Graph API) |
| Group/channel | `conversation:<conversation-id>` | `conversation:19:abc123...@thread.tacv2` |
| Group/channel (raw) | `<conversation-id>` | `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 messaging
- Proactive messages are only possible **after** a user has interacted, because we store conversation references at that point. - 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. - See `/gateway/configuration` for `dmPolicy` and allowlist gating.

View File

@ -322,7 +322,7 @@ Notes:
Send messages and channel actions across Discord/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams. Send messages and channel actions across Discord/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams.
Core actions: Core actions:
- `send` (text + optional media) - `send` (text + optional media; MS Teams also supports `card` for Adaptive Cards)
- `poll` (WhatsApp/Discord/MS Teams polls) - `poll` (WhatsApp/Discord/MS Teams polls)
- `react` / `reactions` / `read` / `edit` / `delete` - `react` / `reactions` / `read` / `edit` / `delete`
- `pin` / `unpin` / `list-pins` - `pin` / `unpin` / `list-pins`

View File

@ -101,9 +101,9 @@ describe("msteams attachments", () => {
}); });
}); });
describe("downloadMSTeamsImageAttachments", () => { describe("downloadMSTeamsAttachments", () => {
it("downloads and stores image contentUrl attachments", async () => { it("downloads and stores image contentUrl attachments", async () => {
const { downloadMSTeamsImageAttachments } = await load(); const { downloadMSTeamsAttachments } = await load();
const fetchMock = vi.fn(async () => { const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("png"), { return new Response(Buffer.from("png"), {
status: 200, 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" }], attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
maxBytes: 1024 * 1024, maxBytes: 1024 * 1024,
allowHosts: ["x"], allowHosts: ["x"],
@ -125,7 +125,7 @@ describe("msteams attachments", () => {
}); });
it("supports Teams file.download.info downloadUrl attachments", async () => { it("supports Teams file.download.info downloadUrl attachments", async () => {
const { downloadMSTeamsImageAttachments } = await load(); const { downloadMSTeamsAttachments } = await load();
const fetchMock = vi.fn(async () => { const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("png"), { return new Response(Buffer.from("png"), {
status: 200, status: 200,
@ -133,7 +133,7 @@ describe("msteams attachments", () => {
}); });
}); });
const media = await downloadMSTeamsImageAttachments({ const media = await downloadMSTeamsAttachments({
attachments: [ attachments: [
{ {
contentType: "application/vnd.microsoft.teams.file.download.info", contentType: "application/vnd.microsoft.teams.file.download.info",
@ -149,8 +149,35 @@ describe("msteams attachments", () => {
expect(media).toHaveLength(1); expect(media).toHaveLength(1);
}); });
it("downloads non-image file attachments (PDF)", async () => {
const { downloadMSTeamsAttachments } = await load();
const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("pdf"), {
status: 200,
headers: { "content-type": "application/pdf" },
});
});
detectMimeMock.mockResolvedValueOnce("application/pdf");
saveMediaBufferMock.mockResolvedValueOnce({
path: "/tmp/saved.pdf",
contentType: "application/pdf",
});
const media = await downloadMSTeamsAttachments({
attachments: [{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }],
maxBytes: 1024 * 1024,
allowHosts: ["x"],
fetchFn: fetchMock as unknown as typeof fetch,
});
expect(fetchMock).toHaveBeenCalledWith("https://x/doc.pdf");
expect(media).toHaveLength(1);
expect(media[0]?.path).toBe("/tmp/saved.pdf");
expect(media[0]?.placeholder).toBe("<media:document>");
});
it("downloads inline image URLs from html attachments", async () => { it("downloads inline image URLs from html attachments", async () => {
const { downloadMSTeamsImageAttachments } = await load(); const { downloadMSTeamsAttachments } = await load();
const fetchMock = vi.fn(async () => { const fetchMock = vi.fn(async () => {
return new Response(Buffer.from("png"), { return new Response(Buffer.from("png"), {
status: 200, status: 200,
@ -158,7 +185,7 @@ describe("msteams attachments", () => {
}); });
}); });
const media = await downloadMSTeamsImageAttachments({ const media = await downloadMSTeamsAttachments({
attachments: [ attachments: [
{ {
contentType: "text/html", contentType: "text/html",
@ -175,9 +202,9 @@ describe("msteams attachments", () => {
}); });
it("stores inline data:image base64 payloads", async () => { it("stores inline data:image base64 payloads", async () => {
const { downloadMSTeamsImageAttachments } = await load(); const { downloadMSTeamsAttachments } = await load();
const base64 = Buffer.from("png").toString("base64"); const base64 = Buffer.from("png").toString("base64");
const media = await downloadMSTeamsImageAttachments({ const media = await downloadMSTeamsAttachments({
attachments: [ attachments: [
{ {
contentType: "text/html", contentType: "text/html",
@ -193,7 +220,7 @@ describe("msteams attachments", () => {
}); });
it("retries with auth when the first request is unauthorized", async () => { 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 fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => {
const hasAuth = Boolean( const hasAuth = Boolean(
opts && opts &&
@ -210,7 +237,7 @@ describe("msteams attachments", () => {
}); });
}); });
const media = await downloadMSTeamsImageAttachments({ const media = await downloadMSTeamsAttachments({
attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }], attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }],
maxBytes: 1024 * 1024, maxBytes: 1024 * 1024,
tokenProvider: { getAccessToken: vi.fn(async () => "token") }, tokenProvider: { getAccessToken: vi.fn(async () => "token") },
@ -224,9 +251,9 @@ describe("msteams attachments", () => {
}); });
it("skips urls outside the allowlist", async () => { it("skips urls outside the allowlist", async () => {
const { downloadMSTeamsImageAttachments } = await load(); const { downloadMSTeamsAttachments } = await load();
const fetchMock = vi.fn(); const fetchMock = vi.fn();
const media = await downloadMSTeamsImageAttachments({ const media = await downloadMSTeamsAttachments({
attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }], attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }],
maxBytes: 1024 * 1024, maxBytes: 1024 * 1024,
allowHosts: ["graph.microsoft.com"], allowHosts: ["graph.microsoft.com"],
@ -236,20 +263,6 @@ describe("msteams attachments", () => {
expect(media).toHaveLength(0); expect(media).toHaveLength(0);
expect(fetchMock).not.toHaveBeenCalled(); 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", () => { describe("buildMSTeamsGraphMessageUrls", () => {
@ -324,6 +337,74 @@ describe("msteams attachments", () => {
expect(fetchMock).toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalled();
expect(saveMediaBufferMock).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", () => { describe("buildMSTeamsMediaPayload", () => {

View File

@ -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 { buildMSTeamsGraphMessageUrls, downloadMSTeamsGraphMedia } from "./attachments/graph.js";
export { export {
buildMSTeamsAttachmentPlaceholder, buildMSTeamsAttachmentPlaceholder,

View File

@ -2,7 +2,7 @@ import { getMSTeamsRuntime } from "../runtime.js";
import { import {
extractInlineImageCandidates, extractInlineImageCandidates,
inferPlaceholder, inferPlaceholder,
isLikelyImageAttachment, isDownloadableAttachment,
isRecord, isRecord,
isUrlAllowed, isUrlAllowed,
normalizeContentType, normalizeContentType,
@ -102,23 +102,31 @@ async function fetchWithAuthFallback(params: {
return firstAttempt; 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; attachments: MSTeamsAttachmentLike[] | undefined;
maxBytes: number; maxBytes: number;
tokenProvider?: MSTeamsAccessTokenProvider; tokenProvider?: MSTeamsAccessTokenProvider;
allowHosts?: string[]; allowHosts?: string[];
fetchFn?: typeof fetch; fetchFn?: typeof fetch;
/** When true, embeds original filename in stored path for later extraction. */
preserveFilenames?: boolean;
}): Promise<MSTeamsInboundMedia[]> { }): Promise<MSTeamsInboundMedia[]> {
const list = Array.isArray(params.attachments) ? params.attachments : []; const list = Array.isArray(params.attachments) ? params.attachments : [];
if (list.length === 0) return []; if (list.length === 0) return [];
const allowHosts = resolveAllowedHosts(params.allowHosts); const allowHosts = resolveAllowedHosts(params.allowHosts);
const candidates: DownloadCandidate[] = list // Download ANY downloadable attachment (not just images)
.filter(isLikelyImageAttachment) const downloadable = list.filter(isDownloadableAttachment);
const candidates: DownloadCandidate[] = downloadable
.map(resolveDownloadCandidate) .map(resolveDownloadCandidate)
.filter(Boolean) as DownloadCandidate[]; .filter(Boolean) as DownloadCandidate[];
const inlineCandidates = extractInlineImageCandidates(list); const inlineCandidates = extractInlineImageCandidates(list);
const seenUrls = new Set<string>(); const seenUrls = new Set<string>();
for (const inline of inlineCandidates) { for (const inline of inlineCandidates) {
if (inline.kind === "url") { if (inline.kind === "url") {
@ -133,7 +141,6 @@ export async function downloadMSTeamsImageAttachments(params: {
}); });
} }
} }
if (candidates.length === 0 && inlineCandidates.length === 0) return []; if (candidates.length === 0 && inlineCandidates.length === 0) return [];
const out: MSTeamsInboundMedia[] = []; const out: MSTeamsInboundMedia[] = [];
@ -141,6 +148,7 @@ export async function downloadMSTeamsImageAttachments(params: {
if (inline.kind !== "data") continue; if (inline.kind !== "data") continue;
if (inline.data.byteLength > params.maxBytes) continue; if (inline.data.byteLength > params.maxBytes) continue;
try { try {
// Data inline candidates (base64 data URLs) don't have original filenames
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer( const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
inline.data, inline.data,
inline.contentType, inline.contentType,
@ -172,11 +180,13 @@ export async function downloadMSTeamsImageAttachments(params: {
headerMime: res.headers.get("content-type"), headerMime: res.headers.get("content-type"),
filePath: candidate.fileHint ?? candidate.url, filePath: candidate.fileHint ?? candidate.url,
}); });
const originalFilename = params.preserveFilenames ? candidate.fileHint : undefined;
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer( const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
buffer, buffer,
mime ?? candidate.contentTypeHint, mime ?? candidate.contentTypeHint,
"inbound", "inbound",
params.maxBytes, params.maxBytes,
originalFilename,
); );
out.push({ out.push({
path: saved.path, path: saved.path,
@ -184,8 +194,13 @@ export async function downloadMSTeamsImageAttachments(params: {
placeholder: candidate.placeholder, placeholder: candidate.placeholder,
}); });
} catch { } catch {
// Ignore download failures and continue. // Ignore download failures and continue with next candidate.
} }
} }
return out; return out;
} }
/**
* @deprecated Use `downloadMSTeamsAttachments` instead (supports all file types).
*/
export const downloadMSTeamsImageAttachments = downloadMSTeamsAttachments;

View File

@ -1,6 +1,6 @@
import { getMSTeamsRuntime } from "../runtime.js"; import { getMSTeamsRuntime } from "../runtime.js";
import { downloadMSTeamsImageAttachments } from "./download.js"; import { downloadMSTeamsAttachments } from "./download.js";
import { GRAPH_ROOT, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js"; import { GRAPH_ROOT, inferPlaceholder, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js";
import type { import type {
MSTeamsAccessTokenProvider, MSTeamsAccessTokenProvider,
MSTeamsAttachmentLike, 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; accessToken: string;
messageUrl: string; messageUrl: string;
maxBytes: number; maxBytes: number;
fetchFn?: typeof fetch; fetchFn?: typeof fetch;
preserveFilenames?: boolean;
}): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> { }): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> {
const hosted = await fetchGraphCollection<GraphHostedContent>({ const hosted = await fetchGraphCollection<GraphHostedContent>({
url: `${params.messageUrl}/hostedContents`, url: `${params.messageUrl}/hostedContents`,
@ -158,7 +163,7 @@ async function downloadGraphHostedImages(params: {
buffer, buffer,
headerMime: item.contentType ?? undefined, headerMime: item.contentType ?? undefined,
}); });
if (mime && !mime.startsWith("image/")) continue; // Download any file type, not just images
try { try {
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer( const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
buffer, buffer,
@ -169,7 +174,7 @@ async function downloadGraphHostedImages(params: {
out.push({ out.push({
path: saved.path, path: saved.path,
contentType: saved.contentType, contentType: saved.contentType,
placeholder: "<media:image>", placeholder: inferPlaceholder({ contentType: saved.contentType }),
}); });
} catch { } catch {
// Ignore save failures. // Ignore save failures.
@ -185,6 +190,8 @@ export async function downloadMSTeamsGraphMedia(params: {
maxBytes: number; maxBytes: number;
allowHosts?: string[]; allowHosts?: string[];
fetchFn?: typeof fetch; fetchFn?: typeof fetch;
/** When true, embeds original filename in stored path for later extraction. */
preserveFilenames?: boolean;
}): Promise<MSTeamsGraphMediaResult> { }): Promise<MSTeamsGraphMediaResult> {
if (!params.messageUrl || !params.tokenProvider) return { media: [] }; if (!params.messageUrl || !params.tokenProvider) return { media: [] };
const allowHosts = resolveAllowedHosts(params.allowHosts); const allowHosts = resolveAllowedHosts(params.allowHosts);
@ -196,11 +203,83 @@ export async function downloadMSTeamsGraphMedia(params: {
return { media: [], messageUrl, tokenError: true }; return { media: [], messageUrl, tokenError: true };
} }
const hosted = await downloadGraphHostedImages({ // Fetch the full message to get SharePoint file attachments (for group chats)
const fetchFn = params.fetchFn ?? fetch;
const sharePointMedia: MSTeamsInboundMedia[] = [];
const downloadedReferenceUrls = new Set<string>();
try {
const msgRes = await fetchFn(messageUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (msgRes.ok) {
const msgData = (await msgRes.json()) as {
body?: { content?: string; contentType?: string };
attachments?: Array<{
id?: string;
contentUrl?: string;
contentType?: string;
name?: string;
}>;
};
// Extract SharePoint file attachments (contentType: "reference")
// Download any file type, not just images
const spAttachments = (msgData.attachments ?? []).filter(
(a) => a.contentType === "reference" && a.contentUrl && a.name,
);
for (const att of spAttachments) {
const name = att.name ?? "file";
try {
// SharePoint URLs need to be accessed via Graph shares API
const shareUrl = att.contentUrl!;
const encodedUrl = Buffer.from(shareUrl).toString("base64url");
const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`;
const spRes = await fetchFn(sharesUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
redirect: "follow",
});
if (spRes.ok) {
const buffer = Buffer.from(await spRes.arrayBuffer());
if (buffer.byteLength <= params.maxBytes) {
const mime = await getMSTeamsRuntime().media.detectMime({
buffer,
headerMime: spRes.headers.get("content-type") ?? undefined,
filePath: name,
});
const originalFilename = params.preserveFilenames ? name : undefined;
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
buffer,
mime ?? "application/octet-stream",
"inbound",
params.maxBytes,
originalFilename,
);
sharePointMedia.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder({ contentType: saved.contentType, fileName: name }),
});
downloadedReferenceUrls.add(shareUrl);
}
}
} catch {
// Ignore SharePoint download failures.
}
}
}
} catch {
// Ignore message fetch failures.
}
const hosted = await downloadGraphHostedContent({
accessToken, accessToken,
messageUrl, messageUrl,
maxBytes: params.maxBytes, maxBytes: params.maxBytes,
fetchFn: params.fetchFn, fetchFn: params.fetchFn,
preserveFilenames: params.preserveFilenames,
}); });
const attachments = await fetchGraphCollection<GraphAttachment>({ const attachments = await fetchGraphCollection<GraphAttachment>({
@ -210,18 +289,29 @@ export async function downloadMSTeamsGraphMedia(params: {
}); });
const normalizedAttachments = attachments.items.map(normalizeGraphAttachment); const normalizedAttachments = attachments.items.map(normalizeGraphAttachment);
const attachmentMedia = await downloadMSTeamsImageAttachments({ const filteredAttachments =
attachments: normalizedAttachments, 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, maxBytes: params.maxBytes,
tokenProvider: params.tokenProvider, tokenProvider: params.tokenProvider,
allowHosts, allowHosts,
fetchFn: params.fetchFn, fetchFn: params.fetchFn,
preserveFilenames: params.preserveFilenames,
}); });
return { return {
media: [...hosted.media, ...attachmentMedia], media: [...sharePointMedia, ...hosted.media, ...attachmentMedia],
hostedCount: hosted.count, hostedCount: hosted.count,
attachmentCount: attachments.items.length, attachmentCount: filteredAttachments.length + sharePointMedia.length,
hostedStatus: hosted.status, hostedStatus: hosted.status,
attachmentStatus: attachments.status, attachmentStatus: attachments.status,
messageUrl, messageUrl,

View File

@ -37,6 +37,15 @@ export const DEFAULT_MEDIA_HOST_ALLOWLIST = [
"statics.teams.cdn.office.net", "statics.teams.cdn.office.net",
"office.com", "office.com",
"office.net", "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; ] as const;
export const GRAPH_ROOT = "https://graph.microsoft.com/v1.0"; export const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
@ -85,6 +94,30 @@ export function isLikelyImageAttachment(att: MSTeamsAttachmentLike): boolean {
return false; 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 { function isHtmlAttachment(att: MSTeamsAttachmentLike): boolean {
const contentType = normalizeContentType(att.contentType) ?? ""; const contentType = normalizeContentType(att.contentType) ?? "";
return contentType.startsWith("text/html"); return contentType.startsWith("text/html");

View File

@ -17,7 +17,7 @@ import {
resolveMSTeamsChannelAllowlist, resolveMSTeamsChannelAllowlist,
resolveMSTeamsUserAllowlist, resolveMSTeamsUserAllowlist,
} from "./resolve-allowlist.js"; } from "./resolve-allowlist.js";
import { sendMessageMSTeams } from "./send.js"; import { sendAdaptiveCardMSTeams, sendMessageMSTeams } from "./send.js";
import { resolveMSTeamsCredentials } from "./token.js"; import { resolveMSTeamsCredentials } from "./token.js";
import { import {
listMSTeamsDirectoryGroupsLive, listMSTeamsDirectoryGroupsLive,
@ -64,6 +64,19 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
threads: true, threads: true,
media: 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"] }, reload: { configPrefixes: ["channels.msteams"] },
configSchema: buildChannelConfigSchema(MSTeamsConfigSchema), configSchema: buildChannelConfigSchema(MSTeamsConfigSchema),
config: { config: {
@ -137,7 +150,12 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
looksLikeId: (raw) => { looksLikeId: (raw) => {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return false; 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"); return trimmed.includes("@thread");
}, },
hint: "<conversationId|user:ID|conversation:ID>", hint: "<conversationId|user:ID|conversation:ID>",
@ -320,6 +338,50 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
if (!enabled) return []; if (!enabled) return [];
return ["poll"] satisfies ChannelMessageActionName[]; return ["poll"] satisfies ChannelMessageActionName[];
}, },
supportsCards: ({ cfg }) => {
return (
cfg.channels?.msteams?.enabled !== false &&
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams))
);
},
handleAction: async (ctx) => {
// Handle send action with card parameter
if (ctx.action === "send" && ctx.params.card) {
const card = ctx.params.card as Record<string, unknown>;
const to =
typeof ctx.params.to === "string"
? ctx.params.to.trim()
: typeof ctx.params.target === "string"
? ctx.params.target.trim()
: "";
if (!to) {
return {
isError: true,
content: [{ type: "text", text: "Card send requires a target (to)." }],
};
}
const result = await sendAdaptiveCardMSTeams({
cfg: ctx.cfg,
to,
card,
});
return {
content: [
{
type: "text",
text: JSON.stringify({
ok: true,
channel: "msteams",
messageId: result.messageId,
conversationId: result.conversationId,
}),
},
],
};
}
// Return null to fall through to default handler
return null as never;
},
}, },
outbound: msteamsOutbound, outbound: msteamsOutbound,
status: { status: {

View File

@ -0,0 +1,234 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.js";
import * as pendingUploads from "./pending-uploads.js";
describe("requiresFileConsent", () => {
const thresholdBytes = 4 * 1024 * 1024; // 4MB
it("returns true for personal chat with non-image", () => {
expect(
requiresFileConsent({
conversationType: "personal",
contentType: "application/pdf",
bufferSize: 1000,
thresholdBytes,
}),
).toBe(true);
});
it("returns true for personal chat with large image", () => {
expect(
requiresFileConsent({
conversationType: "personal",
contentType: "image/png",
bufferSize: 5 * 1024 * 1024, // 5MB
thresholdBytes,
}),
).toBe(true);
});
it("returns false for personal chat with small image", () => {
expect(
requiresFileConsent({
conversationType: "personal",
contentType: "image/png",
bufferSize: 1000,
thresholdBytes,
}),
).toBe(false);
});
it("returns false for group chat with large non-image", () => {
expect(
requiresFileConsent({
conversationType: "groupChat",
contentType: "application/pdf",
bufferSize: 5 * 1024 * 1024,
thresholdBytes,
}),
).toBe(false);
});
it("returns false for channel with large non-image", () => {
expect(
requiresFileConsent({
conversationType: "channel",
contentType: "application/pdf",
bufferSize: 5 * 1024 * 1024,
thresholdBytes,
}),
).toBe(false);
});
it("handles case-insensitive conversation type", () => {
expect(
requiresFileConsent({
conversationType: "Personal",
contentType: "application/pdf",
bufferSize: 1000,
thresholdBytes,
}),
).toBe(true);
expect(
requiresFileConsent({
conversationType: "PERSONAL",
contentType: "application/pdf",
bufferSize: 1000,
thresholdBytes,
}),
).toBe(true);
});
it("returns false when conversationType is undefined", () => {
expect(
requiresFileConsent({
conversationType: undefined,
contentType: "application/pdf",
bufferSize: 1000,
thresholdBytes,
}),
).toBe(false);
});
it("returns true for personal chat when contentType is undefined (non-image)", () => {
expect(
requiresFileConsent({
conversationType: "personal",
contentType: undefined,
bufferSize: 1000,
thresholdBytes,
}),
).toBe(true);
});
it("returns true for personal chat with file exactly at threshold", () => {
expect(
requiresFileConsent({
conversationType: "personal",
contentType: "image/jpeg",
bufferSize: thresholdBytes, // exactly 4MB
thresholdBytes,
}),
).toBe(true);
});
it("returns false for personal chat with file just below threshold", () => {
expect(
requiresFileConsent({
conversationType: "personal",
contentType: "image/jpeg",
bufferSize: thresholdBytes - 1, // 4MB - 1 byte
thresholdBytes,
}),
).toBe(false);
});
});
describe("prepareFileConsentActivity", () => {
const mockUploadId = "test-upload-id-123";
beforeEach(() => {
vi.spyOn(pendingUploads, "storePendingUpload").mockReturnValue(mockUploadId);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("creates activity with consent card attachment", () => {
const result = prepareFileConsentActivity({
media: {
buffer: Buffer.from("test content"),
filename: "test.pdf",
contentType: "application/pdf",
},
conversationId: "conv123",
description: "My file",
});
expect(result.uploadId).toBe(mockUploadId);
expect(result.activity.type).toBe("message");
expect(result.activity.attachments).toHaveLength(1);
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, unknown>;
expect(attachment.contentType).toBe("application/vnd.microsoft.teams.card.file.consent");
expect(attachment.name).toBe("test.pdf");
});
it("stores pending upload with correct data", () => {
const buffer = Buffer.from("test content");
prepareFileConsentActivity({
media: {
buffer,
filename: "test.pdf",
contentType: "application/pdf",
},
conversationId: "conv123",
description: "My file",
});
expect(pendingUploads.storePendingUpload).toHaveBeenCalledWith({
buffer,
filename: "test.pdf",
contentType: "application/pdf",
conversationId: "conv123",
});
});
it("uses default description when not provided", () => {
const result = prepareFileConsentActivity({
media: {
buffer: Buffer.from("test"),
filename: "document.docx",
contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
},
conversationId: "conv456",
});
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, { description: string }>;
expect(attachment.content.description).toBe("File: document.docx");
});
it("uses provided description", () => {
const result = prepareFileConsentActivity({
media: {
buffer: Buffer.from("test"),
filename: "report.pdf",
contentType: "application/pdf",
},
conversationId: "conv789",
description: "Q4 Financial Report",
});
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, { description: string }>;
expect(attachment.content.description).toBe("Q4 Financial Report");
});
it("includes uploadId in consent card context", () => {
const result = prepareFileConsentActivity({
media: {
buffer: Buffer.from("test"),
filename: "file.txt",
contentType: "text/plain",
},
conversationId: "conv000",
});
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, { acceptContext: { uploadId: string } }>;
expect(attachment.content.acceptContext.uploadId).toBe(mockUploadId);
});
it("handles media without contentType", () => {
const result = prepareFileConsentActivity({
media: {
buffer: Buffer.from("binary data"),
filename: "unknown.bin",
},
conversationId: "conv111",
});
expect(result.uploadId).toBe(mockUploadId);
expect(result.activity.type).toBe("message");
});
});

View File

@ -0,0 +1,73 @@
/**
* Shared helpers for FileConsentCard flow in MSTeams.
*
* FileConsentCard is required for:
* - Personal (1:1) chats with large files (>=4MB)
* - Personal chats with non-image files (PDFs, documents, etc.)
*
* This module consolidates the logic used by both send.ts (proactive sends)
* and messenger.ts (reply path) to avoid duplication.
*/
import { buildFileConsentCard } from "./file-consent.js";
import { storePendingUpload } from "./pending-uploads.js";
export type FileConsentMedia = {
buffer: Buffer;
filename: string;
contentType?: string;
};
export type FileConsentActivityResult = {
activity: Record<string, unknown>;
uploadId: string;
};
/**
* Prepare a FileConsentCard activity for large files or non-images in personal chats.
* Returns the activity object and uploadId - caller is responsible for sending.
*/
export function prepareFileConsentActivity(params: {
media: FileConsentMedia;
conversationId: string;
description?: string;
}): FileConsentActivityResult {
const { media, conversationId, description } = params;
const uploadId = storePendingUpload({
buffer: media.buffer,
filename: media.filename,
contentType: media.contentType,
conversationId,
});
const consentCard = buildFileConsentCard({
filename: media.filename,
description: description || `File: ${media.filename}`,
sizeInBytes: media.buffer.length,
context: { uploadId },
});
const activity: Record<string, unknown> = {
type: "message",
attachments: [consentCard],
};
return { activity, uploadId };
}
/**
* Check if a file requires FileConsentCard flow.
* True for: personal chat AND (large file OR non-image)
*/
export function requiresFileConsent(params: {
conversationType: string | undefined;
contentType: string | undefined;
bufferSize: number;
thresholdBytes: number;
}): boolean {
const isPersonal = params.conversationType?.toLowerCase() === "personal";
const isImage = params.contentType?.startsWith("image/") ?? false;
const isLargeFile = params.bufferSize >= params.thresholdBytes;
return isPersonal && (isLargeFile || !isImage);
}

View File

@ -0,0 +1,122 @@
/**
* FileConsentCard utilities for MS Teams large file uploads (>4MB) in personal chats.
*
* Teams requires user consent before the bot can upload large files. This module provides
* utilities for:
* - Building FileConsentCard attachments (to request upload permission)
* - Building FileInfoCard attachments (to confirm upload completion)
* - Parsing fileConsent/invoke activities
*/
export interface FileConsentCardParams {
filename: string;
description?: string;
sizeInBytes: number;
/** Custom context data to include in the card (passed back in the invoke) */
context?: Record<string, unknown>;
}
export interface FileInfoCardParams {
filename: string;
contentUrl: string;
uniqueId: string;
fileType: string;
}
/**
* Build a FileConsentCard attachment for requesting upload permission.
* Use this for files >= 4MB in personal (1:1) chats.
*/
export function buildFileConsentCard(params: FileConsentCardParams) {
return {
contentType: "application/vnd.microsoft.teams.card.file.consent",
name: params.filename,
content: {
description: params.description ?? `File: ${params.filename}`,
sizeInBytes: params.sizeInBytes,
acceptContext: { filename: params.filename, ...params.context },
declineContext: { filename: params.filename, ...params.context },
},
};
}
/**
* Build a FileInfoCard attachment for confirming upload completion.
* Send this after successfully uploading the file to the consent URL.
*/
export function buildFileInfoCard(params: FileInfoCardParams) {
return {
contentType: "application/vnd.microsoft.teams.card.file.info",
contentUrl: params.contentUrl,
name: params.filename,
content: {
uniqueId: params.uniqueId,
fileType: params.fileType,
},
};
}
export interface FileConsentUploadInfo {
name: string;
uploadUrl: string;
contentUrl: string;
uniqueId: string;
fileType: string;
}
export interface FileConsentResponse {
action: "accept" | "decline";
uploadInfo?: FileConsentUploadInfo;
context?: Record<string, unknown>;
}
/**
* Parse a fileConsent/invoke activity.
* Returns null if the activity is not a file consent invoke.
*/
export function parseFileConsentInvoke(activity: {
name?: string;
value?: unknown;
}): FileConsentResponse | null {
if (activity.name !== "fileConsent/invoke") return null;
const value = activity.value as {
type?: string;
action?: string;
uploadInfo?: FileConsentUploadInfo;
context?: Record<string, unknown>;
};
if (value?.type !== "fileUpload") return null;
return {
action: value.action === "accept" ? "accept" : "decline",
uploadInfo: value.uploadInfo,
context: value.context,
};
}
/**
* Upload a file to the consent URL provided by Teams.
* The URL is provided in the fileConsent/invoke response after user accepts.
*/
export async function uploadToConsentUrl(params: {
url: string;
buffer: Buffer;
contentType?: string;
fetchFn?: typeof fetch;
}): Promise<void> {
const fetchFn = params.fetchFn ?? fetch;
const res = await fetchFn(params.url, {
method: "PUT",
headers: {
"Content-Type": params.contentType ?? "application/octet-stream",
"Content-Range": `bytes 0-${params.buffer.length - 1}/${params.buffer.length}`,
},
body: new Uint8Array(params.buffer),
});
if (!res.ok) {
throw new Error(`File upload to consent URL failed: ${res.status} ${res.statusText}`);
}
}

View File

@ -0,0 +1,52 @@
/**
* Native Teams file card attachments for Bot Framework.
*
* The Bot Framework SDK supports `application/vnd.microsoft.teams.card.file.info`
* content type which produces native Teams file cards.
*
* @see https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4
*/
import type { DriveItemProperties } from "./graph-upload.js";
/**
* Build a native Teams file card attachment for Bot Framework.
*
* This uses the `application/vnd.microsoft.teams.card.file.info` content type
* which is supported by Bot Framework and produces native Teams file cards
* (the same display as when a user manually shares a file).
*
* @param file - DriveItem properties from getDriveItemProperties()
* @returns Attachment object for Bot Framework sendActivity()
*/
export function buildTeamsFileInfoCard(file: DriveItemProperties): {
contentType: string;
contentUrl: string;
name: string;
content: {
uniqueId: string;
fileType: string;
};
} {
// Extract unique ID from eTag (remove quotes, braces, and version suffix)
// Example eTag formats: "{GUID},version" or "\"{GUID},version\""
const rawETag = file.eTag;
const uniqueId = rawETag
.replace(/^["']|["']$/g, "") // Remove outer quotes
.replace(/[{}]/g, "") // Remove curly braces
.split(",")[0] ?? rawETag; // Take the GUID part before comma
// Extract file extension from filename
const lastDot = file.name.lastIndexOf(".");
const fileType = lastDot >= 0 ? file.name.slice(lastDot + 1).toLowerCase() : "";
return {
contentType: "application/vnd.microsoft.teams.card.file.info",
contentUrl: file.webDavUrl,
name: file.name,
content: {
uniqueId,
fileType,
},
};
}

View File

@ -0,0 +1,445 @@
/**
* OneDrive/SharePoint upload utilities for MS Teams file sending.
*
* For group chats and channels, files are uploaded to SharePoint and shared via a link.
* This module provides utilities for:
* - Uploading files to OneDrive (personal scope - now deprecated for bot use)
* - Uploading files to SharePoint (group/channel scope)
* - Creating sharing links (organization-wide or per-user)
* - Getting chat members for per-user sharing
*/
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
const GRAPH_BETA = "https://graph.microsoft.com/beta";
const GRAPH_SCOPE = "https://graph.microsoft.com/.default";
export interface OneDriveUploadResult {
id: string;
webUrl: string;
name: string;
}
/**
* Upload a file to the user's OneDrive root folder.
* For larger files, this uses the simple upload endpoint (up to 4MB).
* TODO: For files >4MB, implement resumable upload session.
*/
export async function uploadToOneDrive(params: {
buffer: Buffer;
filename: string;
contentType?: string;
tokenProvider: MSTeamsAccessTokenProvider;
fetchFn?: typeof fetch;
}): Promise<OneDriveUploadResult> {
const fetchFn = params.fetchFn ?? fetch;
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
// Use "ClawdbotShared" folder to organize bot-uploaded files
const uploadPath = `/ClawdbotShared/${encodeURIComponent(params.filename)}`;
const res = await fetchFn(`${GRAPH_ROOT}/me/drive/root:${uploadPath}:/content`, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": params.contentType ?? "application/octet-stream",
},
body: new Uint8Array(params.buffer),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`OneDrive upload failed: ${res.status} ${res.statusText} - ${body}`);
}
const data = (await res.json()) as {
id?: string;
webUrl?: string;
name?: string;
};
if (!data.id || !data.webUrl || !data.name) {
throw new Error("OneDrive upload response missing required fields");
}
return {
id: data.id,
webUrl: data.webUrl,
name: data.name,
};
}
export interface OneDriveSharingLink {
webUrl: string;
}
/**
* Create a sharing link for a OneDrive file.
* The link allows organization members to view the file.
*/
export async function createSharingLink(params: {
itemId: string;
tokenProvider: MSTeamsAccessTokenProvider;
/** Sharing scope: "organization" (default) or "anonymous" */
scope?: "organization" | "anonymous";
fetchFn?: typeof fetch;
}): Promise<OneDriveSharingLink> {
const fetchFn = params.fetchFn ?? fetch;
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
const res = await fetchFn(`${GRAPH_ROOT}/me/drive/items/${params.itemId}/createLink`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "view",
scope: params.scope ?? "organization",
}),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Create sharing link failed: ${res.status} ${res.statusText} - ${body}`);
}
const data = (await res.json()) as {
link?: { webUrl?: string };
};
if (!data.link?.webUrl) {
throw new Error("Create sharing link response missing webUrl");
}
return {
webUrl: data.link.webUrl,
};
}
/**
* Upload a file to OneDrive and create a sharing link.
* Convenience function for the common case.
*/
export async function uploadAndShareOneDrive(params: {
buffer: Buffer;
filename: string;
contentType?: string;
tokenProvider: MSTeamsAccessTokenProvider;
scope?: "organization" | "anonymous";
fetchFn?: typeof fetch;
}): Promise<{
itemId: string;
webUrl: string;
shareUrl: string;
name: string;
}> {
const uploaded = await uploadToOneDrive({
buffer: params.buffer,
filename: params.filename,
contentType: params.contentType,
tokenProvider: params.tokenProvider,
fetchFn: params.fetchFn,
});
const shareLink = await createSharingLink({
itemId: uploaded.id,
tokenProvider: params.tokenProvider,
scope: params.scope,
fetchFn: params.fetchFn,
});
return {
itemId: uploaded.id,
webUrl: uploaded.webUrl,
shareUrl: shareLink.webUrl,
name: uploaded.name,
};
}
// ============================================================================
// SharePoint upload functions for group chats and channels
// ============================================================================
/**
* Upload a file to a SharePoint site.
* This is used for group chats and channels where /me/drive doesn't work for bots.
*
* @param params.siteId - SharePoint site ID (e.g., "contoso.sharepoint.com,guid1,guid2")
*/
export async function uploadToSharePoint(params: {
buffer: Buffer;
filename: string;
contentType?: string;
tokenProvider: MSTeamsAccessTokenProvider;
siteId: string;
fetchFn?: typeof fetch;
}): Promise<OneDriveUploadResult> {
const fetchFn = params.fetchFn ?? fetch;
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
// Use "ClawdbotShared" folder to organize bot-uploaded files
const uploadPath = `/ClawdbotShared/${encodeURIComponent(params.filename)}`;
const res = await fetchFn(`${GRAPH_ROOT}/sites/${params.siteId}/drive/root:${uploadPath}:/content`, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": params.contentType ?? "application/octet-stream",
},
body: new Uint8Array(params.buffer),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`SharePoint upload failed: ${res.status} ${res.statusText} - ${body}`);
}
const data = (await res.json()) as {
id?: string;
webUrl?: string;
name?: string;
};
if (!data.id || !data.webUrl || !data.name) {
throw new Error("SharePoint upload response missing required fields");
}
return {
id: data.id,
webUrl: data.webUrl,
name: data.name,
};
}
export interface ChatMember {
aadObjectId: string;
displayName?: string;
}
/**
* Properties needed for native Teams file card attachments.
* The eTag is used as the attachment ID and webDavUrl as the contentUrl.
*/
export interface DriveItemProperties {
/** The eTag of the driveItem (used as attachment ID) */
eTag: string;
/** The WebDAV URL of the driveItem (used as contentUrl for reference attachment) */
webDavUrl: string;
/** The filename */
name: string;
}
/**
* Get driveItem properties needed for native Teams file card attachments.
* This fetches the eTag and webDavUrl which are required for "reference" type attachments.
*
* @param params.siteId - SharePoint site ID
* @param params.itemId - The driveItem ID (returned from upload)
*/
export async function getDriveItemProperties(params: {
siteId: string;
itemId: string;
tokenProvider: MSTeamsAccessTokenProvider;
fetchFn?: typeof fetch;
}): Promise<DriveItemProperties> {
const fetchFn = params.fetchFn ?? fetch;
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
const res = await fetchFn(
`${GRAPH_ROOT}/sites/${params.siteId}/drive/items/${params.itemId}?$select=eTag,webDavUrl,name`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Get driveItem properties failed: ${res.status} ${res.statusText} - ${body}`);
}
const data = (await res.json()) as {
eTag?: string;
webDavUrl?: string;
name?: string;
};
if (!data.eTag || !data.webDavUrl || !data.name) {
throw new Error("DriveItem response missing required properties (eTag, webDavUrl, or name)");
}
return {
eTag: data.eTag,
webDavUrl: data.webDavUrl,
name: data.name,
};
}
/**
* Get members of a Teams chat for per-user sharing.
* Used to create sharing links scoped to only the chat participants.
*/
export async function getChatMembers(params: {
chatId: string;
tokenProvider: MSTeamsAccessTokenProvider;
fetchFn?: typeof fetch;
}): Promise<ChatMember[]> {
const fetchFn = params.fetchFn ?? fetch;
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
const res = await fetchFn(`${GRAPH_ROOT}/chats/${params.chatId}/members`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Get chat members failed: ${res.status} ${res.statusText} - ${body}`);
}
const data = (await res.json()) as {
value?: Array<{
userId?: string;
displayName?: string;
}>;
};
return (data.value ?? [])
.map((m) => ({
aadObjectId: m.userId ?? "",
displayName: m.displayName,
}))
.filter((m) => m.aadObjectId);
}
/**
* Create a sharing link for a SharePoint drive item.
* For organization scope (default), uses v1.0 API.
* For per-user scope, uses beta API with recipients.
*/
export async function createSharePointSharingLink(params: {
siteId: string;
itemId: string;
tokenProvider: MSTeamsAccessTokenProvider;
/** Sharing scope: "organization" (default) or "users" (per-user with recipients) */
scope?: "organization" | "users";
/** Required when scope is "users": AAD object IDs of recipients */
recipientObjectIds?: string[];
fetchFn?: typeof fetch;
}): Promise<OneDriveSharingLink> {
const fetchFn = params.fetchFn ?? fetch;
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
const scope = params.scope ?? "organization";
// Per-user sharing requires beta API
const apiRoot = scope === "users" ? GRAPH_BETA : GRAPH_ROOT;
const body: Record<string, unknown> = {
type: "view",
scope: scope === "users" ? "users" : "organization",
};
// Add recipients for per-user sharing
if (scope === "users" && params.recipientObjectIds?.length) {
body.recipients = params.recipientObjectIds.map((id) => ({ objectId: id }));
}
const res = await fetchFn(`${apiRoot}/sites/${params.siteId}/drive/items/${params.itemId}/createLink`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
const respBody = await res.text().catch(() => "");
throw new Error(`Create SharePoint sharing link failed: ${res.status} ${res.statusText} - ${respBody}`);
}
const data = (await res.json()) as {
link?: { webUrl?: string };
};
if (!data.link?.webUrl) {
throw new Error("Create SharePoint sharing link response missing webUrl");
}
return {
webUrl: data.link.webUrl,
};
}
/**
* Upload a file to SharePoint and create a sharing link.
*
* For group chats, this creates a per-user sharing link scoped to chat members.
* For channels, this creates an organization-wide sharing link.
*
* @param params.siteId - SharePoint site ID
* @param params.chatId - Optional chat ID for per-user sharing (group chats)
* @param params.usePerUserSharing - Whether to use per-user sharing (requires beta API + Chat.Read.All)
*/
export async function uploadAndShareSharePoint(params: {
buffer: Buffer;
filename: string;
contentType?: string;
tokenProvider: MSTeamsAccessTokenProvider;
siteId: string;
chatId?: string;
usePerUserSharing?: boolean;
fetchFn?: typeof fetch;
}): Promise<{
itemId: string;
webUrl: string;
shareUrl: string;
name: string;
}> {
// 1. Upload file to SharePoint
const uploaded = await uploadToSharePoint({
buffer: params.buffer,
filename: params.filename,
contentType: params.contentType,
tokenProvider: params.tokenProvider,
siteId: params.siteId,
fetchFn: params.fetchFn,
});
// 2. Determine sharing scope
let scope: "organization" | "users" = "organization";
let recipientObjectIds: string[] | undefined;
if (params.usePerUserSharing && params.chatId) {
try {
const members = await getChatMembers({
chatId: params.chatId,
tokenProvider: params.tokenProvider,
fetchFn: params.fetchFn,
});
if (members.length > 0) {
scope = "users";
recipientObjectIds = members.map((m) => m.aadObjectId);
}
} catch {
// Fall back to organization scope if we can't get chat members
// (e.g., missing Chat.Read.All permission)
}
}
// 3. Create sharing link
const shareLink = await createSharePointSharingLink({
siteId: params.siteId,
itemId: uploaded.id,
tokenProvider: params.tokenProvider,
scope,
recipientObjectIds,
fetchFn: params.fetchFn,
});
return {
itemId: uploaded.id,
webUrl: uploaded.webUrl,
shareUrl: shareLink.webUrl,
name: uploaded.name,
};
}

View File

@ -0,0 +1,186 @@
import { describe, expect, it } from "vitest";
import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js";
describe("msteams media-helpers", () => {
describe("getMimeType", () => {
it("detects png from URL", async () => {
expect(await getMimeType("https://example.com/image.png")).toBe("image/png");
});
it("detects jpeg from URL (both extensions)", async () => {
expect(await getMimeType("https://example.com/photo.jpg")).toBe("image/jpeg");
expect(await getMimeType("https://example.com/photo.jpeg")).toBe("image/jpeg");
});
it("detects gif from URL", async () => {
expect(await getMimeType("https://example.com/anim.gif")).toBe("image/gif");
});
it("detects webp from URL", async () => {
expect(await getMimeType("https://example.com/modern.webp")).toBe("image/webp");
});
it("handles URLs with query strings", async () => {
expect(await getMimeType("https://example.com/image.png?v=123")).toBe("image/png");
});
it("handles data URLs", async () => {
expect(await getMimeType("data:image/png;base64,iVBORw0KGgo=")).toBe("image/png");
expect(await getMimeType("data:image/jpeg;base64,/9j/4AAQ")).toBe("image/jpeg");
expect(await getMimeType("data:image/gif;base64,R0lGOD")).toBe("image/gif");
});
it("handles data URLs without base64", async () => {
expect(await getMimeType("data:image/svg+xml,%3Csvg")).toBe("image/svg+xml");
});
it("handles local paths", async () => {
expect(await getMimeType("/tmp/image.png")).toBe("image/png");
expect(await getMimeType("/Users/test/photo.jpg")).toBe("image/jpeg");
});
it("handles tilde paths", async () => {
expect(await getMimeType("~/Downloads/image.gif")).toBe("image/gif");
});
it("defaults to application/octet-stream for unknown extensions", async () => {
expect(await getMimeType("https://example.com/image")).toBe("application/octet-stream");
expect(await getMimeType("https://example.com/image.unknown")).toBe("application/octet-stream");
});
it("is case-insensitive", async () => {
expect(await getMimeType("https://example.com/IMAGE.PNG")).toBe("image/png");
expect(await getMimeType("https://example.com/Photo.JPEG")).toBe("image/jpeg");
});
it("detects document types", async () => {
expect(await getMimeType("https://example.com/doc.pdf")).toBe("application/pdf");
expect(await getMimeType("https://example.com/doc.docx")).toBe(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
);
expect(await getMimeType("https://example.com/spreadsheet.xlsx")).toBe(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
});
});
describe("extractFilename", () => {
it("extracts filename from URL with extension", async () => {
expect(await extractFilename("https://example.com/photo.jpg")).toBe("photo.jpg");
});
it("extracts filename from URL with path", async () => {
expect(await extractFilename("https://example.com/images/2024/photo.png")).toBe("photo.png");
});
it("handles URLs without extension by deriving from MIME", async () => {
// Now defaults to application/octet-stream → .bin fallback
expect(await extractFilename("https://example.com/images/photo")).toBe("photo.bin");
});
it("handles data URLs", async () => {
expect(await extractFilename("data:image/png;base64,iVBORw0KGgo=")).toBe("image.png");
expect(await extractFilename("data:image/jpeg;base64,/9j/4AAQ")).toBe("image.jpg");
});
it("handles document data URLs", async () => {
expect(await extractFilename("data:application/pdf;base64,JVBERi0")).toBe("file.pdf");
});
it("handles local paths", async () => {
expect(await extractFilename("/tmp/screenshot.png")).toBe("screenshot.png");
expect(await extractFilename("/Users/test/photo.jpg")).toBe("photo.jpg");
});
it("handles tilde paths", async () => {
expect(await extractFilename("~/Downloads/image.gif")).toBe("image.gif");
});
it("returns fallback for empty URL", async () => {
expect(await extractFilename("")).toBe("file.bin");
});
it("extracts original filename from embedded pattern", async () => {
// Pattern: {original}---{uuid}.{ext}
expect(
await extractFilename("/media/inbound/report---a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf"),
).toBe("report.pdf");
});
it("extracts original filename with uppercase UUID", async () => {
expect(
await extractFilename("/media/inbound/Document---A1B2C3D4-E5F6-7890-ABCD-EF1234567890.docx"),
).toBe("Document.docx");
});
it("falls back to UUID filename for legacy paths", async () => {
// UUID-only filename (legacy format, no embedded name)
expect(
await extractFilename("/media/inbound/a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf"),
).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf");
});
it("handles --- in filename without valid UUID pattern", async () => {
// foo---bar.txt (bar is not a valid UUID)
expect(await extractFilename("/media/inbound/foo---bar.txt")).toBe("foo---bar.txt");
});
});
describe("isLocalPath", () => {
it("returns true for file:// URLs", () => {
expect(isLocalPath("file:///tmp/image.png")).toBe(true);
expect(isLocalPath("file://localhost/tmp/image.png")).toBe(true);
});
it("returns true for absolute paths", () => {
expect(isLocalPath("/tmp/image.png")).toBe(true);
expect(isLocalPath("/Users/test/photo.jpg")).toBe(true);
});
it("returns true for tilde paths", () => {
expect(isLocalPath("~/Downloads/image.png")).toBe(true);
});
it("returns false for http URLs", () => {
expect(isLocalPath("http://example.com/image.png")).toBe(false);
expect(isLocalPath("https://example.com/image.png")).toBe(false);
});
it("returns false for data URLs", () => {
expect(isLocalPath("data:image/png;base64,iVBORw0KGgo=")).toBe(false);
});
});
describe("extractMessageId", () => {
it("extracts id from valid response", () => {
expect(extractMessageId({ id: "msg123" })).toBe("msg123");
});
it("returns null for missing id", () => {
expect(extractMessageId({ foo: "bar" })).toBeNull();
});
it("returns null for empty id", () => {
expect(extractMessageId({ id: "" })).toBeNull();
});
it("returns null for non-string id", () => {
expect(extractMessageId({ id: 123 })).toBeNull();
expect(extractMessageId({ id: null })).toBeNull();
});
it("returns null for null response", () => {
expect(extractMessageId(null)).toBeNull();
});
it("returns null for undefined response", () => {
expect(extractMessageId(undefined)).toBeNull();
});
it("returns null for non-object response", () => {
expect(extractMessageId("string")).toBeNull();
expect(extractMessageId(123)).toBeNull();
});
});
});

View File

@ -0,0 +1,77 @@
/**
* MIME type detection and filename extraction for MSTeams media attachments.
*/
import path from "node:path";
import {
detectMime,
extensionForMime,
extractOriginalFilename,
getFileExtension,
} from "clawdbot/plugin-sdk";
/**
* Detect MIME type from URL extension or data URL.
* Uses shared MIME detection for consistency with core handling.
*/
export async function getMimeType(url: string): Promise<string> {
// Handle data URLs: data:image/png;base64,...
if (url.startsWith("data:")) {
const match = url.match(/^data:([^;,]+)/);
if (match?.[1]) return match[1];
}
// Use shared MIME detection (extension-based for URLs)
const detected = await detectMime({ filePath: url });
return detected ?? "application/octet-stream";
}
/**
* Extract filename from URL or local path.
* For local paths, extracts original filename if stored with embedded name pattern.
* Falls back to deriving the extension from MIME type when no extension present.
*/
export async function extractFilename(url: string): Promise<string> {
// Handle data URLs: derive extension from MIME
if (url.startsWith("data:")) {
const mime = await getMimeType(url);
const ext = extensionForMime(mime) ?? ".bin";
const prefix = mime.startsWith("image/") ? "image" : "file";
return `${prefix}${ext}`;
}
// Try to extract from URL pathname
try {
const pathname = new URL(url).pathname;
const basename = path.basename(pathname);
const existingExt = getFileExtension(pathname);
if (basename && existingExt) return basename;
// No extension in URL, derive from MIME
const mime = await getMimeType(url);
const ext = extensionForMime(mime) ?? ".bin";
const prefix = mime.startsWith("image/") ? "image" : "file";
return basename ? `${basename}${ext}` : `${prefix}${ext}`;
} catch {
// Local paths - use extractOriginalFilename to extract embedded original name
return extractOriginalFilename(url);
}
}
/**
* Check if a URL refers to a local file path.
*/
export function isLocalPath(url: string): boolean {
return url.startsWith("file://") || url.startsWith("/") || url.startsWith("~");
}
/**
* Extract the message ID from a Bot Framework response.
*/
export function extractMessageId(response: unknown): string | null {
if (!response || typeof response !== "object") return null;
if (!("id" in response)) return null;
const { id } = response as { id?: unknown };
if (typeof id !== "string" || !id) return null;
return id;
}

View File

@ -51,7 +51,7 @@ describe("msteams messenger", () => {
[{ text: "hi", mediaUrl: "https://example.com/a.png" }], [{ text: "hi", mediaUrl: "https://example.com/a.png" }],
{ textChunkLimit: 4000 }, { 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", () => { it("supports inline media mode", () => {
@ -59,7 +59,7 @@ describe("msteams messenger", () => {
[{ text: "hi", mediaUrl: "https://example.com/a.png" }], [{ text: "hi", mediaUrl: "https://example.com/a.png" }],
{ textChunkLimit: 4000, mediaMode: "inline" }, { 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", () => { it("chunks long text when enabled", () => {
@ -101,7 +101,7 @@ describe("msteams messenger", () => {
appId: "app123", appId: "app123",
conversationRef: baseRef, conversationRef: baseRef,
context: ctx, context: ctx,
messages: ["one", "two"], messages: [{ text: "one" }, { text: "two" }],
}); });
expect(sent).toEqual(["one", "two"]); expect(sent).toEqual(["one", "two"]);
@ -129,7 +129,7 @@ describe("msteams messenger", () => {
adapter, adapter,
appId: "app123", appId: "app123",
conversationRef: baseRef, conversationRef: baseRef,
messages: ["hello"], messages: [{ text: "hello" }],
}); });
expect(seen.texts).toEqual(["hello"]); expect(seen.texts).toEqual(["hello"]);
@ -168,7 +168,7 @@ describe("msteams messenger", () => {
appId: "app123", appId: "app123",
conversationRef: baseRef, conversationRef: baseRef,
context: ctx, context: ctx,
messages: ["one"], messages: [{ text: "one" }],
retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 }, retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
onRetry: (e) => retryEvents.push({ nextAttempt: e.nextAttempt, delayMs: e.delayMs }), onRetry: (e) => retryEvents.push({ nextAttempt: e.nextAttempt, delayMs: e.delayMs }),
}); });
@ -196,7 +196,7 @@ describe("msteams messenger", () => {
appId: "app123", appId: "app123",
conversationRef: baseRef, conversationRef: baseRef,
context: ctx, context: ctx,
messages: ["one"], messages: [{ text: "one" }],
retry: { maxAttempts: 3, baseDelayMs: 0, maxDelayMs: 0 }, retry: { maxAttempts: 3, baseDelayMs: 0, maxDelayMs: 0 },
}), }),
).rejects.toMatchObject({ statusCode: 400 }); ).rejects.toMatchObject({ statusCode: 400 });
@ -227,7 +227,7 @@ describe("msteams messenger", () => {
adapter, adapter,
appId: "app123", appId: "app123",
conversationRef: baseRef, conversationRef: baseRef,
messages: ["hello"], messages: [{ text: "hello" }],
retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 }, retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
}); });

View File

@ -1,13 +1,35 @@
import { import {
isSilentReplyText, isSilentReplyText,
loadWebMedia,
type MSTeamsReplyStyle, type MSTeamsReplyStyle,
type ReplyPayload, type ReplyPayload,
SILENT_REPLY_TOKEN, SILENT_REPLY_TOKEN,
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
import type { StoredConversationReference } from "./conversation-store.js"; import type { StoredConversationReference } from "./conversation-store.js";
import { classifyMSTeamsSendError } from "./errors.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"; 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 = { type SendContext = {
sendActivity: (textOrActivity: string | object) => Promise<unknown>; sendActivity: (textOrActivity: string | object) => Promise<unknown>;
}; };
@ -41,6 +63,15 @@ export type MSTeamsReplyRenderOptions = {
mediaMode?: "split" | "inline"; 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 = { export type MSTeamsSendRetryOptions = {
maxAttempts?: number; maxAttempts?: number;
baseDelayMs?: 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( function pushTextMessages(
out: string[], out: MSTeamsRenderedMessage[],
text: string, text: string,
opts: { opts: {
chunkText: boolean; chunkText: boolean;
@ -111,16 +134,17 @@ function pushTextMessages(
for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownText(text, opts.chunkLimit)) { for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownText(text, opts.chunkLimit)) {
const trimmed = chunk.trim(); const trimmed = chunk.trim();
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue; if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
out.push(trimmed); out.push({ text: trimmed });
} }
return; return;
} }
const trimmed = text.trim(); const trimmed = text.trim();
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) return; if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) return;
out.push(trimmed); out.push({ text: trimmed });
} }
function clampMs(value: number, maxMs: number): number { function clampMs(value: number, maxMs: number): number {
if (!Number.isFinite(value) || value < 0) return 0; if (!Number.isFinite(value) || value < 0) return 0;
return Math.min(value, maxMs); return Math.min(value, maxMs);
@ -167,8 +191,8 @@ function shouldRetry(classification: ReturnType<typeof classifyMSTeamsSendError>
export function renderReplyPayloadsToMessages( export function renderReplyPayloadsToMessages(
replies: ReplyPayload[], replies: ReplyPayload[],
options: MSTeamsReplyRenderOptions, options: MSTeamsReplyRenderOptions,
): string[] { ): MSTeamsRenderedMessage[] {
const out: string[] = []; const out: MSTeamsRenderedMessage[] = [];
const chunkLimit = Math.min(options.textChunkLimit, 4000); const chunkLimit = Math.min(options.textChunkLimit, 4000);
const chunkText = options.chunkText !== false; const chunkText = options.chunkText !== false;
const mediaMode = options.mediaMode ?? "split"; const mediaMode = options.mediaMode ?? "split";
@ -185,8 +209,17 @@ export function renderReplyPayloadsToMessages(
} }
if (mediaMode === "inline") { if (mediaMode === "inline") {
const combined = text ? `${text}\n\n${mediaList.join("\n")}` : mediaList.join("\n"); // For inline mode, combine text with first media as attachment
pushTextMessages(out, combined, { chunkText, chunkLimit }); 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; continue;
} }
@ -194,26 +227,142 @@ export function renderReplyPayloadsToMessages(
pushTextMessages(out, text, { chunkText, chunkLimit }); pushTextMessages(out, text, { chunkText, chunkLimit });
for (const mediaUrl of mediaList) { for (const mediaUrl of mediaList) {
if (!mediaUrl) continue; if (!mediaUrl) continue;
out.push(mediaUrl); out.push({ mediaUrl });
} }
} }
return out; return out;
} }
async function buildActivity(
msg: MSTeamsRenderedMessage,
conversationRef: StoredConversationReference,
tokenProvider?: MSTeamsAccessTokenProvider,
sharePointSiteId?: string,
mediaMaxBytes?: number,
): Promise<Record<string, unknown>> {
const activity: Record<string, unknown> = { type: "message" };
if (msg.text) {
activity.text = msg.text;
}
if (msg.mediaUrl) {
let contentUrl = msg.mediaUrl;
let contentType = await getMimeType(msg.mediaUrl);
let fileName = await extractFilename(msg.mediaUrl);
if (isLocalPath(msg.mediaUrl)) {
const maxBytes = mediaMaxBytes ?? MSTEAMS_MAX_MEDIA_BYTES;
const media = await loadWebMedia(msg.mediaUrl, maxBytes);
contentType = media.contentType ?? contentType;
fileName = media.fileName ?? fileName;
// Determine conversation type and file type
// Teams only accepts base64 data URLs for images
const conversationType = conversationRef.conversation?.conversationType?.toLowerCase();
const isPersonal = conversationType === "personal";
const isImage = contentType?.startsWith("image/") ?? false;
if (requiresFileConsent({
conversationType,
contentType,
bufferSize: media.buffer.length,
thresholdBytes: FILE_CONSENT_THRESHOLD_BYTES,
})) {
// Large file or non-image in personal chat: use FileConsentCard flow
const conversationId = conversationRef.conversation?.id ?? "unknown";
const { activity: consentActivity } = prepareFileConsentActivity({
media: { buffer: media.buffer, filename: fileName, contentType },
conversationId,
description: msg.text || undefined,
});
// Return the consent activity (caller sends it)
return consentActivity;
}
if (!isPersonal && !isImage && tokenProvider && sharePointSiteId) {
// Non-image in group chat/channel with SharePoint site configured:
// Upload to SharePoint and use native file card attachment
const chatId = conversationRef.conversation?.id;
// Upload to SharePoint
const uploaded = await uploadAndShareSharePoint({
buffer: media.buffer,
filename: fileName,
contentType,
tokenProvider,
siteId: sharePointSiteId,
chatId: chatId ?? undefined,
usePerUserSharing: conversationType === "groupchat",
});
// Get driveItem properties needed for native file card attachment
const driveItem = await getDriveItemProperties({
siteId: sharePointSiteId,
itemId: uploaded.itemId,
tokenProvider,
});
// Build native Teams file card attachment
const fileCardAttachment = buildTeamsFileInfoCard(driveItem);
activity.attachments = [fileCardAttachment];
return activity;
}
if (!isPersonal && !isImage && tokenProvider) {
// Fallback: no SharePoint site configured, try OneDrive upload
const uploaded = await uploadAndShareOneDrive({
buffer: media.buffer,
filename: fileName,
contentType,
tokenProvider,
});
// Bot Framework doesn't support "reference" attachment type for sending
const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`;
activity.text = msg.text ? `${msg.text}\n\n${fileLink}` : fileLink;
return activity;
}
// Image (any chat): use base64 (works for images in all conversation types)
const base64 = media.buffer.toString("base64");
contentUrl = `data:${media.contentType};base64,${base64}`;
}
activity.attachments = [
{
name: fileName,
contentType,
contentUrl,
},
];
}
return activity;
}
export async function sendMSTeamsMessages(params: { export async function sendMSTeamsMessages(params: {
replyStyle: MSTeamsReplyStyle; replyStyle: MSTeamsReplyStyle;
adapter: MSTeamsAdapter; adapter: MSTeamsAdapter;
appId: string; appId: string;
conversationRef: StoredConversationReference; conversationRef: StoredConversationReference;
context?: SendContext; context?: SendContext;
messages: string[]; messages: MSTeamsRenderedMessage[];
retry?: false | MSTeamsSendRetryOptions; retry?: false | MSTeamsSendRetryOptions;
onRetry?: (event: MSTeamsSendRetryEvent) => void; onRetry?: (event: MSTeamsSendRetryEvent) => void;
/** Token provider for OneDrive/SharePoint uploads in group chats/channels */
tokenProvider?: MSTeamsAccessTokenProvider;
/** SharePoint site ID for file uploads in group chats/channels */
sharePointSiteId?: string;
/** Max media size in bytes. Default: 100MB. */
mediaMaxBytes?: number;
}): Promise<string[]> { }): Promise<string[]> {
const messages = params.messages const messages = params.messages.filter(
.map((m) => (typeof m === "string" ? m : String(m))) (m) => (m.text && m.text.trim().length > 0) || m.mediaUrl,
.filter((m) => m.trim().length > 0); );
if (messages.length === 0) return []; if (messages.length === 0) return [];
const retryOptions = resolveRetryOptions(params.retry); const retryOptions = resolveRetryOptions(params.retry);
@ -259,10 +408,9 @@ export async function sendMSTeamsMessages(params: {
for (const [idx, message] of messages.entries()) { for (const [idx, message] of messages.entries()) {
const response = await sendWithRetry( const response = await sendWithRetry(
async () => async () =>
await ctx.sendActivity({ await ctx.sendActivity(
type: "message", await buildActivity(message, params.conversationRef, params.tokenProvider, params.sharePointSiteId, params.mediaMaxBytes),
text: message, ),
}),
{ messageIndex: idx, messageCount: messages.length }, { messageIndex: idx, messageCount: messages.length },
); );
messageIds.push(extractMessageId(response) ?? "unknown"); messageIds.push(extractMessageId(response) ?? "unknown");
@ -281,10 +429,9 @@ export async function sendMSTeamsMessages(params: {
for (const [idx, message] of messages.entries()) { for (const [idx, message] of messages.entries()) {
const response = await sendWithRetry( const response = await sendWithRetry(
async () => async () =>
await ctx.sendActivity({ await ctx.sendActivity(
type: "message", await buildActivity(message, params.conversationRef, params.tokenProvider, params.sharePointSiteId, params.mediaMaxBytes),
text: message, ),
}),
{ messageIndex: idx, messageCount: messages.length }, { messageIndex: idx, messageCount: messages.length },
); );
messageIds.push(extractMessageId(response) ?? "unknown"); messageIds.push(extractMessageId(response) ?? "unknown");

View File

@ -1,8 +1,14 @@
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
import type { MSTeamsConversationStore } from "./conversation-store.js"; import type { MSTeamsConversationStore } from "./conversation-store.js";
import {
buildFileInfoCard,
parseFileConsentInvoke,
uploadToConsentUrl,
} from "./file-consent.js";
import type { MSTeamsAdapter } from "./messenger.js"; import type { MSTeamsAdapter } from "./messenger.js";
import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js"; import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js";
import type { MSTeamsMonitorLogger } from "./monitor-types.js"; import type { MSTeamsMonitorLogger } from "./monitor-types.js";
import { getPendingUpload, removePendingUpload } from "./pending-uploads.js";
import type { MSTeamsPollStore } from "./polls.js"; import type { MSTeamsPollStore } from "./polls.js";
import type { MSTeamsTurnContext } from "./sdk-types.js"; import type { MSTeamsTurnContext } from "./sdk-types.js";
@ -17,6 +23,7 @@ export type MSTeamsActivityHandler = {
onMembersAdded: ( onMembersAdded: (
handler: (context: unknown, next: () => Promise<void>) => Promise<void>, handler: (context: unknown, next: () => Promise<void>) => Promise<void>,
) => MSTeamsActivityHandler; ) => MSTeamsActivityHandler;
run?: (context: unknown) => Promise<void>;
}; };
export type MSTeamsMessageHandlerDeps = { export type MSTeamsMessageHandlerDeps = {
@ -32,11 +39,109 @@ export type MSTeamsMessageHandlerDeps = {
log: MSTeamsMonitorLogger; log: MSTeamsMonitorLogger;
}; };
/**
* Handle fileConsent/invoke activities for large file uploads.
*/
async function handleFileConsentInvoke(
context: MSTeamsTurnContext,
log: MSTeamsMonitorLogger,
): Promise<boolean> {
const activity = context.activity;
if (activity.type !== "invoke" || activity.name !== "fileConsent/invoke") {
return false;
}
const consentResponse = parseFileConsentInvoke(activity);
if (!consentResponse) {
log.debug("invalid file consent invoke", { value: activity.value });
return false;
}
const uploadId =
typeof consentResponse.context?.uploadId === "string"
? consentResponse.context.uploadId
: undefined;
if (consentResponse.action === "accept" && consentResponse.uploadInfo) {
const pendingFile = getPendingUpload(uploadId);
if (pendingFile) {
log.debug("user accepted file consent, uploading", {
uploadId,
filename: pendingFile.filename,
size: pendingFile.buffer.length,
});
try {
// Upload file to the provided URL
await uploadToConsentUrl({
url: consentResponse.uploadInfo.uploadUrl,
buffer: pendingFile.buffer,
contentType: pendingFile.contentType,
});
// Send confirmation card
const fileInfoCard = buildFileInfoCard({
filename: consentResponse.uploadInfo.name,
contentUrl: consentResponse.uploadInfo.contentUrl,
uniqueId: consentResponse.uploadInfo.uniqueId,
fileType: consentResponse.uploadInfo.fileType,
});
await context.sendActivity({
type: "message",
attachments: [fileInfoCard],
});
log.info("file upload complete", {
uploadId,
filename: consentResponse.uploadInfo.name,
uniqueId: consentResponse.uploadInfo.uniqueId,
});
} catch (err) {
log.debug("file upload failed", { uploadId, error: String(err) });
await context.sendActivity(`File upload failed: ${String(err)}`);
} finally {
removePendingUpload(uploadId);
}
} else {
log.debug("pending file not found for consent", { uploadId });
await context.sendActivity(
"The file upload request has expired. Please try sending the file again.",
);
}
} else {
// User declined
log.debug("user declined file consent", { uploadId });
removePendingUpload(uploadId);
}
return true;
}
export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>( export function registerMSTeamsHandlers<T extends MSTeamsActivityHandler>(
handler: T, handler: T,
deps: MSTeamsMessageHandlerDeps, deps: MSTeamsMessageHandlerDeps,
): T { ): T {
const handleTeamsMessage = createMSTeamsMessageHandler(deps); 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) => { handler.onMessage(async (context, next) => {
try { try {
await handleTeamsMessage(context as MSTeamsTurnContext); await handleTeamsMessage(context as MSTeamsTurnContext);

View File

@ -1,7 +1,7 @@
import { import {
buildMSTeamsGraphMessageUrls, buildMSTeamsGraphMessageUrls,
downloadMSTeamsAttachments,
downloadMSTeamsGraphMedia, downloadMSTeamsGraphMedia,
downloadMSTeamsImageAttachments,
type MSTeamsAccessTokenProvider, type MSTeamsAccessTokenProvider,
type MSTeamsAttachmentLike, type MSTeamsAttachmentLike,
type MSTeamsHtmlAttachmentSummary, type MSTeamsHtmlAttachmentSummary,
@ -24,6 +24,8 @@ export async function resolveMSTeamsInboundMedia(params: {
conversationMessageId?: string; conversationMessageId?: string;
activity: Pick<MSTeamsTurnContext["activity"], "id" | "replyToId" | "channelData">; activity: Pick<MSTeamsTurnContext["activity"], "id" | "replyToId" | "channelData">;
log: MSTeamsLogger; log: MSTeamsLogger;
/** When true, embeds original filename in stored path for later extraction. */
preserveFilenames?: boolean;
}): Promise<MSTeamsInboundMedia[]> { }): Promise<MSTeamsInboundMedia[]> {
const { const {
attachments, attachments,
@ -36,13 +38,15 @@ export async function resolveMSTeamsInboundMedia(params: {
conversationMessageId, conversationMessageId,
activity, activity,
log, log,
preserveFilenames,
} = params; } = params;
let mediaList = await downloadMSTeamsImageAttachments({ let mediaList = await downloadMSTeamsAttachments({
attachments, attachments,
maxBytes, maxBytes,
tokenProvider, tokenProvider,
allowHosts, allowHosts,
preserveFilenames,
}); });
if (mediaList.length === 0) { if (mediaList.length === 0) {
@ -81,6 +85,7 @@ export async function resolveMSTeamsInboundMedia(params: {
tokenProvider, tokenProvider,
maxBytes, maxBytes,
allowHosts, allowHosts,
preserveFilenames,
}); });
attempts.push({ attempts.push({
url: messageUrl, url: messageUrl,
@ -104,7 +109,7 @@ export async function resolveMSTeamsInboundMedia(params: {
} }
if (mediaList.length > 0) { if (mediaList.length > 0) {
log.debug("downloaded image attachments", { count: mediaList.length }); log.debug("downloaded attachments", { count: mediaList.length });
} else if (htmlSummary?.imgTags) { } else if (htmlSummary?.imgTags) {
log.debug("inline images detected but none downloaded", { log.debug("inline images detected but none downloaded", {
imgTags: htmlSummary.imgTags, imgTags: htmlSummary.imgTags,

View File

@ -402,7 +402,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
channelData: activity.channelData, channelData: activity.channelData,
}, },
log, log,
}); preserveFilenames: cfg.media?.preserveFilenames,
});
const mediaPayload = buildMSTeamsMediaPayload(mediaList); const mediaPayload = buildMSTeamsMediaPayload(mediaList);
const envelopeFrom = isDirectMessage ? senderName : conversationType; const envelopeFrom = isDirectMessage ? senderName : conversationType;
@ -476,6 +477,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`); logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
const sharePointSiteId = msteamsCfg?.sharePointSiteId;
const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({ const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({
cfg, cfg,
agentId: route.agentId, agentId: route.agentId,
@ -492,6 +494,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
recordMSTeamsSentMessage(conversationId, id); recordMSTeamsSentMessage(conversationId, id);
} }
}, },
tokenProvider,
sharePointSiteId,
}); });
log.info("dispatching to agent", { sessionKey: route.sessionKey }); log.info("dispatching to agent", { sessionKey: route.sessionKey });

View File

@ -0,0 +1,87 @@
/**
* In-memory storage for files awaiting user consent in the FileConsentCard flow.
*
* When sending large files (>=4MB) in personal chats, Teams requires user consent
* before upload. This module stores the file data temporarily until the user
* accepts or declines, or until the TTL expires.
*/
import crypto from "node:crypto";
export interface PendingUpload {
id: string;
buffer: Buffer;
filename: string;
contentType?: string;
conversationId: string;
createdAt: number;
}
const pendingUploads = new Map<string, PendingUpload>();
/** TTL for pending uploads: 5 minutes */
const PENDING_UPLOAD_TTL_MS = 5 * 60 * 1000;
/**
* Store a file pending user consent.
* Returns the upload ID to include in the FileConsentCard context.
*/
export function storePendingUpload(
upload: Omit<PendingUpload, "id" | "createdAt">,
): string {
const id = crypto.randomUUID();
const entry: PendingUpload = {
...upload,
id,
createdAt: Date.now(),
};
pendingUploads.set(id, entry);
// Auto-cleanup after TTL
setTimeout(() => {
pendingUploads.delete(id);
}, PENDING_UPLOAD_TTL_MS);
return id;
}
/**
* Retrieve a pending upload by ID.
* Returns undefined if not found or expired.
*/
export function getPendingUpload(id?: string): PendingUpload | undefined {
if (!id) return undefined;
const entry = pendingUploads.get(id);
if (!entry) return undefined;
// Check if expired (in case timeout hasn't fired yet)
if (Date.now() - entry.createdAt > PENDING_UPLOAD_TTL_MS) {
pendingUploads.delete(id);
return undefined;
}
return entry;
}
/**
* Remove a pending upload (after successful upload or user decline).
*/
export function removePendingUpload(id?: string): void {
if (id) {
pendingUploads.delete(id);
}
}
/**
* Get the count of pending uploads (for monitoring/debugging).
*/
export function getPendingUploadCount(): number {
return pendingUploads.size;
}
/**
* Clear all pending uploads (for testing).
*/
export function clearPendingUploads(): void {
pendingUploads.clear();
}

View File

@ -76,7 +76,7 @@ export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsRes
| undefined; | undefined;
try { try {
const graphToken = await tokenProvider.getAccessToken( const graphToken = await tokenProvider.getAccessToken(
"https://graph.microsoft.com/.default", "https://graph.microsoft.com",
); );
const accessToken = readAccessToken(graphToken); const accessToken = readAccessToken(graphToken);
const payload = accessToken ? decodeJwtPayload(accessToken) : null; const payload = accessToken ? decodeJwtPayload(accessToken) : null;

View File

@ -1,8 +1,10 @@
import type { import {
ClawdbotConfig, resolveChannelMediaMaxBytes,
MSTeamsReplyStyle, type ClawdbotConfig,
RuntimeEnv, type MSTeamsReplyStyle,
type RuntimeEnv,
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
import type { StoredConversationReference } from "./conversation-store.js"; import type { StoredConversationReference } from "./conversation-store.js";
import { import {
classifyMSTeamsSendError, classifyMSTeamsSendError,
@ -30,6 +32,10 @@ export function createMSTeamsReplyDispatcher(params: {
replyStyle: MSTeamsReplyStyle; replyStyle: MSTeamsReplyStyle;
textLimit: number; textLimit: number;
onSentMessageIds?: (ids: string[]) => void; onSentMessageIds?: (ids: string[]) => void;
/** Token provider for OneDrive/SharePoint uploads in group chats/channels */
tokenProvider?: MSTeamsAccessTokenProvider;
/** SharePoint site ID for file uploads in group chats/channels */
sharePointSiteId?: string;
}) { }) {
const core = getMSTeamsRuntime(); const core = getMSTeamsRuntime();
const sendTypingIndicator = async () => { const sendTypingIndicator = async () => {
@ -52,6 +58,10 @@ export function createMSTeamsReplyDispatcher(params: {
chunkText: true, chunkText: true,
mediaMode: "split", mediaMode: "split",
}); });
const mediaMaxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg,
resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
});
const ids = await sendMSTeamsMessages({ const ids = await sendMSTeamsMessages({
replyStyle: params.replyStyle, replyStyle: params.replyStyle,
adapter: params.adapter, adapter: params.adapter,
@ -67,6 +77,9 @@ export function createMSTeamsReplyDispatcher(params: {
...event, ...event,
}); });
}, },
tokenProvider: params.tokenProvider,
sharePointSiteId: params.sharePointSiteId,
mediaMaxBytes,
}); });
if (ids.length > 0) params.onSentMessageIds?.(ids); if (ids.length > 0) params.onSentMessageIds?.(ids);
}, },

View File

@ -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 { import type {
MSTeamsConversationStore, MSTeamsConversationStore,
StoredConversationReference, StoredConversationReference,
} from "./conversation-store.js"; } from "./conversation-store.js";
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js"; import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
import type { MSTeamsAdapter } from "./messenger.js"; import type { MSTeamsAdapter } from "./messenger.js";
import { getMSTeamsRuntime } from "./runtime.js";
import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js"; import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js";
import { resolveMSTeamsCredentials } from "./token.js"; import { resolveMSTeamsCredentials } from "./token.js";
type GetChildLogger = PluginRuntime["logging"]["getChildLogger"]; export type MSTeamsConversationType = "personal" | "groupChat" | "channel";
let _log: ReturnType<GetChildLogger> | undefined;
const getLog = async (): Promise<ReturnType<GetChildLogger>> => {
if (_log) return _log;
const { getChildLogger } = await import("../logging.js");
_log = getChildLogger({ name: "msteams:send" });
return _log;
};
export type MSTeamsProactiveContext = { export type MSTeamsProactiveContext = {
appId: string; appId: string;
conversationId: string; conversationId: string;
ref: StoredConversationReference; ref: StoredConversationReference;
adapter: MSTeamsAdapter; adapter: MSTeamsAdapter;
log: Awaited<ReturnType<typeof getLog>>; log: ReturnType<PluginRuntime["logging"]["getChildLogger"]>;
/** The type of conversation: personal (1:1), groupChat, or channel */
conversationType: MSTeamsConversationType;
/** Token provider for Graph API / OneDrive operations */
tokenProvider: MSTeamsAccessTokenProvider;
/** SharePoint site ID for file uploads in group chats/channels */
sharePointSiteId?: string;
/** Resolved media max bytes from config (default: 100MB) */
mediaMaxBytes?: number;
}; };
/** /**
@ -110,16 +112,45 @@ export async function resolveMSTeamsSendContext(params: {
} }
const { conversationId, ref } = found; const { 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 { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const adapter = createMSTeamsAdapter(authConfig, sdk); 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 { return {
appId: creds.appId, appId: creds.appId,
conversationId, conversationId,
ref, ref,
adapter: adapter as unknown as MSTeamsAdapter, adapter: adapter as unknown as MSTeamsAdapter,
log, log,
conversationType,
tokenProvider,
sharePointSiteId,
mediaMaxBytes,
}; };
} }

View File

@ -1,18 +1,22 @@
import { loadWebMedia, resolveChannelMediaMaxBytes } from "clawdbot/plugin-sdk";
import type { ClawdbotConfig } 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 { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
import { import {
classifyMSTeamsSendError, classifyMSTeamsSendError,
formatMSTeamsSendErrorHint, formatMSTeamsSendErrorHint,
formatUnknownError, formatUnknownError,
} from "./errors.js"; } from "./errors.js";
import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.js";
import { buildTeamsFileInfoCard } from "./graph-chat.js";
import { import {
buildConversationReference, getDriveItemProperties,
type MSTeamsAdapter, uploadAndShareOneDrive,
sendMSTeamsMessages, uploadAndShareSharePoint,
} from "./messenger.js"; } from "./graph-upload.js";
import { extractFilename, extractMessageId } from "./media-helpers.js";
import { buildConversationReference, sendMSTeamsMessages } from "./messenger.js";
import { buildMSTeamsPollCard } from "./polls.js"; import { buildMSTeamsPollCard } from "./polls.js";
import { resolveMSTeamsSendContext } from "./send-context.js"; import { resolveMSTeamsSendContext, type MSTeamsProactiveContext } from "./send-context.js";
export type SendMSTeamsMessageParams = { export type SendMSTeamsMessageParams = {
/** Full config (for credentials) */ /** Full config (for credentials) */
@ -28,8 +32,19 @@ export type SendMSTeamsMessageParams = {
export type SendMSTeamsMessageResult = { export type SendMSTeamsMessageResult = {
messageId: string; messageId: string;
conversationId: 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 = { export type SendMSTeamsPollParams = {
/** Full config (for credentials) */ /** Full config (for credentials) */
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
@ -49,32 +64,19 @@ export type SendMSTeamsPollResult = {
conversationId: string; conversationId: string;
}; };
function extractMessageId(response: unknown): string | null { export type SendMSTeamsCardParams = {
if (!response || typeof response !== "object") return null; /** Full config (for credentials) */
if (!("id" in response)) return null; cfg: ClawdbotConfig;
const { id } = response as { id?: unknown }; /** Conversation ID or user ID to send to */
if (typeof id !== "string" || !id) return null; to: string;
return id; /** Adaptive Card JSON object */
} card: Record<string, unknown>;
};
async function sendMSTeamsActivity(params: { export type SendMSTeamsCardResult = {
adapter: MSTeamsAdapter; messageId: string;
appId: string; conversationId: string;
conversationRef: StoredConversationReference; };
activity: Record<string, unknown>;
}): Promise<string> {
const baseRef = buildConversationReference(params.conversationRef);
const proactiveRef = {
...baseRef,
activityId: undefined,
};
let messageId = "unknown";
await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => {
const response = await ctx.sendActivity(params.activity);
messageId = extractMessageId(response) ?? "unknown";
});
return messageId;
}
/** /**
* Send a message to a Teams conversation or user. * Send a message to a Teams conversation or user.
@ -82,23 +84,225 @@ async function sendMSTeamsActivity(params: {
* Uses the stored ConversationReference from previous interactions. * Uses the stored ConversationReference from previous interactions.
* The bot must have received at least one message from the conversation * The bot must have received at least one message from the conversation
* before proactive messaging works. * 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( export async function sendMessageMSTeams(
params: SendMSTeamsMessageParams, params: SendMSTeamsMessageParams,
): Promise<SendMSTeamsMessageResult> { ): Promise<SendMSTeamsMessageResult> {
const { cfg, to, text, mediaUrl } = params; const { cfg, to, text, mediaUrl } = params;
const { adapter, appId, conversationId, ref, log } = await resolveMSTeamsSendContext({ const ctx = await resolveMSTeamsSendContext({ cfg, to });
cfg, const { adapter, appId, conversationId, ref, log, conversationType, tokenProvider, sharePointSiteId } = ctx;
to,
});
log.debug("sending proactive message", { log.debug("sending proactive message", {
conversationId, conversationId,
conversationType,
textLength: text.length, textLength: text.length,
hasMedia: Boolean(mediaUrl), hasMedia: Boolean(mediaUrl),
}); });
const message = mediaUrl ? (text ? `${text}\n\n${mediaUrl}` : mediaUrl) : text; // Handle media if present
if (mediaUrl) {
const mediaMaxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
}) ?? MSTEAMS_MAX_MEDIA_BYTES;
const media = await loadWebMedia(mediaUrl, mediaMaxBytes);
const isLargeFile = media.buffer.length >= FILE_CONSENT_THRESHOLD_BYTES;
const isImage = media.contentType?.startsWith("image/") ?? false;
const fallbackFileName = await extractFilename(mediaUrl);
const fileName = media.fileName ?? fallbackFileName;
log.debug("processing media", {
fileName,
contentType: media.contentType,
size: media.buffer.length,
isLargeFile,
isImage,
conversationType,
});
// Personal chats: base64 only works for images; use FileConsentCard for large files or non-images
if (requiresFileConsent({
conversationType,
contentType: media.contentType,
bufferSize: media.buffer.length,
thresholdBytes: FILE_CONSENT_THRESHOLD_BYTES,
})) {
const { activity, uploadId } = prepareFileConsentActivity({
media: { buffer: media.buffer, filename: fileName, contentType: media.contentType },
conversationId,
description: text || undefined,
});
log.debug("sending file consent card", { uploadId, fileName, size: media.buffer.length });
const baseRef = buildConversationReference(ref);
const proactiveRef = { ...baseRef, activityId: undefined };
let messageId = "unknown";
try {
await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => {
const response = await turnCtx.sendActivity(activity);
messageId = extractMessageId(response) ?? "unknown";
});
} catch (err) {
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
throw new Error(
`msteams consent card send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
);
}
log.info("sent file consent card", { conversationId, messageId, uploadId });
return {
messageId,
conversationId,
pendingUploadId: uploadId,
};
}
// Personal chat with small image: use base64 (only works for images)
if (conversationType === "personal") {
// Small image in personal chat: use base64 (only works for images)
const base64 = media.buffer.toString("base64");
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
return sendTextWithMedia(ctx, text, finalMediaUrl);
}
if (isImage && !sharePointSiteId) {
// Group chat/channel without SharePoint: send image inline (avoids OneDrive failures)
const base64 = media.buffer.toString("base64");
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
return sendTextWithMedia(ctx, text, finalMediaUrl);
}
// Group chat or channel: upload to SharePoint (if siteId configured) or OneDrive
try {
if (sharePointSiteId) {
// Use SharePoint upload + Graph API for native file card
log.debug("uploading to SharePoint for native file card", {
fileName,
conversationType,
siteId: sharePointSiteId,
});
const uploaded = await uploadAndShareSharePoint({
buffer: media.buffer,
filename: fileName,
contentType: media.contentType,
tokenProvider,
siteId: sharePointSiteId,
chatId: conversationId,
usePerUserSharing: conversationType === "groupChat",
});
log.debug("SharePoint upload complete", {
itemId: uploaded.itemId,
shareUrl: uploaded.shareUrl,
});
// Get driveItem properties needed for native file card
const driveItem = await getDriveItemProperties({
siteId: sharePointSiteId,
itemId: uploaded.itemId,
tokenProvider,
});
log.debug("driveItem properties retrieved", {
eTag: driveItem.eTag,
webDavUrl: driveItem.webDavUrl,
});
// Build native Teams file card attachment and send via Bot Framework
const fileCardAttachment = buildTeamsFileInfoCard(driveItem);
const activity = {
type: "message",
text: text || undefined,
attachments: [fileCardAttachment],
};
const baseRef = buildConversationReference(ref);
const proactiveRef = { ...baseRef, activityId: undefined };
let messageId = "unknown";
await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => {
const response = await turnCtx.sendActivity(activity);
messageId = extractMessageId(response) ?? "unknown";
});
log.info("sent native file card", {
conversationId,
messageId,
fileName: driveItem.name,
});
return { messageId, conversationId };
}
// Fallback: no SharePoint site configured, use OneDrive with markdown link
log.debug("uploading to OneDrive (no SharePoint site configured)", { fileName, conversationType });
const uploaded = await uploadAndShareOneDrive({
buffer: media.buffer,
filename: fileName,
contentType: media.contentType,
tokenProvider,
});
log.debug("OneDrive upload complete", {
itemId: uploaded.itemId,
shareUrl: uploaded.shareUrl,
});
// Send message with file link (Bot Framework doesn't support "reference" attachment type for sending)
const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`;
const activity = {
type: "message",
text: text ? `${text}\n\n${fileLink}` : fileLink,
};
const baseRef = buildConversationReference(ref);
const proactiveRef = { ...baseRef, activityId: undefined };
let messageId = "unknown";
await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => {
const response = await turnCtx.sendActivity(activity);
messageId = extractMessageId(response) ?? "unknown";
});
log.info("sent message with OneDrive file link", { conversationId, messageId, shareUrl: uploaded.shareUrl });
return { messageId, conversationId };
} catch (err) {
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
throw new Error(
`msteams file send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
);
}
}
// No media: send text only
return sendTextWithMedia(ctx, text, undefined);
}
/**
* Send a text message with optional base64 media URL.
*/
async function sendTextWithMedia(
ctx: MSTeamsProactiveContext,
text: string,
mediaUrl: string | undefined,
): Promise<SendMSTeamsMessageResult> {
const { adapter, appId, conversationId, ref, log, tokenProvider, sharePointSiteId, mediaMaxBytes } = ctx;
let messageIds: string[]; let messageIds: string[];
try { try {
messageIds = await sendMSTeamsMessages({ messageIds = await sendMSTeamsMessages({
@ -106,12 +310,14 @@ export async function sendMessageMSTeams(
adapter, adapter,
appId, appId,
conversationRef: ref, conversationRef: ref,
messages: [message], messages: [{ text: text || undefined, mediaUrl }],
// Enable default retry/backoff for throttling/transient failures.
retry: {}, retry: {},
onRetry: (event) => { onRetry: (event) => {
log.debug("retrying send", { conversationId, ...event }); log.debug("retrying send", { conversationId, ...event });
}, },
tokenProvider,
sharePointSiteId,
mediaMaxBytes,
}); });
} catch (err) { } catch (err) {
const classification = classifyMSTeamsSendError(err); const classification = classifyMSTeamsSendError(err);
@ -121,8 +327,8 @@ export async function sendMessageMSTeams(
`msteams send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`, `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 }); log.info("sent proactive message", { conversationId, messageId });
return { return {
@ -157,7 +363,6 @@ export async function sendPollMSTeams(
const activity = { const activity = {
type: "message", type: "message",
text: pollCard.fallbackText,
attachments: [ attachments: [
{ {
contentType: "application/vnd.microsoft.card.adaptive", 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 { try {
messageId = await sendMSTeamsActivity({ await adapter.continueConversation(appId, proactiveRef, async (ctx) => {
adapter, const response = await ctx.sendActivity(activity);
appId, messageId = extractMessageId(response) ?? "unknown";
conversationRef: ref,
activity,
}); });
} catch (err) { } catch (err) {
const classification = classifyMSTeamsSendError(err); const classification = classifyMSTeamsSendError(err);
@ -192,6 +402,64 @@ export async function sendPollMSTeams(
}; };
} }
/**
* Send an arbitrary Adaptive Card to a Teams conversation or user.
*/
export async function sendAdaptiveCardMSTeams(
params: SendMSTeamsCardParams,
): Promise<SendMSTeamsCardResult> {
const { cfg, to, card } = params;
const { adapter, appId, conversationId, ref, log } = await resolveMSTeamsSendContext({
cfg,
to,
});
log.debug("sending adaptive card", {
conversationId,
cardType: card.type,
cardVersion: card.version,
});
const activity = {
type: "message",
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
content: card,
},
],
};
// Send card via proactive conversation
const baseRef = buildConversationReference(ref);
const proactiveRef = {
...baseRef,
activityId: undefined,
};
let messageId = "unknown";
try {
await adapter.continueConversation(appId, proactiveRef, async (ctx) => {
const response = await ctx.sendActivity(activity);
messageId = extractMessageId(response) ?? "unknown";
});
} catch (err) {
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
throw new Error(
`msteams card send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
);
}
log.info("sent adaptive card", { conversationId, messageId });
return {
messageId,
conversationId,
};
}
/** /**
* List all known conversation references (for debugging/CLI). * List all known conversation references (for debugging/CLI).
*/ */

View File

@ -1,4 +1,6 @@
import { getChannelDock } from "../channels/dock.js";
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.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 { ChannelAgentTool, ChannelMessageActionName } from "../channels/plugins/types.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
@ -46,3 +48,19 @@ export function listChannelAgentTools(params: { cfg?: ClawdbotConfig }): Channel
} }
return tools; 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);
}

View File

@ -5,7 +5,7 @@ import { createAgentSession, SessionManager, SettingsManager } from "@mariozechn
import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js"; import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js";
import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.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 { resolveChannelCapabilities } from "../../config/channel-capabilities.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { getMachineDisplayName } from "../../infra/machine-name.js"; import { getMachineDisplayName } from "../../infra/machine-name.js";
@ -245,6 +245,13 @@ export async function compactEmbeddedPiSession(params: {
channel: runtimeChannel, channel: runtimeChannel,
}) })
: undefined; : undefined;
const messageToolHints = runtimeChannel
? resolveChannelMessageToolHints({
cfg: params.config,
channel: runtimeChannel,
accountId: params.agentAccountId,
})
: undefined;
const runtimeInfo = { const runtimeInfo = {
host: machineName, host: machineName,
@ -287,6 +294,7 @@ export async function compactEmbeddedPiSession(params: {
docsPath: docsPath ?? undefined, docsPath: docsPath ?? undefined,
promptMode, promptMode,
runtimeInfo, runtimeInfo,
messageToolHints,
sandboxInfo, sandboxInfo,
tools, tools,
modelAliasLines: buildModelAliasLines(params.config), modelAliasLines: buildModelAliasLines(params.config),

View File

@ -7,7 +7,10 @@ import { streamSimple } from "@mariozechner/pi-ai";
import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent"; import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js"; 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 { resolveChannelCapabilities } from "../../../config/channel-capabilities.js";
import { getMachineDisplayName } from "../../../infra/machine-name.js"; import { getMachineDisplayName } from "../../../infra/machine-name.js";
import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js"; import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js";
@ -260,6 +263,13 @@ export async function runEmbeddedAttempt(
channel: runtimeChannel, channel: runtimeChannel,
}) })
: undefined; : undefined;
const messageToolHints = runtimeChannel
? resolveChannelMessageToolHints({
cfg: params.config,
channel: runtimeChannel,
accountId: params.agentAccountId,
})
: undefined;
const defaultModelRef = resolveDefaultModelForAgent({ const defaultModelRef = resolveDefaultModelForAgent({
cfg: params.config ?? {}, cfg: params.config ?? {},
@ -305,6 +315,7 @@ export async function runEmbeddedAttempt(
reactionGuidance, reactionGuidance,
promptMode, promptMode,
runtimeInfo, runtimeInfo,
messageToolHints,
sandboxInfo, sandboxInfo,
tools, tools,
modelAliasLines: buildModelAliasLines(params.config), modelAliasLines: buildModelAliasLines(params.config),

View File

@ -35,6 +35,7 @@ export function buildEmbeddedSystemPrompt(params: {
/** Supported message actions for the current channel (e.g., react, edit, unsend) */ /** Supported message actions for the current channel (e.g., react, edit, unsend) */
channelActions?: string[]; channelActions?: string[];
}; };
messageToolHints?: string[];
sandboxInfo?: EmbeddedSandboxInfo; sandboxInfo?: EmbeddedSandboxInfo;
tools: AgentTool[]; tools: AgentTool[];
modelAliasLines: string[]; modelAliasLines: string[];
@ -56,6 +57,7 @@ export function buildEmbeddedSystemPrompt(params: {
reactionGuidance: params.reactionGuidance, reactionGuidance: params.reactionGuidance,
promptMode: params.promptMode, promptMode: params.promptMode,
runtimeInfo: params.runtimeInfo, runtimeInfo: params.runtimeInfo,
messageToolHints: params.messageToolHints,
sandboxInfo: params.sandboxInfo, sandboxInfo: params.sandboxInfo,
toolNames: params.tools.map((tool) => tool.name), toolNames: params.tools.map((tool) => tool.name),
toolSummaries: buildToolSummaryMap(params.tools), toolSummaries: buildToolSummaryMap(params.tools),

View File

@ -85,6 +85,7 @@ function buildMessagingSection(params: {
messageChannelOptions: string; messageChannelOptions: string;
inlineButtonsEnabled: boolean; inlineButtonsEnabled: boolean;
runtimeChannel?: string; runtimeChannel?: string;
messageToolHints?: string[];
}) { }) {
if (params.isMinimal) return []; if (params.isMinimal) return [];
return [ return [
@ -105,6 +106,7 @@ function buildMessagingSection(params: {
: params.runtimeChannel : params.runtimeChannel
? `- Inline buttons not enabled for ${params.runtimeChannel}. If you need them, ask to set ${params.runtimeChannel}.capabilities.inlineButtons ("dm"|"group"|"all"|"allowlist").` ? `- 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) .filter(Boolean)
.join("\n") .join("\n")
@ -159,6 +161,7 @@ export function buildAgentSystemPrompt(params: {
channel?: string; channel?: string;
capabilities?: string[]; capabilities?: string[];
}; };
messageToolHints?: string[];
sandboxInfo?: { sandboxInfo?: {
enabled: boolean; enabled: boolean;
workspaceDir?: string; workspaceDir?: string;
@ -468,6 +471,7 @@ export function buildAgentSystemPrompt(params: {
messageChannelOptions, messageChannelOptions,
inlineButtonsEnabled, inlineButtonsEnabled,
runtimeChannel, runtimeChannel,
messageToolHints: params.messageToolHints,
}), }),
]; ];

View File

@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox";
import { import {
listChannelMessageActions, listChannelMessageActions,
supportsChannelMessageButtons, supportsChannelMessageButtons,
supportsChannelMessageCards,
} from "../../channels/plugins/message-actions.js"; } from "../../channels/plugins/message-actions.js";
import { import {
CHANNEL_MESSAGE_ACTION_NAMES, 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<string, unknown> = { const props: Record<string, unknown> = {
message: Type.Optional(Type.String()), message: Type.Optional(Type.String()),
effectId: Type.Optional( 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.includeButtons) delete props.buttons;
if (!options.includeCards) delete props.card;
return props; return props;
} }
@ -192,7 +203,7 @@ function buildChannelManagementSchema() {
}; };
} }
function buildMessageToolSchemaProps(options: { includeButtons: boolean }) { function buildMessageToolSchemaProps(options: { includeButtons: boolean; includeCards: boolean }) {
return { return {
...buildRoutingSchema(), ...buildRoutingSchema(),
...buildSendSchema(options), ...buildSendSchema(options),
@ -211,7 +222,7 @@ function buildMessageToolSchemaProps(options: { includeButtons: boolean }) {
function buildMessageToolSchemaFromActions( function buildMessageToolSchemaFromActions(
actions: readonly string[], actions: readonly string[],
options: { includeButtons: boolean }, options: { includeButtons: boolean; includeCards: boolean },
) { ) {
const props = buildMessageToolSchemaProps(options); const props = buildMessageToolSchemaProps(options);
return Type.Object({ return Type.Object({
@ -222,6 +233,7 @@ function buildMessageToolSchemaFromActions(
const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, { const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
includeButtons: true, includeButtons: true,
includeCards: true,
}); });
type MessageToolOptions = { type MessageToolOptions = {
@ -238,8 +250,10 @@ type MessageToolOptions = {
function buildMessageToolSchema(cfg: ClawdbotConfig) { function buildMessageToolSchema(cfg: ClawdbotConfig) {
const actions = listChannelMessageActions(cfg); const actions = listChannelMessageActions(cfg);
const includeButtons = supportsChannelMessageButtons(cfg); const includeButtons = supportsChannelMessageButtons(cfg);
const includeCards = supportsChannelMessageCards(cfg);
return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], { return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], {
includeButtons, includeButtons,
includeCards,
}); });
} }

View File

@ -1,7 +1,7 @@
import type { NormalizedUsage } from "../../agents/usage.js"; import type { NormalizedUsage } from "../../agents/usage.js";
import { getChannelDock } from "../../channels/dock.js"; import { getChannelDock } from "../../channels/dock.js";
import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.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 type { ClawdbotConfig } from "../../config/config.js";
import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js";
import { estimateUsageCost, formatTokenCount, formatUsd } from "../../utils/usage-format.js"; import { estimateUsageCost, formatTokenCount, formatUsd } from "../../utils/usage-format.js";
@ -23,7 +23,7 @@ export function buildThreadingToolContext(params: {
if (!config) return {}; if (!config) return {};
const rawProvider = sessionCtx.Provider?.trim().toLowerCase(); const rawProvider = sessionCtx.Provider?.trim().toLowerCase();
if (!rawProvider) return {}; 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) // Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init)
const dock = provider ? getChannelDock(provider) : undefined; const dock = provider ? getChannelDock(provider) : undefined;
if (!dock?.threading?.buildToolContext) { if (!dock?.threading?.buildToolContext) {

View File

@ -22,6 +22,7 @@ import type {
ChannelElevatedAdapter, ChannelElevatedAdapter,
ChannelGroupAdapter, ChannelGroupAdapter,
ChannelId, ChannelId,
ChannelAgentPromptAdapter,
ChannelMentionAdapter, ChannelMentionAdapter,
ChannelPlugin, ChannelPlugin,
ChannelThreadingAdapter, ChannelThreadingAdapter,
@ -51,6 +52,7 @@ export type ChannelDock = {
groups?: ChannelGroupAdapter; groups?: ChannelGroupAdapter;
mentions?: ChannelMentionAdapter; mentions?: ChannelMentionAdapter;
threading?: ChannelThreadingAdapter; threading?: ChannelThreadingAdapter;
agentPrompt?: ChannelAgentPromptAdapter;
}; };
type ChannelDockStreaming = { type ChannelDockStreaming = {
@ -319,6 +321,7 @@ function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock {
groups: plugin.groups, groups: plugin.groups,
mentions: plugin.mentions, mentions: plugin.mentions,
threading: plugin.threading, threading: plugin.threading,
agentPrompt: plugin.agentPrompt,
}; };
} }

View File

@ -21,6 +21,13 @@ export function supportsChannelMessageButtons(cfg: ClawdbotConfig): boolean {
return false; 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( export async function dispatchChannelMessageAction(
ctx: ChannelMessageActionContext, ctx: ChannelMessageActionContext,
): Promise<AgentToolResult<unknown> | null> { ): Promise<AgentToolResult<unknown> | null> {

View File

@ -240,6 +240,10 @@ export type ChannelMessagingAdapter = {
}) => string; }) => string;
}; };
export type ChannelAgentPromptAdapter = {
messageToolHints?: (params: { cfg: ClawdbotConfig; accountId?: string | null }) => string[];
};
export type ChannelDirectoryEntryKind = "user" | "group" | "channel"; export type ChannelDirectoryEntryKind = "user" | "group" | "channel";
export type ChannelDirectoryEntry = { export type ChannelDirectoryEntry = {
@ -281,6 +285,7 @@ export type ChannelMessageActionAdapter = {
listActions?: (params: { cfg: ClawdbotConfig }) => ChannelMessageActionName[]; listActions?: (params: { cfg: ClawdbotConfig }) => ChannelMessageActionName[];
supportsAction?: (params: { action: ChannelMessageActionName }) => boolean; supportsAction?: (params: { action: ChannelMessageActionName }) => boolean;
supportsButtons?: (params: { cfg: ClawdbotConfig }) => boolean; supportsButtons?: (params: { cfg: ClawdbotConfig }) => boolean;
supportsCards?: (params: { cfg: ClawdbotConfig }) => boolean;
extractToolSend?: (params: { args: Record<string, unknown> }) => ChannelToolSend | null; extractToolSend?: (params: { args: Record<string, unknown> }) => ChannelToolSend | null;
handleAction?: (ctx: ChannelMessageActionContext) => Promise<AgentToolResult<unknown>>; handleAction?: (ctx: ChannelMessageActionContext) => Promise<AgentToolResult<unknown>>;
}; };

View File

@ -20,6 +20,7 @@ import type {
ChannelAgentToolFactory, ChannelAgentToolFactory,
ChannelCapabilities, ChannelCapabilities,
ChannelId, ChannelId,
ChannelAgentPromptAdapter,
ChannelMentionAdapter, ChannelMentionAdapter,
ChannelMessageActionAdapter, ChannelMessageActionAdapter,
ChannelMessagingAdapter, ChannelMessagingAdapter,
@ -73,6 +74,7 @@ export type ChannelPlugin<ResolvedAccount = any> = {
streaming?: ChannelStreamingAdapter; streaming?: ChannelStreamingAdapter;
threading?: ChannelThreadingAdapter; threading?: ChannelThreadingAdapter;
messaging?: ChannelMessagingAdapter; messaging?: ChannelMessagingAdapter;
agentPrompt?: ChannelAgentPromptAdapter;
directory?: ChannelDirectoryAdapter; directory?: ChannelDirectoryAdapter;
resolver?: ChannelResolverAdapter; resolver?: ChannelResolverAdapter;
actions?: ChannelMessageActionAdapter; actions?: ChannelMessageActionAdapter;

View File

@ -31,6 +31,7 @@ export type {
export type { export type {
ChannelAccountSnapshot, ChannelAccountSnapshot,
ChannelAccountState, ChannelAccountState,
ChannelAgentPromptAdapter,
ChannelAgentTool, ChannelAgentTool,
ChannelAgentToolFactory, ChannelAgentToolFactory,
ChannelCapabilities, ChannelCapabilities,

View File

@ -5,6 +5,7 @@ import { CHANNEL_TARGET_DESCRIPTION } from "../../../infra/outbound/channel-targ
import { defaultRuntime } from "../../../runtime.js"; import { defaultRuntime } from "../../../runtime.js";
import { createDefaultDeps } from "../../deps.js"; import { createDefaultDeps } from "../../deps.js";
import { runCommandWithRuntime } from "../../cli-utils.js"; import { runCommandWithRuntime } from "../../cli-utils.js";
import { ensurePluginRegistryLoaded } from "../../plugin-registry.js";
export type MessageCliHelpers = { export type MessageCliHelpers = {
withMessageBase: (command: Command) => Command; withMessageBase: (command: Command) => Command;
@ -32,6 +33,7 @@ export function createMessageCliHelpers(
const runMessageAction = async (action: string, opts: Record<string, unknown>) => { const runMessageAction = async (action: string, opts: Record<string, unknown>) => {
setVerbose(Boolean(opts.verbose)); setVerbose(Boolean(opts.verbose));
ensurePluginRegistryLoaded();
const deps = createDefaultDeps(); const deps = createDefaultDeps();
await runCommandWithRuntime( await runCommandWithRuntime(
defaultRuntime, defaultRuntime,

View File

@ -19,6 +19,7 @@ export function registerMessageSendCommand(message: Command, helpers: MessageCli
"--buttons <json>", "--buttons <json>",
"Telegram inline keyboard buttons as JSON (array of button rows)", "Telegram inline keyboard buttons as JSON (array of button rows)",
) )
.option("--card <json>", "Adaptive Card JSON object (when supported by the channel)")
.option("--reply-to <id>", "Reply-to message id") .option("--reply-to <id>", "Reply-to message id")
.option("--thread-id <id>", "Thread id (Telegram forum thread)") .option("--thread-id <id>", "Thread id (Telegram forum thread)")
.option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false), .option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false),

View File

@ -78,4 +78,8 @@ export type MSTeamsConfig = {
replyStyle?: MSTeamsReplyStyle; replyStyle?: MSTeamsReplyStyle;
/** Per-team config. Key is team ID (from the /team/ URL path segment). */ /** Per-team config. Key is team ID (from the /team/ URL path segment). */
teams?: Record<string, MSTeamsTeamConfig>; teams?: Record<string, MSTeamsTeamConfig>;
/** 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;
}; };

View File

@ -599,6 +599,10 @@ export const MSTeamsConfigSchema = z
dms: z.record(z.string(), DmConfigSchema.optional()).optional(), dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
replyStyle: MSTeamsReplyStyleSchema.optional(), replyStyle: MSTeamsReplyStyleSchema.optional(),
teams: z.record(z.string(), MSTeamsTeamSchema.optional()).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() .strict()
.superRefine((value, ctx) => { .superRefine((value, ctx) => {

View File

@ -191,6 +191,12 @@ export const ClawdbotSchema = z
bindings: BindingsSchema, bindings: BindingsSchema,
broadcast: BroadcastSchema, broadcast: BroadcastSchema,
audio: AudioSchema, audio: AudioSchema,
media: z
.object({
preserveFilenames: z.boolean().optional(),
})
.strict()
.optional(),
messages: MessagesSchema, messages: MessagesSchema,
commands: CommandsSchema, commands: CommandsSchema,
session: SessionSchema, session: SessionSchema,

View File

@ -410,6 +410,21 @@ function parseButtonsParam(params: Record<string, unknown>): void {
} }
} }
function parseCardParam(params: Record<string, unknown>): 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<string, unknown>) { async function resolveChannel(cfg: ClawdbotConfig, params: Record<string, unknown>) {
const channelHint = readStringParam(params, "channel"); const channelHint = readStringParam(params, "channel");
const selection = await resolveMessageChannelSelection({ const selection = await resolveMessageChannelSelection({
@ -558,10 +573,15 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
const { cfg, params, channel, accountId, dryRun, gateway, input } = ctx; const { cfg, params, channel, accountId, dryRun, gateway, input } = ctx;
const action: ChannelMessageActionName = "send"; const action: ChannelMessageActionName = "send";
const to = readStringParam(params, "to", { required: true }); const to = readStringParam(params, "to", { required: true });
const mediaHint = readStringParam(params, "media", { trim: false }); // Support media, path, and filePath parameters for attachments
const mediaHint =
readStringParam(params, "media", { trim: false }) ??
readStringParam(params, "path", { trim: false }) ??
readStringParam(params, "filePath", { trim: false });
const hasCard = params.card != null && typeof params.card === "object";
let message = let message =
readStringParam(params, "message", { readStringParam(params, "message", {
required: !mediaHint, required: !mediaHint && !hasCard,
allowEmpty: true, allowEmpty: true,
}) ?? ""; }) ?? "";
@ -570,7 +590,8 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
params.message = message; params.message = message;
if (!params.replyTo && parsed.replyToId) params.replyTo = parsed.replyToId; if (!params.replyTo && parsed.replyToId) params.replyTo = parsed.replyToId;
if (!params.media) { if (!params.media) {
params.media = parsed.mediaUrls?.[0] || parsed.mediaUrl || undefined; // Use path/filePath if media not set, then fall back to parsed directives
params.media = mediaHint || parsed.mediaUrls?.[0] || parsed.mediaUrl || undefined;
} }
message = await maybeApplyCrossContextMarker({ message = await maybeApplyCrossContextMarker({
@ -729,6 +750,7 @@ export async function runMessageAction(
const cfg = input.cfg; const cfg = input.cfg;
const params = { ...input.params }; const params = { ...input.params };
parseButtonsParam(params); parseButtonsParam(params);
parseCardParam(params);
const action = input.action; const action = input.action;
if (action === "broadcast") { if (action === "broadcast") {

View File

@ -32,9 +32,11 @@ const EXT_BY_MIME: Record<string, string> = {
"text/markdown": ".md", "text/markdown": ".md",
}; };
const MIME_BY_EXT: Record<string, string> = Object.fromEntries( const MIME_BY_EXT: Record<string, string> = {
Object.entries(EXT_BY_MIME).map(([mime, ext]) => [ext, mime]), ...Object.fromEntries(Object.entries(EXT_BY_MIME).map(([mime, ext]) => [ext, mime])),
); // Additional extension aliases
".jpeg": "image/jpeg",
};
const AUDIO_FILE_EXTENSIONS = new Set([ const AUDIO_FILE_EXTENSIONS = new Set([
".aac", ".aac",

View File

@ -161,4 +161,114 @@ describe("media store", () => {
expect(path.extname(saved.path)).toBe(".xlsx"); 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<file>: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("---");
});
});
});
}); });

View File

@ -11,6 +11,43 @@ const resolveMediaDir = () => path.join(resolveConfigDir(), "media");
const MAX_BYTES = 5 * 1024 * 1024; // 5MB default const MAX_BYTES = 5 * 1024 * 1024; // 5MB default
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes 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() { export function getMediaDir() {
return resolveMediaDir(); return resolveMediaDir();
} }
@ -152,17 +189,29 @@ export async function saveMediaBuffer(
contentType?: string, contentType?: string,
subdir = "inbound", subdir = "inbound",
maxBytes = MAX_BYTES, maxBytes = MAX_BYTES,
originalFilename?: string,
): Promise<SavedMedia> { ): Promise<SavedMedia> {
if (buffer.byteLength > maxBytes) { if (buffer.byteLength > maxBytes) {
throw new Error(`Media exceeds ${(maxBytes / (1024 * 1024)).toFixed(0)}MB limit`); throw new Error(`Media exceeds ${(maxBytes / (1024 * 1024)).toFixed(0)}MB limit`);
} }
const dir = path.join(resolveMediaDir(), subdir); const dir = path.join(resolveMediaDir(), subdir);
await fs.mkdir(dir, { recursive: true }); await fs.mkdir(dir, { recursive: true });
const baseId = crypto.randomUUID(); const uuid = crypto.randomUUID();
const headerExt = extensionForMime(contentType?.split(";")[0]?.trim() ?? undefined); const headerExt = extensionForMime(contentType?.split(";")[0]?.trim() ?? undefined);
const mime = await detectMime({ buffer, headerMime: contentType }); const mime = await detectMime({ buffer, headerMime: contentType });
const ext = headerExt ?? extensionForMime(mime); const ext = headerExt ?? extensionForMime(mime) ?? "";
const id = ext ? `${baseId}${ext}` : baseId;
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); const dest = path.join(dir, id);
await fs.writeFile(dest, buffer); await fs.writeFile(dest, buffer);
return { id, path: dest, size: buffer.byteLength, contentType: mime }; return { id, path: dest, size: buffer.byteLength, contentType: mime };

View File

@ -19,7 +19,6 @@ describe("plugin-sdk exports", () => {
"writeConfigFile", "writeConfigFile",
"runCommandWithTimeout", "runCommandWithTimeout",
"enqueueSystemEvent", "enqueueSystemEvent",
"detectMime",
"fetchRemoteMedia", "fetchRemoteMedia",
"saveMediaBuffer", "saveMediaBuffer",
"formatAgentEnvelope", "formatAgentEnvelope",

View File

@ -206,6 +206,8 @@ export type {
DiagnosticWebhookProcessedEvent, DiagnosticWebhookProcessedEvent,
DiagnosticWebhookReceivedEvent, DiagnosticWebhookReceivedEvent,
} from "../infra/diagnostic-events.js"; } from "../infra/diagnostic-events.js";
export { detectMime, extensionForMime, getFileExtension } from "../media/mime.js";
export { extractOriginalFilename } from "../media/store.js";
// Channel: Discord // Channel: Discord
export { export {
@ -282,3 +284,6 @@ export { collectWhatsAppStatusIssues } from "../channels/plugins/status-issues/w
// Channel: BlueBubbles // Channel: BlueBubbles
export { collectBlueBubblesStatusIssues } from "../channels/plugins/status-issues/bluebubbles.js"; export { collectBlueBubblesStatusIssues } from "../channels/plugins/status-issues/bluebubbles.js";
// Media utilities
export { loadWebMedia, type WebMediaResult } from "../web/media.js";

View File

@ -4,11 +4,12 @@ import { fileURLToPath } from "node:url";
import { logVerbose, shouldLogVerbose } from "../globals.js"; import { logVerbose, shouldLogVerbose } from "../globals.js";
import { type MediaKind, maxBytesForKind, mediaKindFromMime } from "../media/constants.js"; import { type MediaKind, maxBytesForKind, mediaKindFromMime } from "../media/constants.js";
import { resolveUserPath } from "../utils.js";
import { fetchRemoteMedia } from "../media/fetch.js"; import { fetchRemoteMedia } from "../media/fetch.js";
import { convertHeicToJpeg, resizeToJpeg } from "../media/image-ops.js"; import { convertHeicToJpeg, resizeToJpeg } from "../media/image-ops.js";
import { detectMime, extensionForMime } from "../media/mime.js"; import { detectMime, extensionForMime } from "../media/mime.js";
type WebMediaResult = { export type WebMediaResult = {
buffer: Buffer; buffer: Buffer;
contentType?: string; contentType?: string;
kind: MediaKind; kind: MediaKind;
@ -89,10 +90,9 @@ async function loadWebMediaInternal(
kind: MediaKind; kind: MediaKind;
fileName?: string; fileName?: string;
}): Promise<WebMediaResult> => { }): Promise<WebMediaResult> => {
const cap = // If caller explicitly provides maxBytes, trust it (for channels that handle large files).
maxBytes !== undefined // Otherwise fall back to per-kind defaults.
? Math.min(maxBytes, maxBytesForKind(params.kind)) const cap = maxBytes !== undefined ? maxBytes : maxBytesForKind(params.kind);
: maxBytesForKind(params.kind);
if (params.kind === "image") { if (params.kind === "image") {
const isGif = params.contentType === "image/gif"; const isGif = params.contentType === "image/gif";
if (isGif || !optimizeImages) { if (isGif || !optimizeImages) {
@ -141,6 +141,11 @@ async function loadWebMediaInternal(
return await clampAndFinalize({ buffer, contentType, kind, fileName }); 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 // Local path
const data = await fs.readFile(mediaUrl); const data = await fs.readFile(mediaUrl);
const mime = await detectMime({ buffer: data, filePath: mediaUrl }); const mime = await detectMime({ buffer: data, filePath: mediaUrl });