fix: msteams attachments + plugin prompt hints
Co-authored-by: Christof <10854026+Evizero@users.noreply.github.com>
This commit is contained in:
parent
5fe8c4ab8c
commit
0f7f7bb95f
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
234
extensions/msteams/src/file-consent-helpers.test.ts
Normal file
234
extensions/msteams/src/file-consent-helpers.test.ts
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.js";
|
||||||
|
import * as pendingUploads from "./pending-uploads.js";
|
||||||
|
|
||||||
|
describe("requiresFileConsent", () => {
|
||||||
|
const thresholdBytes = 4 * 1024 * 1024; // 4MB
|
||||||
|
|
||||||
|
it("returns true for personal chat with non-image", () => {
|
||||||
|
expect(
|
||||||
|
requiresFileConsent({
|
||||||
|
conversationType: "personal",
|
||||||
|
contentType: "application/pdf",
|
||||||
|
bufferSize: 1000,
|
||||||
|
thresholdBytes,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for personal chat with large image", () => {
|
||||||
|
expect(
|
||||||
|
requiresFileConsent({
|
||||||
|
conversationType: "personal",
|
||||||
|
contentType: "image/png",
|
||||||
|
bufferSize: 5 * 1024 * 1024, // 5MB
|
||||||
|
thresholdBytes,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for personal chat with small image", () => {
|
||||||
|
expect(
|
||||||
|
requiresFileConsent({
|
||||||
|
conversationType: "personal",
|
||||||
|
contentType: "image/png",
|
||||||
|
bufferSize: 1000,
|
||||||
|
thresholdBytes,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for group chat with large non-image", () => {
|
||||||
|
expect(
|
||||||
|
requiresFileConsent({
|
||||||
|
conversationType: "groupChat",
|
||||||
|
contentType: "application/pdf",
|
||||||
|
bufferSize: 5 * 1024 * 1024,
|
||||||
|
thresholdBytes,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for channel with large non-image", () => {
|
||||||
|
expect(
|
||||||
|
requiresFileConsent({
|
||||||
|
conversationType: "channel",
|
||||||
|
contentType: "application/pdf",
|
||||||
|
bufferSize: 5 * 1024 * 1024,
|
||||||
|
thresholdBytes,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles case-insensitive conversation type", () => {
|
||||||
|
expect(
|
||||||
|
requiresFileConsent({
|
||||||
|
conversationType: "Personal",
|
||||||
|
contentType: "application/pdf",
|
||||||
|
bufferSize: 1000,
|
||||||
|
thresholdBytes,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
requiresFileConsent({
|
||||||
|
conversationType: "PERSONAL",
|
||||||
|
contentType: "application/pdf",
|
||||||
|
bufferSize: 1000,
|
||||||
|
thresholdBytes,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when conversationType is undefined", () => {
|
||||||
|
expect(
|
||||||
|
requiresFileConsent({
|
||||||
|
conversationType: undefined,
|
||||||
|
contentType: "application/pdf",
|
||||||
|
bufferSize: 1000,
|
||||||
|
thresholdBytes,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for personal chat when contentType is undefined (non-image)", () => {
|
||||||
|
expect(
|
||||||
|
requiresFileConsent({
|
||||||
|
conversationType: "personal",
|
||||||
|
contentType: undefined,
|
||||||
|
bufferSize: 1000,
|
||||||
|
thresholdBytes,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for personal chat with file exactly at threshold", () => {
|
||||||
|
expect(
|
||||||
|
requiresFileConsent({
|
||||||
|
conversationType: "personal",
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
bufferSize: thresholdBytes, // exactly 4MB
|
||||||
|
thresholdBytes,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for personal chat with file just below threshold", () => {
|
||||||
|
expect(
|
||||||
|
requiresFileConsent({
|
||||||
|
conversationType: "personal",
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
bufferSize: thresholdBytes - 1, // 4MB - 1 byte
|
||||||
|
thresholdBytes,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("prepareFileConsentActivity", () => {
|
||||||
|
const mockUploadId = "test-upload-id-123";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(pendingUploads, "storePendingUpload").mockReturnValue(mockUploadId);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates activity with consent card attachment", () => {
|
||||||
|
const result = prepareFileConsentActivity({
|
||||||
|
media: {
|
||||||
|
buffer: Buffer.from("test content"),
|
||||||
|
filename: "test.pdf",
|
||||||
|
contentType: "application/pdf",
|
||||||
|
},
|
||||||
|
conversationId: "conv123",
|
||||||
|
description: "My file",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.uploadId).toBe(mockUploadId);
|
||||||
|
expect(result.activity.type).toBe("message");
|
||||||
|
expect(result.activity.attachments).toHaveLength(1);
|
||||||
|
|
||||||
|
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, unknown>;
|
||||||
|
expect(attachment.contentType).toBe("application/vnd.microsoft.teams.card.file.consent");
|
||||||
|
expect(attachment.name).toBe("test.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores pending upload with correct data", () => {
|
||||||
|
const buffer = Buffer.from("test content");
|
||||||
|
prepareFileConsentActivity({
|
||||||
|
media: {
|
||||||
|
buffer,
|
||||||
|
filename: "test.pdf",
|
||||||
|
contentType: "application/pdf",
|
||||||
|
},
|
||||||
|
conversationId: "conv123",
|
||||||
|
description: "My file",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pendingUploads.storePendingUpload).toHaveBeenCalledWith({
|
||||||
|
buffer,
|
||||||
|
filename: "test.pdf",
|
||||||
|
contentType: "application/pdf",
|
||||||
|
conversationId: "conv123",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses default description when not provided", () => {
|
||||||
|
const result = prepareFileConsentActivity({
|
||||||
|
media: {
|
||||||
|
buffer: Buffer.from("test"),
|
||||||
|
filename: "document.docx",
|
||||||
|
contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
},
|
||||||
|
conversationId: "conv456",
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, { description: string }>;
|
||||||
|
expect(attachment.content.description).toBe("File: document.docx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses provided description", () => {
|
||||||
|
const result = prepareFileConsentActivity({
|
||||||
|
media: {
|
||||||
|
buffer: Buffer.from("test"),
|
||||||
|
filename: "report.pdf",
|
||||||
|
contentType: "application/pdf",
|
||||||
|
},
|
||||||
|
conversationId: "conv789",
|
||||||
|
description: "Q4 Financial Report",
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, { description: string }>;
|
||||||
|
expect(attachment.content.description).toBe("Q4 Financial Report");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes uploadId in consent card context", () => {
|
||||||
|
const result = prepareFileConsentActivity({
|
||||||
|
media: {
|
||||||
|
buffer: Buffer.from("test"),
|
||||||
|
filename: "file.txt",
|
||||||
|
contentType: "text/plain",
|
||||||
|
},
|
||||||
|
conversationId: "conv000",
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachment = (result.activity.attachments as unknown[])[0] as Record<string, { acceptContext: { uploadId: string } }>;
|
||||||
|
expect(attachment.content.acceptContext.uploadId).toBe(mockUploadId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles media without contentType", () => {
|
||||||
|
const result = prepareFileConsentActivity({
|
||||||
|
media: {
|
||||||
|
buffer: Buffer.from("binary data"),
|
||||||
|
filename: "unknown.bin",
|
||||||
|
},
|
||||||
|
conversationId: "conv111",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.uploadId).toBe(mockUploadId);
|
||||||
|
expect(result.activity.type).toBe("message");
|
||||||
|
});
|
||||||
|
});
|
||||||
73
extensions/msteams/src/file-consent-helpers.ts
Normal file
73
extensions/msteams/src/file-consent-helpers.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Shared helpers for FileConsentCard flow in MSTeams.
|
||||||
|
*
|
||||||
|
* FileConsentCard is required for:
|
||||||
|
* - Personal (1:1) chats with large files (>=4MB)
|
||||||
|
* - Personal chats with non-image files (PDFs, documents, etc.)
|
||||||
|
*
|
||||||
|
* This module consolidates the logic used by both send.ts (proactive sends)
|
||||||
|
* and messenger.ts (reply path) to avoid duplication.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { buildFileConsentCard } from "./file-consent.js";
|
||||||
|
import { storePendingUpload } from "./pending-uploads.js";
|
||||||
|
|
||||||
|
export type FileConsentMedia = {
|
||||||
|
buffer: Buffer;
|
||||||
|
filename: string;
|
||||||
|
contentType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileConsentActivityResult = {
|
||||||
|
activity: Record<string, unknown>;
|
||||||
|
uploadId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare a FileConsentCard activity for large files or non-images in personal chats.
|
||||||
|
* Returns the activity object and uploadId - caller is responsible for sending.
|
||||||
|
*/
|
||||||
|
export function prepareFileConsentActivity(params: {
|
||||||
|
media: FileConsentMedia;
|
||||||
|
conversationId: string;
|
||||||
|
description?: string;
|
||||||
|
}): FileConsentActivityResult {
|
||||||
|
const { media, conversationId, description } = params;
|
||||||
|
|
||||||
|
const uploadId = storePendingUpload({
|
||||||
|
buffer: media.buffer,
|
||||||
|
filename: media.filename,
|
||||||
|
contentType: media.contentType,
|
||||||
|
conversationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const consentCard = buildFileConsentCard({
|
||||||
|
filename: media.filename,
|
||||||
|
description: description || `File: ${media.filename}`,
|
||||||
|
sizeInBytes: media.buffer.length,
|
||||||
|
context: { uploadId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const activity: Record<string, unknown> = {
|
||||||
|
type: "message",
|
||||||
|
attachments: [consentCard],
|
||||||
|
};
|
||||||
|
|
||||||
|
return { activity, uploadId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file requires FileConsentCard flow.
|
||||||
|
* True for: personal chat AND (large file OR non-image)
|
||||||
|
*/
|
||||||
|
export function requiresFileConsent(params: {
|
||||||
|
conversationType: string | undefined;
|
||||||
|
contentType: string | undefined;
|
||||||
|
bufferSize: number;
|
||||||
|
thresholdBytes: number;
|
||||||
|
}): boolean {
|
||||||
|
const isPersonal = params.conversationType?.toLowerCase() === "personal";
|
||||||
|
const isImage = params.contentType?.startsWith("image/") ?? false;
|
||||||
|
const isLargeFile = params.bufferSize >= params.thresholdBytes;
|
||||||
|
return isPersonal && (isLargeFile || !isImage);
|
||||||
|
}
|
||||||
122
extensions/msteams/src/file-consent.ts
Normal file
122
extensions/msteams/src/file-consent.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* FileConsentCard utilities for MS Teams large file uploads (>4MB) in personal chats.
|
||||||
|
*
|
||||||
|
* Teams requires user consent before the bot can upload large files. This module provides
|
||||||
|
* utilities for:
|
||||||
|
* - Building FileConsentCard attachments (to request upload permission)
|
||||||
|
* - Building FileInfoCard attachments (to confirm upload completion)
|
||||||
|
* - Parsing fileConsent/invoke activities
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FileConsentCardParams {
|
||||||
|
filename: string;
|
||||||
|
description?: string;
|
||||||
|
sizeInBytes: number;
|
||||||
|
/** Custom context data to include in the card (passed back in the invoke) */
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileInfoCardParams {
|
||||||
|
filename: string;
|
||||||
|
contentUrl: string;
|
||||||
|
uniqueId: string;
|
||||||
|
fileType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a FileConsentCard attachment for requesting upload permission.
|
||||||
|
* Use this for files >= 4MB in personal (1:1) chats.
|
||||||
|
*/
|
||||||
|
export function buildFileConsentCard(params: FileConsentCardParams) {
|
||||||
|
return {
|
||||||
|
contentType: "application/vnd.microsoft.teams.card.file.consent",
|
||||||
|
name: params.filename,
|
||||||
|
content: {
|
||||||
|
description: params.description ?? `File: ${params.filename}`,
|
||||||
|
sizeInBytes: params.sizeInBytes,
|
||||||
|
acceptContext: { filename: params.filename, ...params.context },
|
||||||
|
declineContext: { filename: params.filename, ...params.context },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a FileInfoCard attachment for confirming upload completion.
|
||||||
|
* Send this after successfully uploading the file to the consent URL.
|
||||||
|
*/
|
||||||
|
export function buildFileInfoCard(params: FileInfoCardParams) {
|
||||||
|
return {
|
||||||
|
contentType: "application/vnd.microsoft.teams.card.file.info",
|
||||||
|
contentUrl: params.contentUrl,
|
||||||
|
name: params.filename,
|
||||||
|
content: {
|
||||||
|
uniqueId: params.uniqueId,
|
||||||
|
fileType: params.fileType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileConsentUploadInfo {
|
||||||
|
name: string;
|
||||||
|
uploadUrl: string;
|
||||||
|
contentUrl: string;
|
||||||
|
uniqueId: string;
|
||||||
|
fileType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileConsentResponse {
|
||||||
|
action: "accept" | "decline";
|
||||||
|
uploadInfo?: FileConsentUploadInfo;
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a fileConsent/invoke activity.
|
||||||
|
* Returns null if the activity is not a file consent invoke.
|
||||||
|
*/
|
||||||
|
export function parseFileConsentInvoke(activity: {
|
||||||
|
name?: string;
|
||||||
|
value?: unknown;
|
||||||
|
}): FileConsentResponse | null {
|
||||||
|
if (activity.name !== "fileConsent/invoke") return null;
|
||||||
|
|
||||||
|
const value = activity.value as {
|
||||||
|
type?: string;
|
||||||
|
action?: string;
|
||||||
|
uploadInfo?: FileConsentUploadInfo;
|
||||||
|
context?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (value?.type !== "fileUpload") return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: value.action === "accept" ? "accept" : "decline",
|
||||||
|
uploadInfo: value.uploadInfo,
|
||||||
|
context: value.context,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file to the consent URL provided by Teams.
|
||||||
|
* The URL is provided in the fileConsent/invoke response after user accepts.
|
||||||
|
*/
|
||||||
|
export async function uploadToConsentUrl(params: {
|
||||||
|
url: string;
|
||||||
|
buffer: Buffer;
|
||||||
|
contentType?: string;
|
||||||
|
fetchFn?: typeof fetch;
|
||||||
|
}): Promise<void> {
|
||||||
|
const fetchFn = params.fetchFn ?? fetch;
|
||||||
|
const res = await fetchFn(params.url, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": params.contentType ?? "application/octet-stream",
|
||||||
|
"Content-Range": `bytes 0-${params.buffer.length - 1}/${params.buffer.length}`,
|
||||||
|
},
|
||||||
|
body: new Uint8Array(params.buffer),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`File upload to consent URL failed: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
extensions/msteams/src/graph-chat.ts
Normal file
52
extensions/msteams/src/graph-chat.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Native Teams file card attachments for Bot Framework.
|
||||||
|
*
|
||||||
|
* The Bot Framework SDK supports `application/vnd.microsoft.teams.card.file.info`
|
||||||
|
* content type which produces native Teams file cards.
|
||||||
|
*
|
||||||
|
* @see https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DriveItemProperties } from "./graph-upload.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a native Teams file card attachment for Bot Framework.
|
||||||
|
*
|
||||||
|
* This uses the `application/vnd.microsoft.teams.card.file.info` content type
|
||||||
|
* which is supported by Bot Framework and produces native Teams file cards
|
||||||
|
* (the same display as when a user manually shares a file).
|
||||||
|
*
|
||||||
|
* @param file - DriveItem properties from getDriveItemProperties()
|
||||||
|
* @returns Attachment object for Bot Framework sendActivity()
|
||||||
|
*/
|
||||||
|
export function buildTeamsFileInfoCard(file: DriveItemProperties): {
|
||||||
|
contentType: string;
|
||||||
|
contentUrl: string;
|
||||||
|
name: string;
|
||||||
|
content: {
|
||||||
|
uniqueId: string;
|
||||||
|
fileType: string;
|
||||||
|
};
|
||||||
|
} {
|
||||||
|
// Extract unique ID from eTag (remove quotes, braces, and version suffix)
|
||||||
|
// Example eTag formats: "{GUID},version" or "\"{GUID},version\""
|
||||||
|
const rawETag = file.eTag;
|
||||||
|
const uniqueId = rawETag
|
||||||
|
.replace(/^["']|["']$/g, "") // Remove outer quotes
|
||||||
|
.replace(/[{}]/g, "") // Remove curly braces
|
||||||
|
.split(",")[0] ?? rawETag; // Take the GUID part before comma
|
||||||
|
|
||||||
|
// Extract file extension from filename
|
||||||
|
const lastDot = file.name.lastIndexOf(".");
|
||||||
|
const fileType = lastDot >= 0 ? file.name.slice(lastDot + 1).toLowerCase() : "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentType: "application/vnd.microsoft.teams.card.file.info",
|
||||||
|
contentUrl: file.webDavUrl,
|
||||||
|
name: file.name,
|
||||||
|
content: {
|
||||||
|
uniqueId,
|
||||||
|
fileType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
445
extensions/msteams/src/graph-upload.ts
Normal file
445
extensions/msteams/src/graph-upload.ts
Normal file
@ -0,0 +1,445 @@
|
|||||||
|
/**
|
||||||
|
* OneDrive/SharePoint upload utilities for MS Teams file sending.
|
||||||
|
*
|
||||||
|
* For group chats and channels, files are uploaded to SharePoint and shared via a link.
|
||||||
|
* This module provides utilities for:
|
||||||
|
* - Uploading files to OneDrive (personal scope - now deprecated for bot use)
|
||||||
|
* - Uploading files to SharePoint (group/channel scope)
|
||||||
|
* - Creating sharing links (organization-wide or per-user)
|
||||||
|
* - Getting chat members for per-user sharing
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
|
||||||
|
|
||||||
|
const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
|
||||||
|
const GRAPH_BETA = "https://graph.microsoft.com/beta";
|
||||||
|
const GRAPH_SCOPE = "https://graph.microsoft.com/.default";
|
||||||
|
|
||||||
|
export interface OneDriveUploadResult {
|
||||||
|
id: string;
|
||||||
|
webUrl: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file to the user's OneDrive root folder.
|
||||||
|
* For larger files, this uses the simple upload endpoint (up to 4MB).
|
||||||
|
* TODO: For files >4MB, implement resumable upload session.
|
||||||
|
*/
|
||||||
|
export async function uploadToOneDrive(params: {
|
||||||
|
buffer: Buffer;
|
||||||
|
filename: string;
|
||||||
|
contentType?: string;
|
||||||
|
tokenProvider: MSTeamsAccessTokenProvider;
|
||||||
|
fetchFn?: typeof fetch;
|
||||||
|
}): Promise<OneDriveUploadResult> {
|
||||||
|
const fetchFn = params.fetchFn ?? fetch;
|
||||||
|
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
||||||
|
|
||||||
|
// Use "ClawdbotShared" folder to organize bot-uploaded files
|
||||||
|
const uploadPath = `/ClawdbotShared/${encodeURIComponent(params.filename)}`;
|
||||||
|
|
||||||
|
const res = await fetchFn(`${GRAPH_ROOT}/me/drive/root:${uploadPath}:/content`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": params.contentType ?? "application/octet-stream",
|
||||||
|
},
|
||||||
|
body: new Uint8Array(params.buffer),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => "");
|
||||||
|
throw new Error(`OneDrive upload failed: ${res.status} ${res.statusText} - ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
id?: string;
|
||||||
|
webUrl?: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data.id || !data.webUrl || !data.name) {
|
||||||
|
throw new Error("OneDrive upload response missing required fields");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
webUrl: data.webUrl,
|
||||||
|
name: data.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneDriveSharingLink {
|
||||||
|
webUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a sharing link for a OneDrive file.
|
||||||
|
* The link allows organization members to view the file.
|
||||||
|
*/
|
||||||
|
export async function createSharingLink(params: {
|
||||||
|
itemId: string;
|
||||||
|
tokenProvider: MSTeamsAccessTokenProvider;
|
||||||
|
/** Sharing scope: "organization" (default) or "anonymous" */
|
||||||
|
scope?: "organization" | "anonymous";
|
||||||
|
fetchFn?: typeof fetch;
|
||||||
|
}): Promise<OneDriveSharingLink> {
|
||||||
|
const fetchFn = params.fetchFn ?? fetch;
|
||||||
|
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
||||||
|
|
||||||
|
const res = await fetchFn(`${GRAPH_ROOT}/me/drive/items/${params.itemId}/createLink`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: "view",
|
||||||
|
scope: params.scope ?? "organization",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => "");
|
||||||
|
throw new Error(`Create sharing link failed: ${res.status} ${res.statusText} - ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
link?: { webUrl?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data.link?.webUrl) {
|
||||||
|
throw new Error("Create sharing link response missing webUrl");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
webUrl: data.link.webUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file to OneDrive and create a sharing link.
|
||||||
|
* Convenience function for the common case.
|
||||||
|
*/
|
||||||
|
export async function uploadAndShareOneDrive(params: {
|
||||||
|
buffer: Buffer;
|
||||||
|
filename: string;
|
||||||
|
contentType?: string;
|
||||||
|
tokenProvider: MSTeamsAccessTokenProvider;
|
||||||
|
scope?: "organization" | "anonymous";
|
||||||
|
fetchFn?: typeof fetch;
|
||||||
|
}): Promise<{
|
||||||
|
itemId: string;
|
||||||
|
webUrl: string;
|
||||||
|
shareUrl: string;
|
||||||
|
name: string;
|
||||||
|
}> {
|
||||||
|
const uploaded = await uploadToOneDrive({
|
||||||
|
buffer: params.buffer,
|
||||||
|
filename: params.filename,
|
||||||
|
contentType: params.contentType,
|
||||||
|
tokenProvider: params.tokenProvider,
|
||||||
|
fetchFn: params.fetchFn,
|
||||||
|
});
|
||||||
|
|
||||||
|
const shareLink = await createSharingLink({
|
||||||
|
itemId: uploaded.id,
|
||||||
|
tokenProvider: params.tokenProvider,
|
||||||
|
scope: params.scope,
|
||||||
|
fetchFn: params.fetchFn,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
itemId: uploaded.id,
|
||||||
|
webUrl: uploaded.webUrl,
|
||||||
|
shareUrl: shareLink.webUrl,
|
||||||
|
name: uploaded.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SharePoint upload functions for group chats and channels
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file to a SharePoint site.
|
||||||
|
* This is used for group chats and channels where /me/drive doesn't work for bots.
|
||||||
|
*
|
||||||
|
* @param params.siteId - SharePoint site ID (e.g., "contoso.sharepoint.com,guid1,guid2")
|
||||||
|
*/
|
||||||
|
export async function uploadToSharePoint(params: {
|
||||||
|
buffer: Buffer;
|
||||||
|
filename: string;
|
||||||
|
contentType?: string;
|
||||||
|
tokenProvider: MSTeamsAccessTokenProvider;
|
||||||
|
siteId: string;
|
||||||
|
fetchFn?: typeof fetch;
|
||||||
|
}): Promise<OneDriveUploadResult> {
|
||||||
|
const fetchFn = params.fetchFn ?? fetch;
|
||||||
|
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
||||||
|
|
||||||
|
// Use "ClawdbotShared" folder to organize bot-uploaded files
|
||||||
|
const uploadPath = `/ClawdbotShared/${encodeURIComponent(params.filename)}`;
|
||||||
|
|
||||||
|
const res = await fetchFn(`${GRAPH_ROOT}/sites/${params.siteId}/drive/root:${uploadPath}:/content`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": params.contentType ?? "application/octet-stream",
|
||||||
|
},
|
||||||
|
body: new Uint8Array(params.buffer),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => "");
|
||||||
|
throw new Error(`SharePoint upload failed: ${res.status} ${res.statusText} - ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
id?: string;
|
||||||
|
webUrl?: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data.id || !data.webUrl || !data.name) {
|
||||||
|
throw new Error("SharePoint upload response missing required fields");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
webUrl: data.webUrl,
|
||||||
|
name: data.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMember {
|
||||||
|
aadObjectId: string;
|
||||||
|
displayName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Properties needed for native Teams file card attachments.
|
||||||
|
* The eTag is used as the attachment ID and webDavUrl as the contentUrl.
|
||||||
|
*/
|
||||||
|
export interface DriveItemProperties {
|
||||||
|
/** The eTag of the driveItem (used as attachment ID) */
|
||||||
|
eTag: string;
|
||||||
|
/** The WebDAV URL of the driveItem (used as contentUrl for reference attachment) */
|
||||||
|
webDavUrl: string;
|
||||||
|
/** The filename */
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get driveItem properties needed for native Teams file card attachments.
|
||||||
|
* This fetches the eTag and webDavUrl which are required for "reference" type attachments.
|
||||||
|
*
|
||||||
|
* @param params.siteId - SharePoint site ID
|
||||||
|
* @param params.itemId - The driveItem ID (returned from upload)
|
||||||
|
*/
|
||||||
|
export async function getDriveItemProperties(params: {
|
||||||
|
siteId: string;
|
||||||
|
itemId: string;
|
||||||
|
tokenProvider: MSTeamsAccessTokenProvider;
|
||||||
|
fetchFn?: typeof fetch;
|
||||||
|
}): Promise<DriveItemProperties> {
|
||||||
|
const fetchFn = params.fetchFn ?? fetch;
|
||||||
|
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
||||||
|
|
||||||
|
const res = await fetchFn(
|
||||||
|
`${GRAPH_ROOT}/sites/${params.siteId}/drive/items/${params.itemId}?$select=eTag,webDavUrl,name`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => "");
|
||||||
|
throw new Error(`Get driveItem properties failed: ${res.status} ${res.statusText} - ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
eTag?: string;
|
||||||
|
webDavUrl?: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data.eTag || !data.webDavUrl || !data.name) {
|
||||||
|
throw new Error("DriveItem response missing required properties (eTag, webDavUrl, or name)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
eTag: data.eTag,
|
||||||
|
webDavUrl: data.webDavUrl,
|
||||||
|
name: data.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get members of a Teams chat for per-user sharing.
|
||||||
|
* Used to create sharing links scoped to only the chat participants.
|
||||||
|
*/
|
||||||
|
export async function getChatMembers(params: {
|
||||||
|
chatId: string;
|
||||||
|
tokenProvider: MSTeamsAccessTokenProvider;
|
||||||
|
fetchFn?: typeof fetch;
|
||||||
|
}): Promise<ChatMember[]> {
|
||||||
|
const fetchFn = params.fetchFn ?? fetch;
|
||||||
|
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
||||||
|
|
||||||
|
const res = await fetchFn(`${GRAPH_ROOT}/chats/${params.chatId}/members`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => "");
|
||||||
|
throw new Error(`Get chat members failed: ${res.status} ${res.statusText} - ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
value?: Array<{
|
||||||
|
userId?: string;
|
||||||
|
displayName?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (data.value ?? [])
|
||||||
|
.map((m) => ({
|
||||||
|
aadObjectId: m.userId ?? "",
|
||||||
|
displayName: m.displayName,
|
||||||
|
}))
|
||||||
|
.filter((m) => m.aadObjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a sharing link for a SharePoint drive item.
|
||||||
|
* For organization scope (default), uses v1.0 API.
|
||||||
|
* For per-user scope, uses beta API with recipients.
|
||||||
|
*/
|
||||||
|
export async function createSharePointSharingLink(params: {
|
||||||
|
siteId: string;
|
||||||
|
itemId: string;
|
||||||
|
tokenProvider: MSTeamsAccessTokenProvider;
|
||||||
|
/** Sharing scope: "organization" (default) or "users" (per-user with recipients) */
|
||||||
|
scope?: "organization" | "users";
|
||||||
|
/** Required when scope is "users": AAD object IDs of recipients */
|
||||||
|
recipientObjectIds?: string[];
|
||||||
|
fetchFn?: typeof fetch;
|
||||||
|
}): Promise<OneDriveSharingLink> {
|
||||||
|
const fetchFn = params.fetchFn ?? fetch;
|
||||||
|
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
||||||
|
const scope = params.scope ?? "organization";
|
||||||
|
|
||||||
|
// Per-user sharing requires beta API
|
||||||
|
const apiRoot = scope === "users" ? GRAPH_BETA : GRAPH_ROOT;
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
type: "view",
|
||||||
|
scope: scope === "users" ? "users" : "organization",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add recipients for per-user sharing
|
||||||
|
if (scope === "users" && params.recipientObjectIds?.length) {
|
||||||
|
body.recipients = params.recipientObjectIds.map((id) => ({ objectId: id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetchFn(`${apiRoot}/sites/${params.siteId}/drive/items/${params.itemId}/createLink`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const respBody = await res.text().catch(() => "");
|
||||||
|
throw new Error(`Create SharePoint sharing link failed: ${res.status} ${res.statusText} - ${respBody}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
link?: { webUrl?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data.link?.webUrl) {
|
||||||
|
throw new Error("Create SharePoint sharing link response missing webUrl");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
webUrl: data.link.webUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file to SharePoint and create a sharing link.
|
||||||
|
*
|
||||||
|
* For group chats, this creates a per-user sharing link scoped to chat members.
|
||||||
|
* For channels, this creates an organization-wide sharing link.
|
||||||
|
*
|
||||||
|
* @param params.siteId - SharePoint site ID
|
||||||
|
* @param params.chatId - Optional chat ID for per-user sharing (group chats)
|
||||||
|
* @param params.usePerUserSharing - Whether to use per-user sharing (requires beta API + Chat.Read.All)
|
||||||
|
*/
|
||||||
|
export async function uploadAndShareSharePoint(params: {
|
||||||
|
buffer: Buffer;
|
||||||
|
filename: string;
|
||||||
|
contentType?: string;
|
||||||
|
tokenProvider: MSTeamsAccessTokenProvider;
|
||||||
|
siteId: string;
|
||||||
|
chatId?: string;
|
||||||
|
usePerUserSharing?: boolean;
|
||||||
|
fetchFn?: typeof fetch;
|
||||||
|
}): Promise<{
|
||||||
|
itemId: string;
|
||||||
|
webUrl: string;
|
||||||
|
shareUrl: string;
|
||||||
|
name: string;
|
||||||
|
}> {
|
||||||
|
// 1. Upload file to SharePoint
|
||||||
|
const uploaded = await uploadToSharePoint({
|
||||||
|
buffer: params.buffer,
|
||||||
|
filename: params.filename,
|
||||||
|
contentType: params.contentType,
|
||||||
|
tokenProvider: params.tokenProvider,
|
||||||
|
siteId: params.siteId,
|
||||||
|
fetchFn: params.fetchFn,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Determine sharing scope
|
||||||
|
let scope: "organization" | "users" = "organization";
|
||||||
|
let recipientObjectIds: string[] | undefined;
|
||||||
|
|
||||||
|
if (params.usePerUserSharing && params.chatId) {
|
||||||
|
try {
|
||||||
|
const members = await getChatMembers({
|
||||||
|
chatId: params.chatId,
|
||||||
|
tokenProvider: params.tokenProvider,
|
||||||
|
fetchFn: params.fetchFn,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (members.length > 0) {
|
||||||
|
scope = "users";
|
||||||
|
recipientObjectIds = members.map((m) => m.aadObjectId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to organization scope if we can't get chat members
|
||||||
|
// (e.g., missing Chat.Read.All permission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create sharing link
|
||||||
|
const shareLink = await createSharePointSharingLink({
|
||||||
|
siteId: params.siteId,
|
||||||
|
itemId: uploaded.id,
|
||||||
|
tokenProvider: params.tokenProvider,
|
||||||
|
scope,
|
||||||
|
recipientObjectIds,
|
||||||
|
fetchFn: params.fetchFn,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
itemId: uploaded.id,
|
||||||
|
webUrl: uploaded.webUrl,
|
||||||
|
shareUrl: shareLink.webUrl,
|
||||||
|
name: uploaded.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
186
extensions/msteams/src/media-helpers.test.ts
Normal file
186
extensions/msteams/src/media-helpers.test.ts
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js";
|
||||||
|
|
||||||
|
describe("msteams media-helpers", () => {
|
||||||
|
describe("getMimeType", () => {
|
||||||
|
it("detects png from URL", async () => {
|
||||||
|
expect(await getMimeType("https://example.com/image.png")).toBe("image/png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects jpeg from URL (both extensions)", async () => {
|
||||||
|
expect(await getMimeType("https://example.com/photo.jpg")).toBe("image/jpeg");
|
||||||
|
expect(await getMimeType("https://example.com/photo.jpeg")).toBe("image/jpeg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects gif from URL", async () => {
|
||||||
|
expect(await getMimeType("https://example.com/anim.gif")).toBe("image/gif");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects webp from URL", async () => {
|
||||||
|
expect(await getMimeType("https://example.com/modern.webp")).toBe("image/webp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles URLs with query strings", async () => {
|
||||||
|
expect(await getMimeType("https://example.com/image.png?v=123")).toBe("image/png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles data URLs", async () => {
|
||||||
|
expect(await getMimeType("data:image/png;base64,iVBORw0KGgo=")).toBe("image/png");
|
||||||
|
expect(await getMimeType("data:image/jpeg;base64,/9j/4AAQ")).toBe("image/jpeg");
|
||||||
|
expect(await getMimeType("data:image/gif;base64,R0lGOD")).toBe("image/gif");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles data URLs without base64", async () => {
|
||||||
|
expect(await getMimeType("data:image/svg+xml,%3Csvg")).toBe("image/svg+xml");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles local paths", async () => {
|
||||||
|
expect(await getMimeType("/tmp/image.png")).toBe("image/png");
|
||||||
|
expect(await getMimeType("/Users/test/photo.jpg")).toBe("image/jpeg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles tilde paths", async () => {
|
||||||
|
expect(await getMimeType("~/Downloads/image.gif")).toBe("image/gif");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to application/octet-stream for unknown extensions", async () => {
|
||||||
|
expect(await getMimeType("https://example.com/image")).toBe("application/octet-stream");
|
||||||
|
expect(await getMimeType("https://example.com/image.unknown")).toBe("application/octet-stream");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive", async () => {
|
||||||
|
expect(await getMimeType("https://example.com/IMAGE.PNG")).toBe("image/png");
|
||||||
|
expect(await getMimeType("https://example.com/Photo.JPEG")).toBe("image/jpeg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects document types", async () => {
|
||||||
|
expect(await getMimeType("https://example.com/doc.pdf")).toBe("application/pdf");
|
||||||
|
expect(await getMimeType("https://example.com/doc.docx")).toBe(
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
);
|
||||||
|
expect(await getMimeType("https://example.com/spreadsheet.xlsx")).toBe(
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractFilename", () => {
|
||||||
|
it("extracts filename from URL with extension", async () => {
|
||||||
|
expect(await extractFilename("https://example.com/photo.jpg")).toBe("photo.jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts filename from URL with path", async () => {
|
||||||
|
expect(await extractFilename("https://example.com/images/2024/photo.png")).toBe("photo.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles URLs without extension by deriving from MIME", async () => {
|
||||||
|
// Now defaults to application/octet-stream → .bin fallback
|
||||||
|
expect(await extractFilename("https://example.com/images/photo")).toBe("photo.bin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles data URLs", async () => {
|
||||||
|
expect(await extractFilename("data:image/png;base64,iVBORw0KGgo=")).toBe("image.png");
|
||||||
|
expect(await extractFilename("data:image/jpeg;base64,/9j/4AAQ")).toBe("image.jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles document data URLs", async () => {
|
||||||
|
expect(await extractFilename("data:application/pdf;base64,JVBERi0")).toBe("file.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles local paths", async () => {
|
||||||
|
expect(await extractFilename("/tmp/screenshot.png")).toBe("screenshot.png");
|
||||||
|
expect(await extractFilename("/Users/test/photo.jpg")).toBe("photo.jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles tilde paths", async () => {
|
||||||
|
expect(await extractFilename("~/Downloads/image.gif")).toBe("image.gif");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns fallback for empty URL", async () => {
|
||||||
|
expect(await extractFilename("")).toBe("file.bin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts original filename from embedded pattern", async () => {
|
||||||
|
// Pattern: {original}---{uuid}.{ext}
|
||||||
|
expect(
|
||||||
|
await extractFilename("/media/inbound/report---a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf"),
|
||||||
|
).toBe("report.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts original filename with uppercase UUID", async () => {
|
||||||
|
expect(
|
||||||
|
await extractFilename("/media/inbound/Document---A1B2C3D4-E5F6-7890-ABCD-EF1234567890.docx"),
|
||||||
|
).toBe("Document.docx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to UUID filename for legacy paths", async () => {
|
||||||
|
// UUID-only filename (legacy format, no embedded name)
|
||||||
|
expect(
|
||||||
|
await extractFilename("/media/inbound/a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf"),
|
||||||
|
).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles --- in filename without valid UUID pattern", async () => {
|
||||||
|
// foo---bar.txt (bar is not a valid UUID)
|
||||||
|
expect(await extractFilename("/media/inbound/foo---bar.txt")).toBe("foo---bar.txt");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isLocalPath", () => {
|
||||||
|
it("returns true for file:// URLs", () => {
|
||||||
|
expect(isLocalPath("file:///tmp/image.png")).toBe(true);
|
||||||
|
expect(isLocalPath("file://localhost/tmp/image.png")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for absolute paths", () => {
|
||||||
|
expect(isLocalPath("/tmp/image.png")).toBe(true);
|
||||||
|
expect(isLocalPath("/Users/test/photo.jpg")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for tilde paths", () => {
|
||||||
|
expect(isLocalPath("~/Downloads/image.png")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for http URLs", () => {
|
||||||
|
expect(isLocalPath("http://example.com/image.png")).toBe(false);
|
||||||
|
expect(isLocalPath("https://example.com/image.png")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for data URLs", () => {
|
||||||
|
expect(isLocalPath("data:image/png;base64,iVBORw0KGgo=")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractMessageId", () => {
|
||||||
|
it("extracts id from valid response", () => {
|
||||||
|
expect(extractMessageId({ id: "msg123" })).toBe("msg123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for missing id", () => {
|
||||||
|
expect(extractMessageId({ foo: "bar" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for empty id", () => {
|
||||||
|
expect(extractMessageId({ id: "" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-string id", () => {
|
||||||
|
expect(extractMessageId({ id: 123 })).toBeNull();
|
||||||
|
expect(extractMessageId({ id: null })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for null response", () => {
|
||||||
|
expect(extractMessageId(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for undefined response", () => {
|
||||||
|
expect(extractMessageId(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-object response", () => {
|
||||||
|
expect(extractMessageId("string")).toBeNull();
|
||||||
|
expect(extractMessageId(123)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
77
extensions/msteams/src/media-helpers.ts
Normal file
77
extensions/msteams/src/media-helpers.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* MIME type detection and filename extraction for MSTeams media attachments.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
detectMime,
|
||||||
|
extensionForMime,
|
||||||
|
extractOriginalFilename,
|
||||||
|
getFileExtension,
|
||||||
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect MIME type from URL extension or data URL.
|
||||||
|
* Uses shared MIME detection for consistency with core handling.
|
||||||
|
*/
|
||||||
|
export async function getMimeType(url: string): Promise<string> {
|
||||||
|
// Handle data URLs: data:image/png;base64,...
|
||||||
|
if (url.startsWith("data:")) {
|
||||||
|
const match = url.match(/^data:([^;,]+)/);
|
||||||
|
if (match?.[1]) return match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use shared MIME detection (extension-based for URLs)
|
||||||
|
const detected = await detectMime({ filePath: url });
|
||||||
|
return detected ?? "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract filename from URL or local path.
|
||||||
|
* For local paths, extracts original filename if stored with embedded name pattern.
|
||||||
|
* Falls back to deriving the extension from MIME type when no extension present.
|
||||||
|
*/
|
||||||
|
export async function extractFilename(url: string): Promise<string> {
|
||||||
|
// Handle data URLs: derive extension from MIME
|
||||||
|
if (url.startsWith("data:")) {
|
||||||
|
const mime = await getMimeType(url);
|
||||||
|
const ext = extensionForMime(mime) ?? ".bin";
|
||||||
|
const prefix = mime.startsWith("image/") ? "image" : "file";
|
||||||
|
return `${prefix}${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract from URL pathname
|
||||||
|
try {
|
||||||
|
const pathname = new URL(url).pathname;
|
||||||
|
const basename = path.basename(pathname);
|
||||||
|
const existingExt = getFileExtension(pathname);
|
||||||
|
if (basename && existingExt) return basename;
|
||||||
|
// No extension in URL, derive from MIME
|
||||||
|
const mime = await getMimeType(url);
|
||||||
|
const ext = extensionForMime(mime) ?? ".bin";
|
||||||
|
const prefix = mime.startsWith("image/") ? "image" : "file";
|
||||||
|
return basename ? `${basename}${ext}` : `${prefix}${ext}`;
|
||||||
|
} catch {
|
||||||
|
// Local paths - use extractOriginalFilename to extract embedded original name
|
||||||
|
return extractOriginalFilename(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a URL refers to a local file path.
|
||||||
|
*/
|
||||||
|
export function isLocalPath(url: string): boolean {
|
||||||
|
return url.startsWith("file://") || url.startsWith("/") || url.startsWith("~");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the message ID from a Bot Framework response.
|
||||||
|
*/
|
||||||
|
export function extractMessageId(response: unknown): string | null {
|
||||||
|
if (!response || typeof response !== "object") return null;
|
||||||
|
if (!("id" in response)) return null;
|
||||||
|
const { id } = response as { id?: unknown };
|
||||||
|
if (typeof id !== "string" || !id) return null;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
@ -51,7 +51,7 @@ describe("msteams messenger", () => {
|
|||||||
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
[{ 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 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
87
extensions/msteams/src/pending-uploads.ts
Normal file
87
extensions/msteams/src/pending-uploads.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* In-memory storage for files awaiting user consent in the FileConsentCard flow.
|
||||||
|
*
|
||||||
|
* When sending large files (>=4MB) in personal chats, Teams requires user consent
|
||||||
|
* before upload. This module stores the file data temporarily until the user
|
||||||
|
* accepts or declines, or until the TTL expires.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
export interface PendingUpload {
|
||||||
|
id: string;
|
||||||
|
buffer: Buffer;
|
||||||
|
filename: string;
|
||||||
|
contentType?: string;
|
||||||
|
conversationId: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingUploads = new Map<string, PendingUpload>();
|
||||||
|
|
||||||
|
/** TTL for pending uploads: 5 minutes */
|
||||||
|
const PENDING_UPLOAD_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a file pending user consent.
|
||||||
|
* Returns the upload ID to include in the FileConsentCard context.
|
||||||
|
*/
|
||||||
|
export function storePendingUpload(
|
||||||
|
upload: Omit<PendingUpload, "id" | "createdAt">,
|
||||||
|
): string {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const entry: PendingUpload = {
|
||||||
|
...upload,
|
||||||
|
id,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
pendingUploads.set(id, entry);
|
||||||
|
|
||||||
|
// Auto-cleanup after TTL
|
||||||
|
setTimeout(() => {
|
||||||
|
pendingUploads.delete(id);
|
||||||
|
}, PENDING_UPLOAD_TTL_MS);
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve a pending upload by ID.
|
||||||
|
* Returns undefined if not found or expired.
|
||||||
|
*/
|
||||||
|
export function getPendingUpload(id?: string): PendingUpload | undefined {
|
||||||
|
if (!id) return undefined;
|
||||||
|
const entry = pendingUploads.get(id);
|
||||||
|
if (!entry) return undefined;
|
||||||
|
|
||||||
|
// Check if expired (in case timeout hasn't fired yet)
|
||||||
|
if (Date.now() - entry.createdAt > PENDING_UPLOAD_TTL_MS) {
|
||||||
|
pendingUploads.delete(id);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a pending upload (after successful upload or user decline).
|
||||||
|
*/
|
||||||
|
export function removePendingUpload(id?: string): void {
|
||||||
|
if (id) {
|
||||||
|
pendingUploads.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the count of pending uploads (for monitoring/debugging).
|
||||||
|
*/
|
||||||
|
export function getPendingUploadCount(): number {
|
||||||
|
return pendingUploads.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all pending uploads (for testing).
|
||||||
|
*/
|
||||||
|
export function clearPendingUploads(): void {
|
||||||
|
pendingUploads.clear();
|
||||||
|
}
|
||||||
@ -76,7 +76,7 @@ export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsRes
|
|||||||
| undefined;
|
| 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;
|
||||||
|
|||||||
@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
@ -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>>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -31,6 +31,7 @@ export type {
|
|||||||
export type {
|
export type {
|
||||||
ChannelAccountSnapshot,
|
ChannelAccountSnapshot,
|
||||||
ChannelAccountState,
|
ChannelAccountState,
|
||||||
|
ChannelAgentPromptAdapter,
|
||||||
ChannelAgentTool,
|
ChannelAgentTool,
|
||||||
ChannelAgentToolFactory,
|
ChannelAgentToolFactory,
|
||||||
ChannelCapabilities,
|
ChannelCapabilities,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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") {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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("---");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -19,7 +19,6 @@ describe("plugin-sdk exports", () => {
|
|||||||
"writeConfigFile",
|
"writeConfigFile",
|
||||||
"runCommandWithTimeout",
|
"runCommandWithTimeout",
|
||||||
"enqueueSystemEvent",
|
"enqueueSystemEvent",
|
||||||
"detectMime",
|
|
||||||
"fetchRemoteMedia",
|
"fetchRemoteMedia",
|
||||||
"saveMediaBuffer",
|
"saveMediaBuffer",
|
||||||
"formatAgentEnvelope",
|
"formatAgentEnvelope",
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user