mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(msteams): recover inbound channel and group-chat files safely (#90738)
* fix(msteams): read file attachments on Teams channel messages
Three bugs blocked reading files attached to channel messages:
- Graph /teams/{id} used channelData.team.id (the 19:..@thread.tacv2 thread id)
instead of team.aadGroupId (the AAD group GUID) -> 400.
- The Graph fetch was gated on an <attachment id> HTML marker that channel
@mention activities don't carry -> fetch skipped.
- A thread reply was fetched at /messages/{replyId} (404) then fell back to the
bare thread root, returning the root's file for every reply. Replies live at
/messages/{root}/replies/{replyId}.
Fixes #89594
* fix(msteams): gate Graph media fallback on text/html stub; run trigger tests in channel context
* fix(msteams): canonicalize Graph attachment recovery
Build one canonical Graph message URL per Teams activity, recover marker-free channel and group-chat file shares, and retain bounded current-main attachment handling.
Co-authored-by: Colton Williams <colton@coltons-apps.tech>
* fix(msteams): canonicalize Graph media recovery
Recover channel and group-chat files through one fail-closed Graph identity path, bound inbound enrichment, and remove invalid app-only Graph fallbacks.
Co-authored-by: Colton Williams <colton@coltons-apps.tech>
Co-authored-by: Joshua Packwood <joshua.packwood@gmail.com>
---------
Co-authored-by: Colton Williams <colton@coltons-apps.tech>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Joshua Packwood <joshua.packwood@gmail.com>
This commit is contained in:
+34
-14
@@ -608,29 +608,48 @@ Adds:
|
||||
|
||||
**Bottom line:** RSC is for real-time listening; Graph API is for historical access. To catch up on missed messages while offline, you need Graph API with `ChannelMessage.Read.All` (requires admin consent).
|
||||
|
||||
## Graph-enabled media + history (required for channels)
|
||||
## Graph-enabled media + history
|
||||
|
||||
For images/files in **channels**, or to fetch **message history**, enable Microsoft Graph permissions and grant admin consent:
|
||||
Enable only the Microsoft Graph application permissions needed for the Teams scopes and data you use:
|
||||
|
||||
1. Entra ID (Azure AD) **App Registration** → add Graph **Application permissions**:
|
||||
- `ChannelMessage.Read.All` (channel attachments + history)
|
||||
- `Chat.Read.All` or `ChatMessage.Read.All` (group chats)
|
||||
- `ChannelMessage.Read.All` for channel attachments and channel history.
|
||||
- `Chat.Read.All` for group-chat attachments and group-chat history.
|
||||
- `Files.Read.All` when attachment bytes must be downloaded from SharePoint/OneDrive storage; history-only setups do not need it.
|
||||
2. **Grant admin consent** for the tenant.
|
||||
3. Bump the Teams app **manifest version**, re-upload, and **reinstall the app in Teams**.
|
||||
4. **Fully quit and relaunch Teams** to clear cached app metadata.
|
||||
|
||||
### Channel/group file recovery (`graphMediaFallback`)
|
||||
|
||||
Teams can remove file markers from the HTML activity sent to a bot. In that case, the Bot Framework activity is indistinguishable from an ordinary HTML message; the complete attachment reference exists only on the Graph copy of the message.
|
||||
|
||||
Enable the fallback after granting the permissions above:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
msteams: {
|
||||
graphMediaFallback: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
This applies to channels and group chats only. It adds one Graph message lookup whenever an HTML activity produced no directly downloadable media, including ordinary or mention-only messages. The default is `false` so existing installations do not gain extra Graph traffic or permission errors automatically.
|
||||
|
||||
**User mentions:** @mentions work out of the box for users already in the conversation. To dynamically search and mention users **not in the current conversation**, add `User.Read.All` (Application) permission and grant admin consent.
|
||||
|
||||
## Known limitations
|
||||
|
||||
### Webhook timeouts
|
||||
|
||||
Teams delivers messages via HTTP webhook. OpenClaw applies fixed HTTP server timeouts to that webhook listener: 30s inactivity, 30s total request, 15s to receive headers. If agent processing takes longer than the client's own retry window, you may see:
|
||||
Teams delivers messages via HTTP webhook. OpenClaw applies fixed HTTP server timeouts to that webhook listener: 30s inactivity, 30s total request, 15s to receive headers. Optional inbound media and context enrichment has a shared 10-second budget, but the Teams SDK still waits for the agent turn before returning the webhook response. If the full turn exceeds Teams' retry window, you may see:
|
||||
|
||||
- Teams retrying the message (causing duplicates).
|
||||
- Dropped replies.
|
||||
|
||||
OpenClaw acks the webhook quickly (before agent processing finishes) and sends replies proactively once the agent responds, but very slow agent runs can still surface retries/duplicates on the Teams side.
|
||||
Replies are sent proactively once the agent responds, but slow agent runs can still surface retries or duplicates on the Teams side.
|
||||
|
||||
### Teams cloud and service URL support
|
||||
|
||||
@@ -709,6 +728,7 @@ Key settings (see [/gateway/configuration](/gateway/configuration) for shared ch
|
||||
- `channels.msteams.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains: Graph, SharePoint/OneDrive, Teams CDN, Bot Framework, Azure Media Services).
|
||||
- `channels.msteams.mediaAuthAllowHosts`: allowlist for attaching Authorization headers on media retries (defaults to Graph + Bot Framework hosts).
|
||||
- `channels.msteams.graphMediaFallback`: opt into Graph message lookups when channel/group HTML omits file markers (default `false`; see [Channel/group file recovery](#channelgroup-file-recovery-graphmediafallback)).
|
||||
- `channels.msteams.mediaMaxMb`: per-channel media size limit override in MB. Falls back to `agents.defaults.mediaMaxMb` when unset.
|
||||
- `channels.msteams.requireMention`: require @mention in channels/groups (default `true`).
|
||||
- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply style](#reply-style-threads-vs-posts)).
|
||||
@@ -817,12 +837,12 @@ Bots can send files in DMs using the built-in FileConsentCard flow. **Sending fi
|
||||
| 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 |
|
||||
| **Group chats/channels** | Upload to SharePoint → native file card | Requires `sharePointSiteId` + Graph permissions |
|
||||
| **Images (any context)** | Base64-encoded inline | Works out of the box |
|
||||
|
||||
### Why group chats need SharePoint
|
||||
|
||||
Bots do not have a personal OneDrive drive (`/me/drive` does not work for application identities). To send files in group chats/channels, the bot uploads to a **SharePoint site** and creates a sharing link.
|
||||
Bots use an application identity, while Microsoft Graph's `/me` resource [requires a signed-in user](https://learn.microsoft.com/en-us/graph/api/user-get?view=graph-rest-1.0). To send files in group chats/channels, the bot uploads to a **SharePoint site** and creates a sharing link.
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -868,12 +888,12 @@ Per-user sharing is more secure since only chat participants can access the file
|
||||
|
||||
### 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) |
|
||||
| Scenario | Result |
|
||||
| ------------------------------------------------- | ------------------------------------------------ |
|
||||
| Group chat + file + `sharePointSiteId` configured | Upload to SharePoint, send a native file card |
|
||||
| Group chat + file + no `sharePointSiteId` | Fail with an actionable configuration error |
|
||||
| Personal chat + file | FileConsentCard flow (works without SharePoint) |
|
||||
| Any context + image | Base64-encoded inline (works without SharePoint) |
|
||||
|
||||
### Files stored location
|
||||
|
||||
|
||||
+2
-1
@@ -684,7 +684,8 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H3: With Teams RSC only (app installed, no Graph API permissions)
|
||||
- H3: With Teams RSC + Microsoft Graph Application permissions
|
||||
- H3: RSC vs Graph API
|
||||
- H2: Graph-enabled media + history (required for channels)
|
||||
- H2: Graph-enabled media + history
|
||||
- H3: Channel/group file recovery (graphMediaFallback)
|
||||
- H2: Known limitations
|
||||
- H3: Webhook timeouts
|
||||
- H3: Teams cloud and service URL support
|
||||
|
||||
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { PluginRuntime } from "../runtime-api.js";
|
||||
import {
|
||||
buildMSTeamsAttachmentPlaceholder,
|
||||
buildMSTeamsGraphMessageUrls,
|
||||
buildMSTeamsGraphMessageUrl,
|
||||
buildMSTeamsMediaPayload,
|
||||
resolveMSTeamsInboundAttachmentPresentation,
|
||||
} from "./attachments.js";
|
||||
@@ -27,7 +27,7 @@ const CONTENT_TYPE_APPLICATION_PDF = "application/pdf";
|
||||
const CONTENT_TYPE_TEXT_HTML = "text/html";
|
||||
const CONTENT_TYPE_TEAMS_FILE_DOWNLOAD_INFO = "application/vnd.microsoft.teams.file.download.info";
|
||||
type AttachmentPlaceholderInput = Parameters<typeof buildMSTeamsAttachmentPlaceholder>[0];
|
||||
type GraphMessageUrlParams = Parameters<typeof buildMSTeamsGraphMessageUrls>[0];
|
||||
type GraphMessageUrlParams = Parameters<typeof buildMSTeamsGraphMessageUrl>[0];
|
||||
type MSTeamsMediaPayload = ReturnType<typeof buildMSTeamsMediaPayload>;
|
||||
|
||||
const runtimeStub = {
|
||||
@@ -77,22 +77,16 @@ const createImageMediaEntries = (...paths: string[]) =>
|
||||
createMediaEntriesWithType(CONTENT_TYPE_IMAGE_PNG, ...paths);
|
||||
const DEFAULT_CHANNEL_TEAM_ID = "team-id";
|
||||
const DEFAULT_CHANNEL_ID = "chan-id";
|
||||
const createChannelGraphMessageUrlParams = (params: {
|
||||
messageId: string;
|
||||
replyToId?: string;
|
||||
conversationId?: string;
|
||||
}) => ({
|
||||
const createChannelGraphMessageUrlParams = (
|
||||
params: Pick<GraphMessageUrlParams, "messageId" | "threadRootMessageId">,
|
||||
) => ({
|
||||
conversationType: "channel" as const,
|
||||
teamAadGroupId: DEFAULT_CHANNEL_TEAM_ID,
|
||||
channelId: DEFAULT_CHANNEL_ID,
|
||||
...params,
|
||||
channelData: {
|
||||
team: { id: DEFAULT_CHANNEL_TEAM_ID },
|
||||
channel: { id: DEFAULT_CHANNEL_ID },
|
||||
},
|
||||
});
|
||||
const buildExpectedChannelMessagePath = (params: { messageId: string; replyToId?: string }) =>
|
||||
params.replyToId
|
||||
? `/teams/${DEFAULT_CHANNEL_TEAM_ID}/channels/${DEFAULT_CHANNEL_ID}/messages/${params.replyToId}/replies/${params.messageId}`
|
||||
: `/teams/${DEFAULT_CHANNEL_TEAM_ID}/channels/${DEFAULT_CHANNEL_ID}/messages/${params.messageId}`;
|
||||
const GRAPH_CHANNEL_MESSAGES_ROOT =
|
||||
"https://graph.microsoft.com/v1.0/teams/team-id/channels/chan-id/messages";
|
||||
|
||||
const expectMSTeamsMediaPayload = (
|
||||
payload: MSTeamsMediaPayload,
|
||||
@@ -147,31 +141,27 @@ const ATTACHMENT_PLACEHOLDER_CASES = [
|
||||
}),
|
||||
];
|
||||
|
||||
const GRAPH_URL_EXPECTATION_CASES = [
|
||||
withLabel("builds channel message urls", {
|
||||
const GRAPH_MESSAGE_URL_CASES = [
|
||||
withLabel("builds a channel top-level message URL", {
|
||||
params: createChannelGraphMessageUrlParams({
|
||||
conversationId: "19:thread@thread.tacv2",
|
||||
messageId: "123",
|
||||
}),
|
||||
expectedPath: buildExpectedChannelMessagePath({ messageId: "123" }),
|
||||
expectedUrl: `${GRAPH_CHANNEL_MESSAGES_ROOT}/123`,
|
||||
}),
|
||||
withLabel("builds channel reply urls when replyToId is present", {
|
||||
withLabel("builds a channel reply URL beneath its thread root", {
|
||||
params: createChannelGraphMessageUrlParams({
|
||||
messageId: "reply-id",
|
||||
replyToId: "root-id",
|
||||
}),
|
||||
expectedPath: buildExpectedChannelMessagePath({
|
||||
messageId: "reply-id",
|
||||
replyToId: "root-id",
|
||||
threadRootMessageId: "root-id",
|
||||
}),
|
||||
expectedUrl: `${GRAPH_CHANNEL_MESSAGES_ROOT}/root-id/replies/reply-id`,
|
||||
}),
|
||||
withLabel("builds chat message urls", {
|
||||
withLabel("builds a chat message URL", {
|
||||
params: {
|
||||
conversationType: "groupChat" as const,
|
||||
conversationId: "19:chat@thread.v2",
|
||||
messageId: "456",
|
||||
} satisfies GraphMessageUrlParams,
|
||||
expectedPath: "/chats/19%3Achat%40thread.v2/messages/456",
|
||||
expectedUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat%40thread.v2/messages/456",
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -261,30 +251,63 @@ describe("msteams attachment helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMSTeamsGraphMessageUrls", () => {
|
||||
it.each(GRAPH_URL_EXPECTATION_CASES)("$label", ({ params, expectedPath }) => {
|
||||
const urls = buildMSTeamsGraphMessageUrls(params);
|
||||
expect(urls[0]).toContain(expectedPath);
|
||||
describe("buildMSTeamsGraphMessageUrl", () => {
|
||||
it.each(GRAPH_MESSAGE_URL_CASES)("$label", ({ params, expectedUrl }) => {
|
||||
expect(buildMSTeamsGraphMessageUrl(params)).toBe(expectedUrl);
|
||||
});
|
||||
|
||||
it("uses resolved Graph chat ID for personal DMs instead of Bot Framework a: ID", () => {
|
||||
const urls = buildMSTeamsGraphMessageUrls({
|
||||
conversationType: "personal",
|
||||
conversationId: "19:real-graph-chat-id@unq.gbl.spaces",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
expect(urls).toHaveLength(1);
|
||||
expect(urls[0]).toContain("/chats/19%3Areal-graph-chat-id%40unq.gbl.spaces/messages/msg-1");
|
||||
it("fails closed when a canonical channel identifier is missing", () => {
|
||||
expect(
|
||||
buildMSTeamsGraphMessageUrl({
|
||||
conversationType: "channel",
|
||||
messageId: "message-id",
|
||||
channelId: DEFAULT_CHANNEL_ID,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
buildMSTeamsGraphMessageUrl({
|
||||
conversationType: "channel",
|
||||
teamAadGroupId: DEFAULT_CHANNEL_TEAM_ID,
|
||||
channelId: DEFAULT_CHANNEL_ID,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still builds URLs when a: conversation ID is passed (caller did not resolve)", () => {
|
||||
const urls = buildMSTeamsGraphMessageUrls({
|
||||
conversationType: "personal",
|
||||
conversationId: "a:1dRsHCobZ1AxURzY",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
expect(urls).toHaveLength(1);
|
||||
expect(urls[0]).toContain("/chats/a%3A1dRsHCobZ1AxURzY/messages/msg-1");
|
||||
it("treats a matching thread root and message ID as a top-level message", () => {
|
||||
expect(
|
||||
buildMSTeamsGraphMessageUrl({
|
||||
...createChannelGraphMessageUrlParams({
|
||||
messageId: "root-id",
|
||||
threadRootMessageId: "root-id",
|
||||
}),
|
||||
}),
|
||||
).toBe(`${GRAPH_CHANNEL_MESSAGES_ROOT}/root-id`);
|
||||
});
|
||||
|
||||
it("uses a resolved Graph chat ID for personal DMs", () => {
|
||||
expect(
|
||||
buildMSTeamsGraphMessageUrl({
|
||||
conversationType: "personal",
|
||||
conversationId: "19:real-graph-chat-id@unq.gbl.spaces",
|
||||
messageId: "msg-1",
|
||||
}),
|
||||
).toBe(
|
||||
"https://graph.microsoft.com/v1.0/chats/19%3Areal-graph-chat-id%40unq.gbl.spaces/messages/msg-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes every channel path identifier", () => {
|
||||
expect(
|
||||
buildMSTeamsGraphMessageUrl({
|
||||
conversationType: "channel",
|
||||
teamAadGroupId: "team/id",
|
||||
channelId: "channel id",
|
||||
messageId: "reply/id",
|
||||
threadRootMessageId: "root id",
|
||||
}),
|
||||
).toBe(
|
||||
"https://graph.microsoft.com/v1.0/teams/team%2Fid/channels/channel%20id/messages/root%20id/replies/reply%2Fid",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ export {
|
||||
isBotFrameworkPersonalChatId,
|
||||
} from "./attachments/bot-framework.js";
|
||||
export { downloadMSTeamsAttachments } from "./attachments/download.js";
|
||||
export { buildMSTeamsGraphMessageUrls, downloadMSTeamsGraphMedia } from "./attachments/graph.js";
|
||||
export { buildMSTeamsGraphMessageUrl, downloadMSTeamsGraphMedia } from "./attachments/graph.js";
|
||||
export {
|
||||
buildMSTeamsAttachmentPlaceholder,
|
||||
extractMSTeamsHtmlAttachmentIds,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// Msteams plugin module implements bot framework behavior.
|
||||
import { parseMediaContentLength } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
resolveMSTeamsRequestTimeoutMs,
|
||||
type MSTeamsRequestDeadline,
|
||||
withMSTeamsRequestDeadline,
|
||||
} from "../request-timeout.js";
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import { ensureUserAgentHeader } from "../user-agent.js";
|
||||
import {
|
||||
@@ -81,6 +86,7 @@ async function fetchBotFrameworkAttachmentInfo(params: {
|
||||
fetchFnSupportsDispatcher?: boolean;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
logger?: MSTeamsAttachmentDownloadLogger;
|
||||
deadline?: MSTeamsRequestDeadline;
|
||||
}): Promise<BotFrameworkAttachmentInfo | undefined> {
|
||||
const url = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}`;
|
||||
let response: Response;
|
||||
@@ -98,6 +104,7 @@ async function fetchBotFrameworkAttachmentInfo(params: {
|
||||
policy: params.policy,
|
||||
}),
|
||||
},
|
||||
timeoutMs: resolveMSTeamsRequestTimeoutMs(params.deadline),
|
||||
});
|
||||
} catch (err) {
|
||||
params.logger?.warn?.("msteams botFramework attachmentInfo fetch failed", {
|
||||
@@ -139,6 +146,7 @@ async function saveBotFrameworkAttachmentView(params: {
|
||||
fetchFnSupportsDispatcher?: boolean;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
logger?: MSTeamsAttachmentDownloadLogger;
|
||||
deadline?: MSTeamsRequestDeadline;
|
||||
}): Promise<{ path: string; contentType?: string } | undefined> {
|
||||
const url = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}/views/${encodeURIComponent(params.viewId)}`;
|
||||
let response: Response;
|
||||
@@ -156,6 +164,7 @@ async function saveBotFrameworkAttachmentView(params: {
|
||||
policy: params.policy,
|
||||
}),
|
||||
},
|
||||
timeoutMs: resolveMSTeamsRequestTimeoutMs(params.deadline),
|
||||
});
|
||||
} catch (err) {
|
||||
params.logger?.warn?.("msteams botFramework attachmentView fetch failed", {
|
||||
@@ -198,6 +207,8 @@ async function saveBotFrameworkAttachmentView(params: {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return undefined;
|
||||
} finally {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +228,7 @@ export async function downloadMSTeamsBotFrameworkAttachment(params: {
|
||||
fetchFn?: typeof fetch;
|
||||
fetchFnSupportsDispatcher?: boolean;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
deadline?: MSTeamsRequestDeadline;
|
||||
fileNameHint?: string | null;
|
||||
contentTypeHint?: string | null;
|
||||
preserveFilenames?: boolean;
|
||||
@@ -225,6 +237,7 @@ export async function downloadMSTeamsBotFrameworkAttachment(params: {
|
||||
if (!params.serviceUrl || !params.attachmentId || !params.tokenProvider) {
|
||||
return undefined;
|
||||
}
|
||||
const tokenProvider = params.tokenProvider;
|
||||
const policy: MSTeamsAttachmentFetchPolicy = resolveAttachmentFetchPolicy({
|
||||
allowHosts: params.allowHosts,
|
||||
authAllowHosts: params.authAllowHosts,
|
||||
@@ -236,7 +249,11 @@ export async function downloadMSTeamsBotFrameworkAttachment(params: {
|
||||
|
||||
let accessToken: string;
|
||||
try {
|
||||
accessToken = await params.tokenProvider.getAccessToken(BOT_FRAMEWORK_SCOPE);
|
||||
accessToken = await withMSTeamsRequestDeadline({
|
||||
deadline: params.deadline,
|
||||
label: "MS Teams Bot Framework token",
|
||||
work: () => tokenProvider.getAccessToken(BOT_FRAMEWORK_SCOPE),
|
||||
});
|
||||
} catch (err) {
|
||||
params.logger?.warn?.("msteams botFramework token acquisition failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
@@ -256,6 +273,7 @@ export async function downloadMSTeamsBotFrameworkAttachment(params: {
|
||||
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
|
||||
resolveFn: params.resolveFn,
|
||||
logger: params.logger,
|
||||
deadline: params.deadline,
|
||||
});
|
||||
if (!info) {
|
||||
return undefined;
|
||||
@@ -304,6 +322,7 @@ export async function downloadMSTeamsBotFrameworkAttachment(params: {
|
||||
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
|
||||
resolveFn: params.resolveFn,
|
||||
logger: params.logger,
|
||||
deadline: params.deadline,
|
||||
});
|
||||
if (!saved) {
|
||||
return undefined;
|
||||
@@ -332,6 +351,7 @@ export async function downloadMSTeamsBotFrameworkAttachments(params: {
|
||||
fetchFn?: typeof fetch;
|
||||
fetchFnSupportsDispatcher?: boolean;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
deadline?: MSTeamsRequestDeadline;
|
||||
fileNameHint?: string | null;
|
||||
contentTypeHint?: string | null;
|
||||
preserveFilenames?: boolean;
|
||||
@@ -367,6 +387,7 @@ export async function downloadMSTeamsBotFrameworkAttachments(params: {
|
||||
fetchFn: params.fetchFn,
|
||||
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
|
||||
resolveFn: params.resolveFn,
|
||||
deadline: params.deadline,
|
||||
fileNameHint: params.fileNameHint,
|
||||
contentTypeHint: params.contentTypeHint,
|
||||
preserveFilenames: params.preserveFilenames,
|
||||
|
||||
@@ -4,6 +4,11 @@ import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
resolveMSTeamsRequestTimeoutMs,
|
||||
type MSTeamsRequestDeadline,
|
||||
withMSTeamsRequestDeadline,
|
||||
} from "../request-timeout.js";
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
|
||||
import {
|
||||
@@ -129,6 +134,7 @@ async function fetchWithAuthFallback(params: {
|
||||
requestInit?: RequestInit;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
policy: MSTeamsAttachmentFetchPolicy;
|
||||
deadline?: MSTeamsRequestDeadline;
|
||||
}): Promise<Response> {
|
||||
const firstAttempt = await safeFetchWithPolicy({
|
||||
url: params.url,
|
||||
@@ -137,6 +143,7 @@ async function fetchWithAuthFallback(params: {
|
||||
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
|
||||
requestInit: params.requestInit,
|
||||
resolveFn: params.resolveFn,
|
||||
timeoutMs: resolveMSTeamsRequestTimeoutMs(params.deadline),
|
||||
});
|
||||
if (firstAttempt.ok) {
|
||||
return firstAttempt;
|
||||
@@ -144,6 +151,7 @@ async function fetchWithAuthFallback(params: {
|
||||
if (!params.tokenProvider) {
|
||||
return firstAttempt;
|
||||
}
|
||||
const tokenProvider = params.tokenProvider;
|
||||
if (firstAttempt.status !== 401 && firstAttempt.status !== 403) {
|
||||
return firstAttempt;
|
||||
}
|
||||
@@ -156,7 +164,11 @@ async function fetchWithAuthFallback(params: {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
for (const scope of scopes) {
|
||||
try {
|
||||
const token = await params.tokenProvider.getAccessToken(scope);
|
||||
const token = await withMSTeamsRequestDeadline({
|
||||
deadline: params.deadline,
|
||||
label: "MS Teams attachment token",
|
||||
work: () => tokenProvider.getAccessToken(scope),
|
||||
});
|
||||
const authHeaders = new Headers(params.requestInit?.headers);
|
||||
authHeaders.set("Authorization", `Bearer ${token}`);
|
||||
const authAttempt = await safeFetchWithPolicy({
|
||||
@@ -169,6 +181,7 @@ async function fetchWithAuthFallback(params: {
|
||||
headers: authHeaders,
|
||||
},
|
||||
resolveFn: params.resolveFn,
|
||||
timeoutMs: resolveMSTeamsRequestTimeoutMs(params.deadline),
|
||||
});
|
||||
if (authAttempt.ok) {
|
||||
return authAttempt;
|
||||
@@ -204,6 +217,7 @@ export async function downloadMSTeamsAttachments(params: {
|
||||
fetchFn?: typeof fetch;
|
||||
fetchFnSupportsDispatcher?: boolean;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
deadline?: MSTeamsRequestDeadline;
|
||||
/** When true, embeds original filename in stored path for later extraction. */
|
||||
preserveFilenames?: boolean;
|
||||
/**
|
||||
@@ -313,6 +327,7 @@ export async function downloadMSTeamsAttachments(params: {
|
||||
requestInit: init,
|
||||
resolveFn: params.resolveFn,
|
||||
policy,
|
||||
deadline: params.deadline,
|
||||
}),
|
||||
});
|
||||
out.push(media);
|
||||
|
||||
@@ -86,12 +86,16 @@ function oversizedGraphJson(payload: Record<string, unknown>): string {
|
||||
return JSON.stringify({ ...payload, padding: "x".repeat(16 * 1024 * 1024) });
|
||||
}
|
||||
|
||||
type GuardedFetchParams = { url: string; init?: RequestInit };
|
||||
type GuardedFetchParams = { url: string; init?: RequestInit; timeoutMs?: number };
|
||||
|
||||
function guardedFetchResult(params: GuardedFetchParams, response: Response) {
|
||||
function guardedFetchResult(
|
||||
params: GuardedFetchParams,
|
||||
response: Response,
|
||||
release: () => Promise<void> = async () => {},
|
||||
) {
|
||||
return {
|
||||
response,
|
||||
release: async () => {},
|
||||
release,
|
||||
finalUrl: params.url,
|
||||
};
|
||||
}
|
||||
@@ -281,6 +285,7 @@ describe("downloadMSTeamsGraphMedia hosted content $value fallback", () => {
|
||||
|
||||
const guardCalls = vi.mocked(fetchWithSsrFGuard).mock.calls;
|
||||
for (const [call] of guardCalls) {
|
||||
expect(call.timeoutMs).toBe(30_000);
|
||||
const headers = call.init?.headers;
|
||||
expect(headers).toBeInstanceOf(Headers);
|
||||
expect((headers as Headers).get("Authorization")).toBe("Bearer test-token");
|
||||
@@ -326,6 +331,7 @@ describe("downloadMSTeamsGraphMedia hosted content $value fallback", () => {
|
||||
vi.mocked(safeFetchWithPolicy),
|
||||
"safeFetchWithPolicy call",
|
||||
);
|
||||
expect(fetchParams.timeoutMs).toBe(30_000);
|
||||
expect(fetchParams.requestInit?.headers).toBeInstanceOf(Headers);
|
||||
const requestInit = fetchParams.requestInit;
|
||||
const headers = requestInit?.headers as Headers;
|
||||
@@ -403,6 +409,42 @@ describe("downloadMSTeamsGraphMedia attachment sourcing and error logging", () =
|
||||
expect(result.attachmentCount).toBe(1);
|
||||
});
|
||||
|
||||
it("releases message metadata before starting nested SharePoint downloads", async () => {
|
||||
const order: string[] = [];
|
||||
vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: GuardedFetchParams) => {
|
||||
if (params.url.endsWith("/messages/msg-release")) {
|
||||
return guardedFetchResult(
|
||||
params,
|
||||
mockFetchResponse({
|
||||
attachments: [
|
||||
{
|
||||
contentType: "reference",
|
||||
contentUrl: "https://tenant.sharepoint.com/release.pdf",
|
||||
name: "release.pdf",
|
||||
},
|
||||
],
|
||||
}),
|
||||
async () => {
|
||||
order.push("message-release");
|
||||
},
|
||||
);
|
||||
}
|
||||
return guardedFetchResult(params, mockFetchResponse({ value: [] }));
|
||||
});
|
||||
vi.mocked(downloadAndStoreMSTeamsRemoteMedia).mockImplementation(async () => {
|
||||
order.push("sharepoint-download");
|
||||
return { path: "/tmp/release.pdf", contentType: "application/pdf", placeholder: "[file]" };
|
||||
});
|
||||
|
||||
await downloadMSTeamsGraphMedia({
|
||||
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-release",
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
|
||||
maxBytes: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
expect(order.slice(0, 2)).toEqual(["message-release", "sharepoint-download"]);
|
||||
});
|
||||
|
||||
it("skips message metadata when the Graph response exceeds the byte cap", async () => {
|
||||
mockGraphMediaFetch({
|
||||
messageId: "msg-huge",
|
||||
@@ -453,6 +495,50 @@ describe("downloadMSTeamsGraphMedia attachment sourcing and error logging", () =
|
||||
expect((context as { error?: unknown }).error).toBe("network boom");
|
||||
});
|
||||
|
||||
it("keeps downloaded message attachments when hosted content lookup fails", async () => {
|
||||
vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: GuardedFetchParams) => {
|
||||
if (params.url.endsWith("/hostedContents")) {
|
||||
throw new Error("hosted content unavailable");
|
||||
}
|
||||
return guardedFetchResult(
|
||||
params,
|
||||
mockFetchResponse({
|
||||
attachments: [
|
||||
{
|
||||
contentType: "reference",
|
||||
contentUrl: "https://tenant.sharepoint.com/partial.pdf",
|
||||
name: "partial.pdf",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
vi.mocked(downloadAndStoreMSTeamsRemoteMedia).mockResolvedValue({
|
||||
path: "/tmp/partial.pdf",
|
||||
contentType: "application/pdf",
|
||||
placeholder: "[file]",
|
||||
});
|
||||
const logger = { warn: vi.fn() };
|
||||
|
||||
const result = await downloadMSTeamsGraphMedia({
|
||||
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-partial",
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
|
||||
maxBytes: 10 * 1024 * 1024,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(result.media).toEqual([
|
||||
{
|
||||
path: "/tmp/partial.pdf",
|
||||
contentType: "application/pdf",
|
||||
placeholder: "[file]",
|
||||
},
|
||||
]);
|
||||
expect(logger.warn).toHaveBeenCalledWith("msteams graph hostedContents fetch failed", {
|
||||
error: "hosted content unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
it("logs a debug event when the message fetch returns non-ok", async () => {
|
||||
// If the message endpoint returns 403/404, we want that recorded so
|
||||
// operators can distinguish auth issues from empty result sets.
|
||||
@@ -463,18 +549,18 @@ describe("downloadMSTeamsGraphMedia attachment sourcing and error logging", () =
|
||||
}
|
||||
return guardedFetchResult(params, mockFetchResponse({ error: "forbidden" }, 403));
|
||||
});
|
||||
const log = { debug: vi.fn() };
|
||||
const logger = { debug: vi.fn() };
|
||||
|
||||
const result = await downloadMSTeamsGraphMedia({
|
||||
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-403",
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
|
||||
maxBytes: 10 * 1024 * 1024,
|
||||
log,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(result.media).toHaveLength(0);
|
||||
expect(result.attachmentStatus).toBe(403);
|
||||
const [message, context] = requireFirstMockCall(log.debug, "message fetch debug event");
|
||||
const [message, context] = requireFirstMockCall(logger.debug, "message fetch debug event");
|
||||
expect(message).toBe("graph media message fetch not ok");
|
||||
expect((context as { status?: unknown }).status).toBe(403);
|
||||
});
|
||||
@@ -501,4 +587,27 @@ describe("downloadMSTeamsGraphMedia attachment sourcing and error logging", () =
|
||||
expect(message).toBe("msteams graph token acquisition failed");
|
||||
expect((context as { error?: unknown }).error).toBe("token expired");
|
||||
});
|
||||
|
||||
it("bounds stalled token acquisition to the shared operation deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const resultPromise = downloadMSTeamsGraphMedia({
|
||||
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-token-timeout",
|
||||
tokenProvider: { getAccessToken: vi.fn(() => new Promise<string>(() => {})) },
|
||||
maxBytes: 10 * 1024 * 1024,
|
||||
deadline: {
|
||||
label: "MS Teams inbound preprocessing",
|
||||
timeoutMs: 50,
|
||||
deadlineAtMs: Date.now() + 50,
|
||||
},
|
||||
});
|
||||
const assertion = expect(resultPromise).resolves.toMatchObject({ tokenError: true });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(51);
|
||||
await assertion;
|
||||
expect(fetchWithSsrFGuard).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,8 +8,12 @@ import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
uniqueStrings,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
resolveMSTeamsRequestTimeoutMs,
|
||||
type MSTeamsRequestDeadline,
|
||||
withMSTeamsRequestDeadline,
|
||||
} from "../request-timeout.js";
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import { ensureUserAgentHeader } from "../user-agent.js";
|
||||
import { downloadMSTeamsAttachments } from "./download.js";
|
||||
@@ -19,7 +23,6 @@ import {
|
||||
encodeGraphShareId,
|
||||
GRAPH_ROOT,
|
||||
inferPlaceholder,
|
||||
readNestedString,
|
||||
isUrlAllowed,
|
||||
type MSTeamsAttachmentDownloadLogger,
|
||||
type MSTeamsAttachmentFetchPolicy,
|
||||
@@ -33,7 +36,6 @@ import {
|
||||
import type {
|
||||
MSTeamsAccessTokenProvider,
|
||||
MSTeamsAttachmentLike,
|
||||
MSTeamsGraphMediaLogger,
|
||||
MSTeamsGraphMediaResult,
|
||||
MSTeamsInboundMedia,
|
||||
} from "./types.js";
|
||||
@@ -52,75 +54,40 @@ type GraphAttachment = {
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
export function buildMSTeamsGraphMessageUrls(params: {
|
||||
export function buildMSTeamsGraphMessageUrl(params: {
|
||||
conversationType?: string | null;
|
||||
conversationId?: string | null;
|
||||
messageId?: string | null;
|
||||
replyToId?: string | null;
|
||||
conversationMessageId?: string | null;
|
||||
channelData?: unknown;
|
||||
}): string[] {
|
||||
threadRootMessageId?: string | null;
|
||||
teamAadGroupId?: string | null;
|
||||
channelId?: string | null;
|
||||
}): string | undefined {
|
||||
const conversationType = normalizeLowercaseStringOrEmpty(params.conversationType ?? "");
|
||||
const messageIdCandidates = new Set<string>();
|
||||
const pushCandidate = (value: string | null | undefined) => {
|
||||
const trimmed = normalizeOptionalString(value) ?? "";
|
||||
if (trimmed) {
|
||||
messageIdCandidates.add(trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
pushCandidate(params.messageId);
|
||||
pushCandidate(params.conversationMessageId);
|
||||
pushCandidate(readNestedString(params.channelData, ["messageId"]));
|
||||
pushCandidate(readNestedString(params.channelData, ["teamsMessageId"]));
|
||||
|
||||
const replyToId = normalizeOptionalString(params.replyToId) ?? "";
|
||||
const messageId = normalizeOptionalString(params.messageId);
|
||||
if (!messageId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (conversationType === "channel") {
|
||||
const teamId =
|
||||
readNestedString(params.channelData, ["team", "id"]) ??
|
||||
readNestedString(params.channelData, ["teamId"]);
|
||||
const channelId =
|
||||
readNestedString(params.channelData, ["channel", "id"]) ??
|
||||
readNestedString(params.channelData, ["channelId"]) ??
|
||||
readNestedString(params.channelData, ["teamsChannelId"]);
|
||||
if (!teamId || !channelId) {
|
||||
return [];
|
||||
const teamAadGroupId = normalizeOptionalString(params.teamAadGroupId);
|
||||
const channelId = normalizeOptionalString(params.channelId);
|
||||
if (!teamAadGroupId || !channelId) {
|
||||
return undefined;
|
||||
}
|
||||
const urls: string[] = [];
|
||||
if (replyToId) {
|
||||
for (const candidate of messageIdCandidates) {
|
||||
if (candidate === replyToId) {
|
||||
continue;
|
||||
}
|
||||
urls.push(
|
||||
`${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(replyToId)}/replies/${encodeURIComponent(candidate)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (messageIdCandidates.size === 0 && replyToId) {
|
||||
messageIdCandidates.add(replyToId);
|
||||
}
|
||||
for (const candidate of messageIdCandidates) {
|
||||
urls.push(
|
||||
`${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(candidate)}`,
|
||||
);
|
||||
}
|
||||
return uniqueStrings(urls);
|
||||
const messageRoot = `${GRAPH_ROOT}/teams/${encodeURIComponent(teamAadGroupId)}/channels/${encodeURIComponent(channelId)}/messages`;
|
||||
const threadRootMessageId = normalizeOptionalString(params.threadRootMessageId);
|
||||
// Graph addresses replies only beneath the thread root. A bare reply ID is
|
||||
// not a top-level message, while fetching the root would attach the wrong file.
|
||||
return threadRootMessageId && threadRootMessageId !== messageId
|
||||
? `${messageRoot}/${encodeURIComponent(threadRootMessageId)}/replies/${encodeURIComponent(messageId)}`
|
||||
: `${messageRoot}/${encodeURIComponent(messageId)}`;
|
||||
}
|
||||
|
||||
const chatId = params.conversationId?.trim() || readNestedString(params.channelData, ["chatId"]);
|
||||
const chatId = normalizeOptionalString(params.conversationId);
|
||||
if (!chatId) {
|
||||
return [];
|
||||
return undefined;
|
||||
}
|
||||
if (messageIdCandidates.size === 0 && replyToId) {
|
||||
messageIdCandidates.add(replyToId);
|
||||
}
|
||||
const urls = Array.from(messageIdCandidates).map(
|
||||
(candidate) =>
|
||||
`${GRAPH_ROOT}/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(candidate)}`,
|
||||
);
|
||||
return uniqueStrings(urls);
|
||||
return `${GRAPH_ROOT}/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(messageId)}`;
|
||||
}
|
||||
|
||||
async function fetchGraphCollection(params: {
|
||||
@@ -128,6 +95,7 @@ async function fetchGraphCollection(params: {
|
||||
accessToken: string;
|
||||
fetchFn?: typeof fetch;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
deadline?: MSTeamsRequestDeadline;
|
||||
}): Promise<{ status: number; items: unknown[] }> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
@@ -138,6 +106,7 @@ async function fetchGraphCollection(params: {
|
||||
},
|
||||
policy: params.ssrfPolicy,
|
||||
auditContext: "msteams.graph.collection",
|
||||
timeoutMs: resolveMSTeamsRequestTimeoutMs(params.deadline),
|
||||
});
|
||||
try {
|
||||
const status = response.status;
|
||||
@@ -189,13 +158,23 @@ async function downloadGraphHostedContent(params: {
|
||||
preserveFilenames?: boolean;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
logger?: MSTeamsAttachmentDownloadLogger;
|
||||
}): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> {
|
||||
const hosted = (await fetchGraphCollection({
|
||||
url: `${params.messageUrl}/hostedContents`,
|
||||
accessToken: params.accessToken,
|
||||
fetchFn: params.fetchFn,
|
||||
ssrfPolicy: params.ssrfPolicy,
|
||||
})) as { status: number; items: GraphHostedContent[] };
|
||||
deadline?: MSTeamsRequestDeadline;
|
||||
}): Promise<{ media: MSTeamsInboundMedia[]; status?: number; count: number }> {
|
||||
let hosted: { status: number; items: GraphHostedContent[] };
|
||||
try {
|
||||
hosted = (await fetchGraphCollection({
|
||||
url: `${params.messageUrl}/hostedContents`,
|
||||
accessToken: params.accessToken,
|
||||
fetchFn: params.fetchFn,
|
||||
ssrfPolicy: params.ssrfPolicy,
|
||||
deadline: params.deadline,
|
||||
})) as { status: number; items: GraphHostedContent[] };
|
||||
} catch (err) {
|
||||
params.logger?.warn?.("msteams graph hostedContents fetch failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return { media: [], count: 0 };
|
||||
}
|
||||
if (hosted.items.length === 0) {
|
||||
return { media: [], status: hosted.status, count: 0 };
|
||||
}
|
||||
@@ -218,6 +197,7 @@ async function downloadGraphHostedContent(params: {
|
||||
},
|
||||
policy: params.ssrfPolicy,
|
||||
auditContext: "msteams.graph.hostedContent.value",
|
||||
timeoutMs: resolveMSTeamsRequestTimeoutMs(params.deadline),
|
||||
});
|
||||
try {
|
||||
if (!valRes.ok) {
|
||||
@@ -257,29 +237,31 @@ export async function downloadMSTeamsGraphMedia(params: {
|
||||
fetchFn?: typeof fetch;
|
||||
fetchFnSupportsDispatcher?: boolean;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
deadline?: MSTeamsRequestDeadline;
|
||||
/** When true, embeds original filename in stored path for later extraction. */
|
||||
preserveFilenames?: boolean;
|
||||
/** Optional logger used to surface Graph/SharePoint fetch errors. */
|
||||
logger?: MSTeamsAttachmentDownloadLogger;
|
||||
/** Back-compat diagnostic logger used by older tests/callers. */
|
||||
log?: MSTeamsGraphMediaLogger;
|
||||
}): Promise<MSTeamsGraphMediaResult> {
|
||||
if (!params.messageUrl || !params.tokenProvider) {
|
||||
return { media: [] };
|
||||
}
|
||||
const tokenProvider = params.tokenProvider;
|
||||
const policy: MSTeamsAttachmentFetchPolicy = resolveAttachmentFetchPolicy({
|
||||
allowHosts: params.allowHosts,
|
||||
authAllowHosts: params.authAllowHosts,
|
||||
});
|
||||
const ssrfPolicy = resolveMediaSsrfPolicy(policy.allowHosts);
|
||||
const messageUrl = params.messageUrl;
|
||||
const debugLog =
|
||||
params.log ?? (params.logger as MSTeamsGraphMediaLogger | undefined) ?? undefined;
|
||||
let accessToken: string;
|
||||
try {
|
||||
accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com");
|
||||
accessToken = await withMSTeamsRequestDeadline({
|
||||
deadline: params.deadline,
|
||||
label: "MS Teams Graph media token",
|
||||
work: () => tokenProvider.getAccessToken("https://graph.microsoft.com"),
|
||||
});
|
||||
} catch (err) {
|
||||
debugLog?.debug?.("graph media token acquisition failed", {
|
||||
params.logger?.debug?.("graph media token acquisition failed", {
|
||||
messageUrl,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -293,6 +275,7 @@ export async function downloadMSTeamsGraphMedia(params: {
|
||||
const sharePointMedia: MSTeamsInboundMedia[] = [];
|
||||
const downloadedReferenceUrls = new Set<string>();
|
||||
let messageAttachments: GraphAttachment[] = [];
|
||||
let referenceAttachments: GraphAttachment[] = [];
|
||||
let messageStatus: number | undefined;
|
||||
try {
|
||||
const { response: msgRes, release } = await fetchWithSsrFGuard({
|
||||
@@ -303,6 +286,7 @@ export async function downloadMSTeamsGraphMedia(params: {
|
||||
},
|
||||
policy: ssrfPolicy,
|
||||
auditContext: "msteams.graph.message",
|
||||
timeoutMs: resolveMSTeamsRequestTimeoutMs(params.deadline),
|
||||
});
|
||||
try {
|
||||
messageStatus = msgRes.status;
|
||||
@@ -317,7 +301,7 @@ export async function downloadMSTeamsGraphMedia(params: {
|
||||
"MS Teams Graph message",
|
||||
);
|
||||
} catch (err) {
|
||||
debugLog?.debug?.("graph media message parse failed", {
|
||||
params.logger?.debug?.("graph media message parse failed", {
|
||||
messageUrl,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -329,67 +313,11 @@ export async function downloadMSTeamsGraphMedia(params: {
|
||||
}
|
||||
messageAttachments = Array.isArray(msgData.attachments) ? msgData.attachments : [];
|
||||
|
||||
const spAttachments = messageAttachments.filter(
|
||||
referenceAttachments = messageAttachments.filter(
|
||||
(a) => a.contentType === "reference" && a.contentUrl && a.name,
|
||||
);
|
||||
for (const att of spAttachments) {
|
||||
const name = att.name ?? "file";
|
||||
const shareUrl = att.contentUrl ?? "";
|
||||
if (!shareUrl) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const sharesUrl = `${GRAPH_ROOT}/shares/${encodeGraphShareId(shareUrl)}/driveItem/content`;
|
||||
if (!isUrlAllowed(sharesUrl, policy.allowHosts)) {
|
||||
debugLog?.debug?.("graph media sharepoint url not in allowHosts", {
|
||||
messageUrl,
|
||||
sharesUrl,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const media = await downloadAndStoreMSTeamsRemoteMedia({
|
||||
url: sharesUrl,
|
||||
filePathHint: name,
|
||||
maxBytes: params.maxBytes,
|
||||
contentTypeHint: "application/octet-stream",
|
||||
preserveFilenames: params.preserveFilenames,
|
||||
ssrfPolicy,
|
||||
useDirectFetch: true,
|
||||
fetchImpl: async (input, init) => {
|
||||
const requestUrl = resolveRequestUrl(input);
|
||||
const headers = ensureUserAgentHeader(init?.headers);
|
||||
applyAuthorizationHeaderForUrl({
|
||||
headers,
|
||||
url: requestUrl,
|
||||
authAllowHosts: policy.authAllowHosts,
|
||||
bearerToken: accessToken,
|
||||
});
|
||||
return await safeFetchWithPolicy({
|
||||
url: requestUrl,
|
||||
policy,
|
||||
fetchFn,
|
||||
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
|
||||
requestInit: {
|
||||
...init,
|
||||
headers,
|
||||
},
|
||||
resolveFn: params.resolveFn,
|
||||
});
|
||||
},
|
||||
});
|
||||
sharePointMedia.push(media);
|
||||
downloadedReferenceUrls.add(shareUrl);
|
||||
} catch (err) {
|
||||
params.logger?.warn?.("msteams SharePoint reference download failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
name,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debugLog?.debug?.("graph media message fetch not ok", {
|
||||
params.logger?.debug?.("graph media message fetch not ok", {
|
||||
messageUrl,
|
||||
status: messageStatus,
|
||||
});
|
||||
@@ -398,7 +326,7 @@ export async function downloadMSTeamsGraphMedia(params: {
|
||||
await release();
|
||||
}
|
||||
} catch (err) {
|
||||
debugLog?.debug?.("graph media message fetch failed", {
|
||||
params.logger?.debug?.("graph media message fetch failed", {
|
||||
messageUrl,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -407,6 +335,66 @@ export async function downloadMSTeamsGraphMedia(params: {
|
||||
});
|
||||
}
|
||||
|
||||
// The message response owns a pinned dispatcher. Release it before nested
|
||||
// SharePoint requests so one metadata connection never spans child downloads.
|
||||
for (const att of referenceAttachments) {
|
||||
const name = att.name ?? "file";
|
||||
const shareUrl = att.contentUrl ?? "";
|
||||
if (!shareUrl) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const sharesUrl = `${GRAPH_ROOT}/shares/${encodeGraphShareId(shareUrl)}/driveItem/content`;
|
||||
if (!isUrlAllowed(sharesUrl, policy.allowHosts)) {
|
||||
params.logger?.debug?.("graph media sharepoint url not in allowHosts", {
|
||||
messageUrl,
|
||||
sharesUrl,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const media = await downloadAndStoreMSTeamsRemoteMedia({
|
||||
url: sharesUrl,
|
||||
filePathHint: name,
|
||||
maxBytes: params.maxBytes,
|
||||
contentTypeHint: "application/octet-stream",
|
||||
preserveFilenames: params.preserveFilenames,
|
||||
ssrfPolicy,
|
||||
useDirectFetch: true,
|
||||
fetchImpl: async (input, init) => {
|
||||
const requestUrl = resolveRequestUrl(input);
|
||||
const headers = ensureUserAgentHeader(init?.headers);
|
||||
applyAuthorizationHeaderForUrl({
|
||||
headers,
|
||||
url: requestUrl,
|
||||
authAllowHosts: policy.authAllowHosts,
|
||||
bearerToken: accessToken,
|
||||
});
|
||||
return await safeFetchWithPolicy({
|
||||
url: requestUrl,
|
||||
policy,
|
||||
fetchFn,
|
||||
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
|
||||
requestInit: {
|
||||
...init,
|
||||
headers,
|
||||
},
|
||||
resolveFn: params.resolveFn,
|
||||
timeoutMs: resolveMSTeamsRequestTimeoutMs(params.deadline),
|
||||
});
|
||||
},
|
||||
});
|
||||
sharePointMedia.push(media);
|
||||
downloadedReferenceUrls.add(shareUrl);
|
||||
} catch (err) {
|
||||
params.logger?.warn?.("msteams SharePoint reference download failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const hosted = await downloadGraphHostedContent({
|
||||
accessToken,
|
||||
messageUrl,
|
||||
@@ -415,6 +403,7 @@ export async function downloadMSTeamsGraphMedia(params: {
|
||||
preserveFilenames: params.preserveFilenames,
|
||||
ssrfPolicy,
|
||||
logger: params.logger,
|
||||
deadline: params.deadline,
|
||||
});
|
||||
|
||||
const normalizedAttachments = messageAttachments.map(normalizeGraphAttachment);
|
||||
@@ -443,6 +432,7 @@ export async function downloadMSTeamsGraphMedia(params: {
|
||||
fetchFn: params.fetchFn,
|
||||
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
|
||||
resolveFn: params.resolveFn,
|
||||
deadline: params.deadline,
|
||||
preserveFilenames: params.preserveFilenames,
|
||||
logger: params.logger,
|
||||
});
|
||||
|
||||
@@ -145,6 +145,25 @@ describe("downloadAndStoreMSTeamsRemoteMedia", () => {
|
||||
expect(runtimeSaveRemoteMediaMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels a guarded response when storage fails before reading the body", async () => {
|
||||
const cancel = vi.fn();
|
||||
const body = new ReadableStream<Uint8Array>({ cancel });
|
||||
const fetchImpl = vi.fn(async () => new Response(body, { status: 200 }));
|
||||
saveResponseMediaMock.mockRejectedValueOnce(new Error("mkdir failed"));
|
||||
|
||||
await expect(
|
||||
downloadAndStoreMSTeamsRemoteMedia({
|
||||
url: "https://graph.microsoft.com/v1.0/shares/abc/driveItem/content",
|
||||
filePathHint: "file.png",
|
||||
maxBytes: 1024,
|
||||
useDirectFetch: true,
|
||||
fetchImpl,
|
||||
}),
|
||||
).rejects.toThrow("mkdir failed");
|
||||
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to the runtime saveRemoteMedia path when useDirectFetch is omitted", async () => {
|
||||
// Non-SharePoint caller, no pre-validated fetchImpl: make sure the strict
|
||||
// SSRF dispatcher path is still used.
|
||||
|
||||
@@ -21,13 +21,19 @@ async function saveRemoteMediaDirect(params: {
|
||||
originalFilename?: string;
|
||||
}): Promise<SavedRemoteMedia> {
|
||||
const response = await params.fetchImpl(params.url, { redirect: "follow" });
|
||||
return await saveResponseMedia(response, {
|
||||
sourceUrl: params.url,
|
||||
filePathHint: params.filePathHint,
|
||||
maxBytes: params.maxBytes,
|
||||
fallbackContentType: params.contentTypeHint,
|
||||
originalFilename: params.originalFilename,
|
||||
});
|
||||
try {
|
||||
return await saveResponseMedia(response, {
|
||||
sourceUrl: params.url,
|
||||
filePathHint: params.filePathHint,
|
||||
maxBytes: params.maxBytes,
|
||||
fallbackContentType: params.contentTypeHint,
|
||||
originalFilename: params.originalFilename,
|
||||
});
|
||||
} finally {
|
||||
// Guarded responses release their pinned dispatcher on EOF or cancel. A
|
||||
// storage failure can happen before the body is read, so always cancel it.
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadAndStoreMSTeamsRemoteMedia(params: {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { MSTEAMS_REQUEST_TIMEOUT_MS } from "../request-timeout.js";
|
||||
import { responseWithRelease } from "../response-with-release.js";
|
||||
import type { MSTeamsAttachmentLike } from "./types.js";
|
||||
|
||||
@@ -467,11 +468,12 @@ export type MSTeamsAttachmentFetchPolicy = {
|
||||
|
||||
/**
|
||||
* Logger surface for attachment download errors. Structured so callers can
|
||||
* pass `MSTeamsMonitorLogger` directly without adapters. Optional `warn`/
|
||||
* `error` methods prevent silent swallowing of fetch failures — see issue
|
||||
* pass `MSTeamsMonitorLogger` directly without adapters. Optional methods
|
||||
* prevent silent swallowing of fetch failures — see issue
|
||||
* #63396 where empty `catch {}` blocks hid a Node 24+ undici incompatibility.
|
||||
*/
|
||||
export type MSTeamsAttachmentDownloadLogger = {
|
||||
debug?: (message: string, meta?: Record<string, unknown>) => void;
|
||||
warn?: (message: string, meta?: Record<string, unknown>) => void;
|
||||
error?: (message: string, meta?: Record<string, unknown>) => void;
|
||||
};
|
||||
@@ -604,6 +606,7 @@ export async function safeFetch(params: {
|
||||
fetchFnSupportsDispatcher?: boolean;
|
||||
requestInit?: RequestInit;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
timeoutMs?: number;
|
||||
}): Promise<Response> {
|
||||
const resolveFn = params.resolveFn ?? lookup;
|
||||
const hasDispatcher = Boolean(
|
||||
@@ -646,6 +649,7 @@ export async function safeFetch(params: {
|
||||
retainAuthorizationRedirectHostnameAllowlist:
|
||||
resolveRetainedAuthorizationRedirectHostnameAllowlist(params.authorizationAllowHosts),
|
||||
auditContext: "msteams.attachment",
|
||||
timeoutMs: params.timeoutMs ?? MSTEAMS_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
return responseWithRelease(guarded.response, guarded.release);
|
||||
}
|
||||
@@ -723,6 +727,7 @@ export async function safeFetchWithPolicy(params: {
|
||||
fetchFnSupportsDispatcher?: boolean;
|
||||
requestInit?: RequestInit;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
timeoutMs?: number;
|
||||
}): Promise<Response> {
|
||||
return await safeFetch({
|
||||
url: params.url,
|
||||
@@ -732,5 +737,6 @@ export async function safeFetchWithPolicy(params: {
|
||||
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
|
||||
requestInit: params.requestInit,
|
||||
resolveFn: params.resolveFn,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,13 +37,3 @@ export type MSTeamsGraphMediaResult = {
|
||||
messageUrl?: string;
|
||||
tokenError?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Narrow logger surface used by `downloadMSTeamsGraphMedia` for diagnostic
|
||||
* events. Accepting an optional callback keeps the helper testable without
|
||||
* pulling in the full channel logger type, while still allowing the monitor
|
||||
* handler to forward its plugin logger.
|
||||
*/
|
||||
export type MSTeamsGraphMediaLogger = {
|
||||
debug?: (message: string, meta?: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
@@ -76,6 +76,15 @@ describe("msteams config schema", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts the opt-in Graph media fallback", () => {
|
||||
const res = MSTeamsConfigSchema.safeParse({ graphMediaFallback: true });
|
||||
|
||||
expect(res.success).toBe(true);
|
||||
if (res.success) {
|
||||
expect(res.data.graphMediaFallback).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts replyStyle at global/team/channel levels", () => {
|
||||
const res = MSTeamsConfigSchema.safeParse({
|
||||
replyStyle: "top-level",
|
||||
|
||||
@@ -18,6 +18,10 @@ export const msTeamsChannelConfigUiHints = {
|
||||
label: "MS Teams Service URL",
|
||||
help: "Bot Connector service URL for SDK proactive sends/edits/deletes. Set with cloud for USGov/DoD; set alone for GCC.",
|
||||
},
|
||||
graphMediaFallback: {
|
||||
label: "MS Teams Graph Media Fallback",
|
||||
help: "Query Microsoft Graph for unresolved channel or group-chat HTML media. Adds one lookup per matching message when enabled (default: false).",
|
||||
},
|
||||
streaming: {
|
||||
label: "MS Teams Streaming",
|
||||
help: 'Microsoft Teams preview/progress streaming mode: "off" | "partial" | "block" | "progress". Personal chats use Teams native streaminfo progress when available.',
|
||||
|
||||
@@ -38,12 +38,8 @@ export function mergeStoredConversationReference(
|
||||
// Preserve fields from the previous entry that may not be present on every
|
||||
// inbound activity. Without this, sparse activities (e.g. conversationUpdate,
|
||||
// reactions) would clear previously captured values. Some fields are only
|
||||
// populated opportunistically, such as timezone from clientInfo entities and
|
||||
// graphChatId from Graph lookups used for DM media downloads.
|
||||
// populated opportunistically, such as timezone from clientInfo entities.
|
||||
...(existing?.timezone && !incoming.timezone ? { timezone: existing.timezone } : {}),
|
||||
...(existing?.graphChatId && !incoming.graphChatId
|
||||
? { graphChatId: existing.graphChatId }
|
||||
: {}),
|
||||
...(existing?.tenantId && !incoming.tenantId ? { tenantId: existing.tenantId } : {}),
|
||||
...(existing?.aadObjectId && !incoming.aadObjectId
|
||||
? { aadObjectId: existing.aadObjectId }
|
||||
|
||||
@@ -139,7 +139,7 @@ describe("msteams conversation store (plugin state)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes concurrent upserts so sparse activities do not drop preserved fields", async () => {
|
||||
it("serializes concurrent upserts so sparse activities preserve independent fields", async () => {
|
||||
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-store-"));
|
||||
const store = createMSTeamsConversationStoreState({ stateDir });
|
||||
|
||||
@@ -148,7 +148,6 @@ describe("msteams conversation store (plugin state)", () => {
|
||||
channelId: "msteams",
|
||||
serviceUrl: "https://service.example.com",
|
||||
user: { id: "u1" },
|
||||
graphChatId: "19:resolved@unq.gbl.spaces",
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
@@ -169,7 +168,6 @@ describe("msteams conversation store (plugin state)", () => {
|
||||
]);
|
||||
|
||||
await expect(store.get("conv-race")).resolves.toMatchObject({
|
||||
graphChatId: "19:resolved@unq.gbl.spaces",
|
||||
timezone: "Europe/London",
|
||||
tenantId: "tenant-1",
|
||||
});
|
||||
|
||||
@@ -223,42 +223,6 @@ describe.each(storeFactories)("msteams conversation store ($name)", ({ createSto
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves graphChatId across upserts that omit it", async () => {
|
||||
const store = await createStore();
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2026-03-25T20:00:00.000Z"));
|
||||
await store.upsert("conv-graph", {
|
||||
conversation: { id: "conv-graph", conversationType: "personal" },
|
||||
channelId: "msteams",
|
||||
serviceUrl: "https://service.example.com",
|
||||
user: { id: "u1" },
|
||||
graphChatId: "19:resolved-chat-id@unq.gbl.spaces",
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date("2026-03-25T20:01:00.000Z"));
|
||||
// Second upsert without graphChatId (normal activity-based upsert)
|
||||
await store.upsert("conv-graph", {
|
||||
conversation: { id: "conv-graph", conversationType: "personal" },
|
||||
channelId: "msteams",
|
||||
serviceUrl: "https://service.example.com",
|
||||
user: { id: "u1" },
|
||||
});
|
||||
|
||||
await expect(store.get("conv-graph")).resolves.toEqual({
|
||||
conversation: { id: "conv-graph", conversationType: "personal" },
|
||||
channelId: "msteams",
|
||||
serviceUrl: "https://service.example.com",
|
||||
user: { id: "u1" },
|
||||
graphChatId: "19:resolved-chat-id@unq.gbl.spaces",
|
||||
lastSeenAt: "2026-03-25T20:01:00.000Z",
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers the freshest personal conversation for repeated upserts of the same user", async () => {
|
||||
const store = await createStore();
|
||||
|
||||
|
||||
@@ -43,13 +43,6 @@ export type StoredConversationReference = {
|
||||
serviceUrl?: string;
|
||||
/** Locale */
|
||||
locale?: string;
|
||||
/**
|
||||
* Cached Graph API chat ID (format: `19:xxx@thread.tacv2` or `19:xxx@unq.gbl.spaces`).
|
||||
* Bot Framework conversation IDs for personal DMs use a different format (`a:1xxx` or
|
||||
* `8:orgid:xxx`) that the Graph API does not accept. This field caches the resolved
|
||||
* Graph-native chat ID so we don't need to re-query the API on every send.
|
||||
*/
|
||||
graphChatId?: string;
|
||||
/** IANA timezone from Teams clientInfo entity (e.g. "America/New_York") */
|
||||
timezone?: string;
|
||||
};
|
||||
|
||||
@@ -194,8 +194,8 @@ describe("reactMessageMSTeams", () => {
|
||||
|
||||
it("resolves user: target through conversation store", async () => {
|
||||
mockState.findPreferredDmByUserId.mockResolvedValue({
|
||||
conversationId: "a:bot-id",
|
||||
reference: { graphChatId: "19:dm-chat@thread.tacv2" },
|
||||
conversationId: "19:dm-chat@thread.tacv2",
|
||||
reference: {},
|
||||
});
|
||||
mockState.postGraphBetaJson.mockResolvedValue(undefined);
|
||||
|
||||
|
||||
@@ -23,31 +23,7 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
describe("getMessageMSTeams", () => {
|
||||
it("resolves user: target using graphChatId from store", async () => {
|
||||
mockState.findPreferredDmByUserId.mockResolvedValue({
|
||||
conversationId: "a:bot-framework-dm-id",
|
||||
reference: { graphChatId: "19:graph-native-chat@thread.tacv2" },
|
||||
});
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
id: "msg-1",
|
||||
body: { content: "From user DM" },
|
||||
createdDateTime: "2026-03-23T12:00:00Z",
|
||||
});
|
||||
|
||||
await getMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "user:aad-object-id-123",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
expect(mockState.findPreferredDmByUserId).toHaveBeenCalledWith("aad-object-id-123");
|
||||
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: `/chats/${encodeURIComponent("19:graph-native-chat@thread.tacv2")}/messages/msg-1`,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to conversationId when it starts with 19:", async () => {
|
||||
it("resolves user targets whose stored conversation ID is Graph-native", async () => {
|
||||
mockState.findPreferredDmByUserId.mockResolvedValue({
|
||||
conversationId: "19:resolved-chat@thread.tacv2",
|
||||
reference: {},
|
||||
@@ -82,7 +58,7 @@ describe("getMessageMSTeams", () => {
|
||||
).rejects.toThrow("No conversation found for user:unknown-user");
|
||||
});
|
||||
|
||||
it("throws when user: target has Bot Framework ID and no graphChatId", async () => {
|
||||
it("throws when user: target has an opaque Bot Framework ID", async () => {
|
||||
mockState.findPreferredDmByUserId.mockResolvedValue({
|
||||
conversationId: "a:bot-framework-dm-id",
|
||||
reference: {},
|
||||
|
||||
@@ -208,8 +208,8 @@ describe("searchMessagesMSTeams", () => {
|
||||
|
||||
it("resolves user: target through conversation store", async () => {
|
||||
mockState.findPreferredDmByUserId.mockResolvedValue({
|
||||
conversationId: "a:bot-id",
|
||||
reference: { graphChatId: "19:dm-chat@thread.tacv2" },
|
||||
conversationId: "19:dm-chat@thread.tacv2",
|
||||
reference: {},
|
||||
});
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
|
||||
@@ -85,18 +85,12 @@ export async function resolveGraphConversationId(to: string): Promise<string> {
|
||||
);
|
||||
}
|
||||
|
||||
// Prefer the cached Graph-native chat ID (19:xxx format) over the Bot Framework
|
||||
// conversation ID, which may be in a non-Graph format (a:xxx / 8:orgid:xxx) for
|
||||
// personal DMs. send-context.ts resolves and caches this on first send.
|
||||
if (found.reference.graphChatId) {
|
||||
return found.reference.graphChatId;
|
||||
}
|
||||
if (found.conversationId.startsWith("19:")) {
|
||||
return found.conversationId;
|
||||
}
|
||||
throw new Error(
|
||||
`Conversation for user:${cleaned} uses a Bot Framework ID (${found.conversationId}) ` +
|
||||
"that Graph API does not accept. Send a message to this user first so the Graph chat ID is cached.",
|
||||
"that Graph API does not accept. Use a Graph-native conversation:19:... target when available.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
// Msteams tests cover graph thread plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
_teamGroupIdCacheForTest,
|
||||
fetchChannelMessage,
|
||||
fetchChatMessageText,
|
||||
fetchThreadReplies,
|
||||
formatThreadContext,
|
||||
resolveTeamGroupId,
|
||||
stripHtmlFromTeamsMessage,
|
||||
} from "./graph-thread.js";
|
||||
import { fetchGraphJson } from "./graph.js";
|
||||
@@ -62,100 +60,6 @@ describe("stripHtmlFromTeamsMessage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTeamGroupId", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchGraphJson).mockReset();
|
||||
_teamGroupIdCacheForTest.clear();
|
||||
});
|
||||
|
||||
it("fetches team id from Graph and caches it", async () => {
|
||||
vi.mocked(fetchGraphJson).mockResolvedValueOnce({ id: "group-guid-1" } as never);
|
||||
|
||||
const result = await resolveTeamGroupId("tok", "team-123");
|
||||
expect(result).toBe("group-guid-1");
|
||||
expect(fetchGraphJson).toHaveBeenCalledWith({
|
||||
token: "tok",
|
||||
path: "/teams/team-123?$select=id",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns cached value without calling Graph again", async () => {
|
||||
vi.mocked(fetchGraphJson).mockResolvedValueOnce({ id: "group-guid-2" } as never);
|
||||
|
||||
await resolveTeamGroupId("tok", "team-456");
|
||||
await resolveTeamGroupId("tok", "team-456");
|
||||
|
||||
expect(fetchGraphJson).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not cache team ids when the expiry would exceed a valid Date", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
try {
|
||||
vi.mocked(fetchGraphJson).mockResolvedValue({ id: "group-guid-boundary" } as never);
|
||||
|
||||
await resolveTeamGroupId("tok", "team-boundary");
|
||||
await resolveTeamGroupId("tok", "team-boundary");
|
||||
|
||||
expect(fetchGraphJson).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("evicts cached team ids when the current clock is invalid", async () => {
|
||||
vi.mocked(fetchGraphJson).mockResolvedValue({ id: "group-guid-invalid-clock" } as never);
|
||||
|
||||
await resolveTeamGroupId("tok", "team-invalid-clock");
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
|
||||
try {
|
||||
await resolveTeamGroupId("tok", "team-invalid-clock");
|
||||
} finally {
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
|
||||
expect(fetchGraphJson).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("falls back to conversationTeamId when Graph returns no id", async () => {
|
||||
vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never);
|
||||
|
||||
const result = await resolveTeamGroupId("tok", "team-fallback");
|
||||
expect(result).toBe("team-fallback");
|
||||
});
|
||||
|
||||
it("caps cache at 500 entries — evicts oldest on overflow", async () => {
|
||||
vi.mocked(fetchGraphJson).mockResolvedValue({ id: "group-guid" } as never);
|
||||
|
||||
const token = "test-token";
|
||||
for (let i = 0; i < 500; i++) {
|
||||
await resolveTeamGroupId(token, `team-${i}`);
|
||||
}
|
||||
expect(_teamGroupIdCacheForTest.size).toBe(500);
|
||||
expect(_teamGroupIdCacheForTest.has("team-0")).toBe(true);
|
||||
expect(_teamGroupIdCacheForTest.has("team-499")).toBe(true);
|
||||
|
||||
vi.mocked(fetchGraphJson).mockClear();
|
||||
await resolveTeamGroupId(token, "team-500");
|
||||
expect(fetchGraphJson).toHaveBeenCalledTimes(1);
|
||||
expect(_teamGroupIdCacheForTest.size).toBe(500);
|
||||
expect(_teamGroupIdCacheForTest.has("team-0")).toBe(false);
|
||||
expect(_teamGroupIdCacheForTest.has("team-500")).toBe(true);
|
||||
|
||||
vi.mocked(fetchGraphJson).mockClear();
|
||||
await resolveTeamGroupId(token, "team-0");
|
||||
expect(fetchGraphJson).toHaveBeenCalledTimes(1);
|
||||
expect(_teamGroupIdCacheForTest.size).toBe(500);
|
||||
expect(_teamGroupIdCacheForTest.has("team-1")).toBe(false);
|
||||
expect(_teamGroupIdCacheForTest.has("team-500")).toBe(true);
|
||||
|
||||
// team-500 remains cached after team-0 is reinserted at the insertion-order tail.
|
||||
vi.mocked(fetchGraphJson).mockClear();
|
||||
await resolveTeamGroupId(token, "team-500");
|
||||
expect(fetchGraphJson).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchChannelMessage", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchGraphJson).mockReset();
|
||||
@@ -238,6 +142,23 @@ describe("fetchChatMessageText", () => {
|
||||
const result = await fetchChatMessageText("tok", "19:chat", "m-1");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards a shared deadline to the Graph request", async () => {
|
||||
vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never);
|
||||
const deadline = {
|
||||
label: "MS Teams inbound preprocessing",
|
||||
timeoutMs: 10_000,
|
||||
deadlineAtMs: Date.now() + 10_000,
|
||||
};
|
||||
|
||||
await fetchChatMessageText("tok", "19:chat", "m-1", deadline);
|
||||
|
||||
expect(fetchGraphJson).toHaveBeenCalledWith({
|
||||
token: "tok",
|
||||
path: "/chats/19%3Achat/messages/m-1",
|
||||
deadline,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchThreadReplies", () => {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
// Msteams plugin module implements graph thread behavior.
|
||||
import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { fetchGraphJson, type GraphResponse } from "./graph.js";
|
||||
import type { MSTeamsRequestDeadline } from "./request-timeout.js";
|
||||
|
||||
export type GraphThreadMessage = {
|
||||
id?: string;
|
||||
@@ -16,19 +12,6 @@ export type GraphThreadMessage = {
|
||||
createdDateTime?: string;
|
||||
};
|
||||
|
||||
// Successful lookups use a 10-minute TTL and a 500-entry insertion-order cap.
|
||||
// Pruning after insert evicts the oldest team IDs before this process cache grows unbounded.
|
||||
const teamGroupIdCache = new Map<string, { groupId: string; expiresAt: number }>();
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const TEAM_GROUP_ID_CACHE_MAX_ENTRIES = 500;
|
||||
|
||||
function resolveTeamGroupIdCacheExpiresAt(nowRaw = Date.now()): number | undefined {
|
||||
const now = asDateTimestampMs(nowRaw);
|
||||
return now === undefined
|
||||
? undefined
|
||||
: resolveExpiresAtMsFromDurationMs(CACHE_TTL_MS, { nowMs: now });
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML tags from Teams message content, preserving @mention display names.
|
||||
* Teams wraps mentions in <at>Name</at> tags.
|
||||
@@ -52,52 +35,6 @@ export function stripHtmlFromTeamsMessage(html: string): string {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Azure AD group GUID for a Teams conversation team ID.
|
||||
* Results are cached with a TTL to avoid repeated Graph API calls.
|
||||
*/
|
||||
export async function resolveTeamGroupId(
|
||||
token: string,
|
||||
conversationTeamId: string,
|
||||
): Promise<string> {
|
||||
const cached = teamGroupIdCache.get(conversationTeamId);
|
||||
if (cached) {
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
const expiresAt = asDateTimestampMs(cached.expiresAt);
|
||||
if (now !== undefined && expiresAt !== undefined && expiresAt > now) {
|
||||
return cached.groupId;
|
||||
}
|
||||
teamGroupIdCache.delete(conversationTeamId);
|
||||
}
|
||||
|
||||
// The team ID in channelData is typically the group ID itself for standard teams.
|
||||
// Validate by fetching /teams/{id} and returning the confirmed id.
|
||||
// Requires Team.ReadBasic.All permission; fall back to raw ID if missing.
|
||||
try {
|
||||
const path = `/teams/${encodeURIComponent(conversationTeamId)}?$select=id`;
|
||||
const team = await fetchGraphJson<{ id?: string }>({ token, path });
|
||||
const groupId = team.id ?? conversationTeamId;
|
||||
|
||||
// Only cache when the Graph lookup succeeds — caching a fallback raw ID
|
||||
// can cause silent failures for the entire TTL if the ID is not a valid
|
||||
// Graph team GUID (e.g. Bot Framework conversation key).
|
||||
const expiresAt = resolveTeamGroupIdCacheExpiresAt();
|
||||
if (expiresAt !== undefined) {
|
||||
teamGroupIdCache.set(conversationTeamId, {
|
||||
groupId,
|
||||
expiresAt,
|
||||
});
|
||||
pruneMapToMaxSize(teamGroupIdCache, TEAM_GROUP_ID_CACHE_MAX_ENTRIES);
|
||||
}
|
||||
|
||||
return groupId;
|
||||
} catch {
|
||||
// Fallback to raw team ID without caching so subsequent calls retry the
|
||||
// Graph lookup instead of using a potentially invalid cached value.
|
||||
return conversationTeamId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single channel message (the parent/root of a thread).
|
||||
* Returns undefined on error so callers can degrade gracefully.
|
||||
@@ -107,10 +44,15 @@ export async function fetchChannelMessage(
|
||||
groupId: string,
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
deadline?: MSTeamsRequestDeadline,
|
||||
): Promise<GraphThreadMessage | undefined> {
|
||||
const path = `/teams/${encodeURIComponent(groupId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}?$select=id,from,body,createdDateTime`;
|
||||
try {
|
||||
return await fetchGraphJson<GraphThreadMessage>({ token, path });
|
||||
return await fetchGraphJson<GraphThreadMessage>({
|
||||
token,
|
||||
path,
|
||||
...(deadline ? { deadline } : {}),
|
||||
});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -122,8 +64,7 @@ export async function fetchChannelMessage(
|
||||
* Used to recover the complete quoted message for Teams quote replies: the
|
||||
* inbound blockquote only carries a Teams-truncated `preview` snippet. The
|
||||
* app-only `GET /chats/{chatId}/messages/{messageId}` endpoint IS permitted
|
||||
* with the `Chat.Read.All` application permission (unlike the delegated
|
||||
* `/me/chats` listing used by `resolveGraphChatId`, which 400s app-only).
|
||||
* with the `Chat.Read.All` application permission.
|
||||
*
|
||||
* Returns undefined on any failure so callers degrade to the truncated preview.
|
||||
*/
|
||||
@@ -131,13 +72,18 @@ export async function fetchChatMessageText(
|
||||
token: string,
|
||||
chatId: string,
|
||||
messageId: string,
|
||||
deadline?: MSTeamsRequestDeadline,
|
||||
): Promise<string | undefined> {
|
||||
// The get-chatMessage endpoint does not support OData query params (e.g.
|
||||
// `$select`); tenants that enforce the documented contract reject the request,
|
||||
// which would silently fall back to the truncated preview. Request it plainly.
|
||||
const path = `/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(messageId)}`;
|
||||
try {
|
||||
const msg = await fetchGraphJson<GraphThreadMessage>({ token, path });
|
||||
const msg = await fetchGraphJson<GraphThreadMessage>({
|
||||
token,
|
||||
path,
|
||||
...(deadline ? { deadline } : {}),
|
||||
});
|
||||
const raw = msg.body?.content ?? "";
|
||||
const text = msg.body?.contentType === "html" ? stripHtmlFromTeamsMessage(raw) : raw.trim();
|
||||
return text || undefined;
|
||||
@@ -162,13 +108,18 @@ export async function fetchThreadReplies(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
limit = 50,
|
||||
deadline?: MSTeamsRequestDeadline,
|
||||
): Promise<GraphThreadMessage[]> {
|
||||
const top = Math.min(Math.max(limit, 1), 50);
|
||||
// NOTE: Graph replies endpoint returns oldest-first and does not support $orderby.
|
||||
// For threads with >50 replies, only the oldest 50 are returned. The most recent
|
||||
// replies (often the most relevant context) may be truncated.
|
||||
const path = `/teams/${encodeURIComponent(groupId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}/replies?$top=${top}&$select=id,from,body,createdDateTime`;
|
||||
const res = await fetchGraphJson<GraphResponse<GraphThreadMessage>>({ token, path });
|
||||
const res = await fetchGraphJson<GraphResponse<GraphThreadMessage>>({
|
||||
token,
|
||||
path,
|
||||
...(deadline ? { deadline } : {}),
|
||||
});
|
||||
return res.value ?? [];
|
||||
}
|
||||
|
||||
@@ -197,6 +148,3 @@ export function formatThreadContext(
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// Exported for testing only.
|
||||
export { teamGroupIdCache as _teamGroupIdCacheForTest };
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { withFetchPreconnect, withServer } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildTeamsFileInfoCard } from "./graph-chat.js";
|
||||
import { resolveGraphChatId, uploadToOneDrive, uploadToSharePoint } from "./graph-upload.js";
|
||||
import { requireMSTeamsSharePointSiteId, uploadToSharePoint } from "./graph-upload.js";
|
||||
|
||||
type FetchCall = [string, { method?: string; headers?: Record<string, string> } | undefined];
|
||||
|
||||
@@ -37,34 +37,11 @@ describe("graph upload helpers", () => {
|
||||
getAccessToken: vi.fn(async () => "graph-token"),
|
||||
};
|
||||
|
||||
it("uploads to OneDrive with the personal drive path", async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({ id: "item-1", webUrl: "https://example.com/1", name: "a.txt" }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
),
|
||||
it("requires a non-empty SharePoint site ID", () => {
|
||||
expect(() => requireMSTeamsSharePointSiteId()).toThrow(
|
||||
"channels.msteams.sharePointSiteId is required",
|
||||
);
|
||||
|
||||
const result = await uploadToOneDrive({
|
||||
buffer: Buffer.from("hello"),
|
||||
filename: "a.txt",
|
||||
tokenProvider,
|
||||
fetchFn: withFetchPreconnect(fetchFn),
|
||||
});
|
||||
|
||||
expectGraphUploadFetch(
|
||||
fetchFn,
|
||||
"https://graph.microsoft.com/v1.0/me/drive/root:/OpenClawShared/a.txt:/content",
|
||||
);
|
||||
expect(result).toEqual({
|
||||
id: "item-1",
|
||||
webUrl: "https://example.com/1",
|
||||
name: "a.txt",
|
||||
});
|
||||
expect(requireMSTeamsSharePointSiteId(" site-123 ")).toBe("site-123");
|
||||
});
|
||||
|
||||
it("uploads to SharePoint with the site drive path", async () => {
|
||||
@@ -144,111 +121,6 @@ describe("graph upload helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGraphChatId", () => {
|
||||
const tokenProvider = {
|
||||
getAccessToken: vi.fn(async () => "graph-token"),
|
||||
};
|
||||
|
||||
it("returns the ID directly when it already starts with 19:", async () => {
|
||||
const fetchFn = vi.fn();
|
||||
const result = await resolveGraphChatId({
|
||||
botFrameworkConversationId: "19:abc123@thread.tacv2",
|
||||
tokenProvider,
|
||||
fetchFn: withFetchPreconnect(fetchFn),
|
||||
});
|
||||
// Should short-circuit without making any API call
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
expect(result).toBe("19:abc123@thread.tacv2");
|
||||
});
|
||||
|
||||
it("resolves personal DM chat ID via Graph API using user AAD object ID", async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ value: [{ id: "19:dm-chat-id@unq.gbl.spaces" }] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await resolveGraphChatId({
|
||||
botFrameworkConversationId: "a:1abc_bot_framework_dm_id",
|
||||
userAadObjectId: "user-aad-object-id-123",
|
||||
tokenProvider,
|
||||
fetchFn: withFetchPreconnect(fetchFn),
|
||||
});
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
const [callUrlRaw, init] = requireFetchCall(fetchFn);
|
||||
expect(init?.headers?.Authorization).toBe("Bearer graph-token");
|
||||
expect(init?.headers?.["User-Agent"]).toMatch(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/);
|
||||
const callUrl = new URL(callUrlRaw);
|
||||
expect(callUrl.origin).toBe("https://graph.microsoft.com");
|
||||
expect(callUrl.pathname).toBe("/v1.0/me/chats");
|
||||
expect(callUrl.searchParams.get("$filter")).toBe(
|
||||
"chatType eq 'oneOnOne' and members/any(m:m/microsoft.graph.aadUserConversationMember/userId eq 'user-aad-object-id-123')",
|
||||
);
|
||||
expect(callUrl.searchParams.get("$select")).toBe("id");
|
||||
expect(result).toBe("19:dm-chat-id@unq.gbl.spaces");
|
||||
});
|
||||
|
||||
it("resolves personal DM chat ID without user AAD object ID (lists all 1:1 chats)", async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ value: [{ id: "19:fallback-chat@unq.gbl.spaces" }] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await resolveGraphChatId({
|
||||
botFrameworkConversationId: "8:orgid:user-object-id",
|
||||
tokenProvider,
|
||||
fetchFn: withFetchPreconnect(fetchFn),
|
||||
});
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledOnce();
|
||||
expect(result).toBe("19:fallback-chat@unq.gbl.spaces");
|
||||
});
|
||||
|
||||
it("returns null when Graph API returns no chats", async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ value: [] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await resolveGraphChatId({
|
||||
botFrameworkConversationId: "a:1unknown_dm",
|
||||
userAadObjectId: "some-user",
|
||||
tokenProvider,
|
||||
fetchFn: withFetchPreconnect(fetchFn),
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when Graph API call fails", async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async () =>
|
||||
new Response("Unauthorized", {
|
||||
status: 401,
|
||||
headers: { "content-type": "text/plain" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await resolveGraphChatId({
|
||||
botFrameworkConversationId: "a:1some_dm_id",
|
||||
userAadObjectId: "some-user",
|
||||
tokenProvider,
|
||||
fetchFn: withFetchPreconnect(fetchFn),
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("graph upload response limits", () => {
|
||||
const tokenProvider = {
|
||||
getAccessToken: vi.fn(async () => "graph-token"),
|
||||
@@ -294,9 +166,14 @@ describe("graph upload response limits", () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
uploadToOneDrive({ buffer: Buffer.from("x"), filename: "big.txt", tokenProvider }),
|
||||
uploadToSharePoint({
|
||||
buffer: Buffer.from("x"),
|
||||
filename: "big.txt",
|
||||
siteId: "site-123",
|
||||
tokenProvider,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"msteams.graph-upload.uploadOneDriveFile: JSON response exceeds 16777216 bytes",
|
||||
"msteams.graph-upload.uploadSharePointFile: JSON response exceeds 16777216 bytes",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* OneDrive/SharePoint upload utilities for MS Teams file sending.
|
||||
* 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
|
||||
@@ -18,148 +17,26 @@ const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
|
||||
const GRAPH_BETA = "https://graph.microsoft.com/beta";
|
||||
const GRAPH_SCOPE = "https://graph.microsoft.com";
|
||||
|
||||
interface OneDriveUploadResult {
|
||||
export function requireMSTeamsSharePointSiteId(siteId?: string): string {
|
||||
const normalized = siteId?.trim();
|
||||
if (!normalized) {
|
||||
throw new Error(
|
||||
"channels.msteams.sharePointSiteId is required to send files to group chats or channels",
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
interface DriveUploadResult {
|
||||
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).
|
||||
*/
|
||||
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 "OpenClawShared" folder to organize bot-uploaded files
|
||||
const uploadPath = `/OpenClawShared/${encodeURIComponent(params.filename)}`;
|
||||
|
||||
const res = await fetchFn(`${GRAPH_ROOT}/me/drive/root:${uploadPath}:/content`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"User-Agent": buildUserAgent(),
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": params.contentType ?? "application/octet-stream",
|
||||
},
|
||||
body: new Uint8Array(params.buffer),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw await createMSTeamsHttpError(res, "OneDrive upload failed");
|
||||
}
|
||||
|
||||
const data = await readProviderJsonResponse<{
|
||||
id?: string;
|
||||
webUrl?: string;
|
||||
name?: string;
|
||||
}>(res, "msteams.graph-upload.uploadOneDriveFile");
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
interface OneDriveSharingLink {
|
||||
interface SharingLinkResult {
|
||||
webUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a sharing link for a OneDrive file.
|
||||
* The link allows organization members to view the file.
|
||||
*/
|
||||
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: {
|
||||
"User-Agent": buildUserAgent(),
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: "view",
|
||||
scope: params.scope ?? "organization",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw await createMSTeamsHttpError(res, "Create sharing link failed");
|
||||
}
|
||||
|
||||
const data = await readProviderJsonResponse<{
|
||||
link?: { webUrl?: string };
|
||||
}>(res, "msteams.graph-upload.createOneDriveSharingLink");
|
||||
|
||||
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
|
||||
// ============================================================================
|
||||
@@ -177,7 +54,7 @@ export async function uploadToSharePoint(params: {
|
||||
tokenProvider: MSTeamsAccessTokenProvider;
|
||||
siteId: string;
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<OneDriveUploadResult> {
|
||||
}): Promise<DriveUploadResult> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
||||
|
||||
@@ -220,7 +97,6 @@ export async function uploadToSharePoint(params: {
|
||||
|
||||
interface ChatMember {
|
||||
aadObjectId: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -278,80 +154,6 @@ export async function getDriveItemProperties(params: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Graph API-native chat ID from a Bot Framework conversation ID.
|
||||
*
|
||||
* Bot Framework personal DM conversation IDs use formats like `a:1xxx@unq.gbl.spaces`
|
||||
* or `8:orgid:xxx` that the Graph API does not accept. Graph API requires the
|
||||
* `19:xxx@thread.tacv2` or `19:xxx@unq.gbl.spaces` format.
|
||||
*
|
||||
* This function looks up the matching Graph chat by querying the bot's chats filtered
|
||||
* by the target user's AAD object ID.
|
||||
*/
|
||||
export async function resolveGraphChatId(params: {
|
||||
/** Bot Framework conversation ID (may be in non-Graph format for personal DMs) */
|
||||
botFrameworkConversationId: string;
|
||||
/** AAD object ID of the user in the conversation (used for filtering chats) */
|
||||
userAadObjectId?: string;
|
||||
tokenProvider: MSTeamsAccessTokenProvider;
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<string | null> {
|
||||
const { botFrameworkConversationId, userAadObjectId, tokenProvider } = params;
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
|
||||
// If the conversation ID already looks like a valid Graph chat ID, return it directly.
|
||||
// Graph chat IDs start with "19:" — Bot Framework group chat IDs already use this format.
|
||||
if (botFrameworkConversationId.startsWith("19:")) {
|
||||
return botFrameworkConversationId;
|
||||
}
|
||||
|
||||
// For personal DMs with non-Graph conversation IDs (e.g. `a:1xxx` or `8:orgid:xxx`),
|
||||
// query the bot's chats to find the matching one.
|
||||
const token = await tokenProvider.getAccessToken(GRAPH_SCOPE);
|
||||
|
||||
// Build filter: if we have the user's AAD object ID, narrow the search to 1:1 chats
|
||||
// with that member. Otherwise, fall back to listing all 1:1 chats.
|
||||
let path: string;
|
||||
if (userAadObjectId) {
|
||||
const encoded = encodeURIComponent(
|
||||
`chatType eq 'oneOnOne' and members/any(m:m/microsoft.graph.aadUserConversationMember/userId eq '${userAadObjectId}')`,
|
||||
);
|
||||
path = `/me/chats?$filter=${encoded}&$select=id`;
|
||||
} else {
|
||||
// Fallback: list all 1:1 chats when no user ID is available.
|
||||
// Only safe when the bot has exactly one 1:1 chat; returns null otherwise to
|
||||
// avoid sending to the wrong person's chat.
|
||||
path = `/me/chats?$filter=${encodeURIComponent("chatType eq 'oneOnOne'")}&$select=id`;
|
||||
}
|
||||
|
||||
const res = await fetchFn(`${GRAPH_ROOT}${path}`, {
|
||||
headers: { "User-Agent": buildUserAgent(), Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await readProviderJsonResponse<{
|
||||
value?: Array<{ id?: string }>;
|
||||
}>(res, "msteams.graph-upload.getOneOnOneChatId");
|
||||
|
||||
const chats = data.value ?? [];
|
||||
|
||||
// When filtered by userAadObjectId, any non-empty result is the right 1:1 chat.
|
||||
if (userAadObjectId && chats.length > 0 && chats[0]?.id) {
|
||||
return chats[0].id;
|
||||
}
|
||||
|
||||
// Without a user ID we can only be certain when exactly one chat is returned;
|
||||
// multiple results would be ambiguous and could route to the wrong person.
|
||||
if (!userAadObjectId && chats.length === 1 && chats[0]?.id) {
|
||||
return chats[0].id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get members of a Teams chat for per-user sharing.
|
||||
* Used to create sharing links scoped to only the chat participants.
|
||||
@@ -373,17 +175,11 @@ async function getChatMembers(params: {
|
||||
}
|
||||
|
||||
const data = await readProviderJsonResponse<{
|
||||
value?: Array<{
|
||||
userId?: string;
|
||||
displayName?: string;
|
||||
}>;
|
||||
value?: Array<{ userId?: string }>;
|
||||
}>(res, "msteams.graph-upload.getChatMembers");
|
||||
|
||||
return (data.value ?? [])
|
||||
.map((m) => ({
|
||||
aadObjectId: m.userId ?? "",
|
||||
displayName: m.displayName,
|
||||
}))
|
||||
.map((m) => ({ aadObjectId: m.userId ?? "" }))
|
||||
.filter((m) => m.aadObjectId);
|
||||
}
|
||||
|
||||
@@ -401,7 +197,7 @@ async function createSharePointSharingLink(params: {
|
||||
/** Required when scope is "users": AAD object IDs of recipients */
|
||||
recipientObjectIds?: string[];
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<OneDriveSharingLink> {
|
||||
}): Promise<SharingLinkResult> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
||||
const scope = params.scope ?? "organization";
|
||||
|
||||
@@ -279,6 +279,25 @@ describe("msteams graph helpers", () => {
|
||||
expect(arrayBuffer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the remaining operation deadline to the guarded Graph transport", async () => {
|
||||
mockGraphCollection(groupOne);
|
||||
const remainingMs = 5_000;
|
||||
|
||||
await fetchGraphJson({
|
||||
token: graphToken,
|
||||
path: "/groups?$select=id",
|
||||
deadline: {
|
||||
label: "MS Teams inbound preprocessing",
|
||||
timeoutMs: remainingMs,
|
||||
deadlineAtMs: Date.now() + remainingMs,
|
||||
},
|
||||
});
|
||||
|
||||
const timeoutMs = fetchWithSsrFGuardMock.mock.calls[0]?.[0]?.timeoutMs;
|
||||
expect(timeoutMs).toBeGreaterThan(0);
|
||||
expect(timeoutMs).toBeLessThanOrEqual(remainingMs);
|
||||
});
|
||||
|
||||
it("bounds absolute Graph pagination requests", async () => {
|
||||
mockGraphCollection(groupOne);
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ import { fetchWithSsrFGuard, type MSTeamsConfig } from "../runtime-api.js";
|
||||
import { GRAPH_ROOT } from "./attachments/shared.js";
|
||||
import { resolveMSTeamsSdkCloudOptions } from "./cloud.js";
|
||||
import { createMSTeamsHttpError } from "./http-error.js";
|
||||
import {
|
||||
MSTEAMS_REQUEST_TIMEOUT_MS,
|
||||
resolveMSTeamsRequestTimeoutMs,
|
||||
type MSTeamsRequestDeadline,
|
||||
} from "./request-timeout.js";
|
||||
import { responseWithRelease } from "./response-with-release.js";
|
||||
import { createMSTeamsTokenProvider, loadMSTeamsSdkWithAuth } from "./sdk.js";
|
||||
import { readAccessToken } from "./token-response.js";
|
||||
@@ -11,7 +16,6 @@ import { resolveDelegatedAccessToken, resolveMSTeamsCredentials } from "./token.
|
||||
import { buildUserAgent } from "./user-agent.js";
|
||||
|
||||
const GRAPH_BETA = "https://graph.microsoft.com/beta";
|
||||
const GRAPH_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
export type GraphUser = {
|
||||
id?: string;
|
||||
@@ -48,6 +52,7 @@ async function requestGraph(params: {
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
errorPrefix?: string;
|
||||
deadline?: MSTeamsRequestDeadline;
|
||||
}): Promise<Response> {
|
||||
const hasBody = params.body !== undefined;
|
||||
const url = `${params.root ?? GRAPH_ROOT}${params.path}`;
|
||||
@@ -66,7 +71,7 @@ async function requestGraph(params: {
|
||||
body: hasBody ? JSON.stringify(params.body) : undefined,
|
||||
},
|
||||
auditContext: "msteams.graph",
|
||||
timeoutMs: GRAPH_REQUEST_TIMEOUT_MS,
|
||||
timeoutMs: resolveMSTeamsRequestTimeoutMs(params.deadline),
|
||||
});
|
||||
let releaseInFinally = true;
|
||||
try {
|
||||
@@ -102,6 +107,8 @@ export async function fetchGraphJson<T>(params: {
|
||||
method?: string;
|
||||
/** Request body (serialized as JSON). Only used for non-GET methods. */
|
||||
body?: unknown;
|
||||
/** Optional shared operation deadline; actively aborts the guarded fetch when spent. */
|
||||
deadline?: MSTeamsRequestDeadline;
|
||||
}): Promise<T> {
|
||||
const res = await requestGraph({
|
||||
token: params.token,
|
||||
@@ -109,6 +116,7 @@ export async function fetchGraphJson<T>(params: {
|
||||
method: params.method as "GET" | "POST" | "DELETE" | undefined,
|
||||
body: params.body,
|
||||
headers: params.headers,
|
||||
deadline: params.deadline,
|
||||
});
|
||||
return await readOptionalGraphJson<T>(res, `Graph ${params.path} failed`);
|
||||
}
|
||||
@@ -132,7 +140,7 @@ export async function fetchGraphAbsoluteUrl<T>(params: {
|
||||
},
|
||||
},
|
||||
auditContext: "msteams.graph.absolute",
|
||||
timeoutMs: GRAPH_REQUEST_TIMEOUT_MS,
|
||||
timeoutMs: MSTEAMS_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -131,30 +131,6 @@ export function stripMSTeamsMentionTags(text: string): string {
|
||||
return text.replace(/<at[^>]*>.*?<\/at>/gi, "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bot Framework uses 'a:xxx' conversation IDs for personal chats, but Graph API
|
||||
* requires the '19:{userId}_{botAppId}@unq.gbl.spaces' format.
|
||||
*
|
||||
* This is the documented Graph API format for 1:1 chat thread IDs between a user
|
||||
* and a bot/app. See Microsoft docs "Get chat between user and app":
|
||||
* https://learn.microsoft.com/en-us/graph/api/userscopeteamsappinstallation-get-chat
|
||||
*
|
||||
* The format is only synthesized when the Bot Framework conversation ID starts with
|
||||
* 'a:' (the opaque format used by BF but not recognized by Graph). If the ID already
|
||||
* has the '19:...' Graph format, it is passed through unchanged.
|
||||
*/
|
||||
export function translateMSTeamsDmConversationIdForGraph(params: {
|
||||
isDirectMessage: boolean;
|
||||
conversationId: string;
|
||||
aadObjectId?: string | null;
|
||||
appId?: string | null;
|
||||
}): string {
|
||||
const { isDirectMessage, conversationId, aadObjectId, appId } = params;
|
||||
return isDirectMessage && conversationId.startsWith("a:") && aadObjectId && appId
|
||||
? `19:${aadObjectId}_${appId}@unq.gbl.spaces`
|
||||
: conversationId;
|
||||
}
|
||||
|
||||
export function wasMSTeamsBotMentioned(activity: MentionableActivity): boolean {
|
||||
const botId = activity.recipient?.id;
|
||||
if (!botId) {
|
||||
|
||||
@@ -7,14 +7,14 @@ import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { StoredConversationReference } from "./conversation-store.js";
|
||||
const graphUploadMockState = vi.hoisted(() => ({
|
||||
uploadAndShareOneDrive: vi.fn(),
|
||||
uploadAndShareSharePoint: vi.fn(),
|
||||
getDriveItemProperties: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./graph-upload.js", () => {
|
||||
vi.mock("./graph-upload.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./graph-upload.js")>();
|
||||
return {
|
||||
uploadAndShareOneDrive: graphUploadMockState.uploadAndShareOneDrive,
|
||||
...actual,
|
||||
uploadAndShareSharePoint: graphUploadMockState.uploadAndShareSharePoint,
|
||||
getDriveItemProperties: graphUploadMockState.getDriveItemProperties,
|
||||
};
|
||||
@@ -76,14 +76,6 @@ const createRecordedSendActivity = (
|
||||
|
||||
const REVOCATION_ERROR = "Cannot perform 'set' on a proxy that has been revoked";
|
||||
|
||||
function requireSentMessage(sent: Array<{ text?: string; entities?: unknown[] }>) {
|
||||
const firstSent = sent[0];
|
||||
if (!firstSent?.text) {
|
||||
throw new Error("expected Teams message send to include rendered text");
|
||||
}
|
||||
return firstSent;
|
||||
}
|
||||
|
||||
function findEntity(
|
||||
entities: unknown,
|
||||
predicate: (entity: Record<string, unknown>) => boolean,
|
||||
@@ -104,14 +96,6 @@ function requireAiGeneratedEntity(entities: unknown): Record<string, unknown> {
|
||||
return entity;
|
||||
}
|
||||
|
||||
function requireMentionEntity(entities: unknown): Record<string, unknown> {
|
||||
const entity = findEntity(entities, (candidate) => candidate.type === "mention");
|
||||
if (!entity) {
|
||||
throw new Error("expected Teams mention entity");
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
type MockAppOptions = {
|
||||
createFn?: (activity: unknown) => Promise<unknown>;
|
||||
onClientCreated?: (serviceUrl: string, conversationId: string) => void;
|
||||
@@ -180,15 +164,8 @@ function createMockApp(opts?: MockAppOptions): MSTeamsApp {
|
||||
describe("msteams messenger", () => {
|
||||
beforeEach(() => {
|
||||
setMSTeamsRuntime(runtimeStub);
|
||||
graphUploadMockState.uploadAndShareOneDrive.mockReset();
|
||||
graphUploadMockState.uploadAndShareSharePoint.mockReset();
|
||||
graphUploadMockState.getDriveItemProperties.mockReset();
|
||||
graphUploadMockState.uploadAndShareOneDrive.mockResolvedValue({
|
||||
itemId: "item123",
|
||||
webUrl: "https://onedrive.example.com/item123",
|
||||
shareUrl: "https://onedrive.example.com/share/item123",
|
||||
name: "upload.txt",
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderReplyPayloadsToMessages", () => {
|
||||
@@ -345,55 +322,28 @@ describe("msteams messenger", () => {
|
||||
expect(capturedConversationId).toBe("19:abc@thread.tacv2");
|
||||
});
|
||||
|
||||
it("preserves parsed mentions when appending OneDrive fallback file links", async () => {
|
||||
const tmpDir = await mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "msteams-mention-"));
|
||||
it("requires SharePoint storage for channel files", async () => {
|
||||
const tmpDir = await mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "msteams-storage-"));
|
||||
const localFile = path.join(tmpDir, "note.txt");
|
||||
await writeFile(localFile, "hello");
|
||||
|
||||
try {
|
||||
const sent: Array<{ text?: string; entities?: unknown[] }> = [];
|
||||
const ctx = {
|
||||
sendActivity: async (activity: unknown) => {
|
||||
sent.push(activity as { text?: string; entities?: unknown[] });
|
||||
return { id: "id:one" };
|
||||
},
|
||||
};
|
||||
|
||||
const ids = await sendMSTeamsMessages({
|
||||
replyStyle: "thread",
|
||||
app: createMockApp(),
|
||||
appId: "app123",
|
||||
conversationRef: {
|
||||
...baseRef,
|
||||
conversation: {
|
||||
...baseRef.conversation,
|
||||
conversationType: "channel",
|
||||
await expect(
|
||||
sendMSTeamsMessages({
|
||||
replyStyle: "thread",
|
||||
app: createMockApp(),
|
||||
appId: "app123",
|
||||
conversationRef: {
|
||||
...baseRef,
|
||||
conversation: {
|
||||
...baseRef.conversation,
|
||||
conversationType: "channel",
|
||||
},
|
||||
},
|
||||
},
|
||||
context: ctx,
|
||||
messages: [{ text: "Hello @[John](29:08q2j2o3jc09au90eucae)", mediaUrl: localFile }],
|
||||
tokenProvider: {
|
||||
getAccessToken: async () => "token",
|
||||
},
|
||||
});
|
||||
|
||||
expect(ids).toEqual(["id:one"]);
|
||||
expect(graphUploadMockState.uploadAndShareOneDrive).toHaveBeenCalledOnce();
|
||||
expect(sent).toHaveLength(1);
|
||||
const firstSent = requireSentMessage(sent);
|
||||
expect(firstSent.text).toContain("Hello <at>John</at>");
|
||||
expect(firstSent.text).toContain(
|
||||
"📎 [upload.txt](https://onedrive.example.com/share/item123)",
|
||||
);
|
||||
const mentionEntity = requireMentionEntity(sent[0]?.entities);
|
||||
expect(mentionEntity.text).toBe("<at>John</at>");
|
||||
expect(mentionEntity.mentioned).toEqual({
|
||||
id: "29:08q2j2o3jc09au90eucae",
|
||||
name: "John",
|
||||
});
|
||||
expect(requireAiGeneratedEntity(sent[0]?.entities).additionalType).toEqual([
|
||||
"AIGeneratedContent",
|
||||
]);
|
||||
messages: [{ text: "one", mediaUrl: localFile }],
|
||||
tokenProvider: { getAccessToken: async () => "token" },
|
||||
}),
|
||||
).rejects.toThrow("channels.msteams.sharePointSiteId is required");
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -431,18 +381,23 @@ describe("msteams messenger", () => {
|
||||
const attempts: string[] = [];
|
||||
const retryEvents: Array<{ nextAttempt: number; delayMs: number }> = [];
|
||||
let uploadAttempts = 0;
|
||||
graphUploadMockState.uploadAndShareOneDrive.mockImplementation(async () => {
|
||||
graphUploadMockState.uploadAndShareSharePoint.mockImplementation(async () => {
|
||||
uploadAttempts += 1;
|
||||
if (uploadAttempts === 1) {
|
||||
throw Object.assign(new Error("transient upload failure"), { statusCode: 429 });
|
||||
}
|
||||
return {
|
||||
itemId: "item123",
|
||||
webUrl: "https://onedrive.example.com/item123",
|
||||
shareUrl: "https://onedrive.example.com/share/item123",
|
||||
webUrl: "https://sharepoint.example.com/item123",
|
||||
shareUrl: "https://sharepoint.example.com/share/item123",
|
||||
name: "retry.txt",
|
||||
};
|
||||
});
|
||||
graphUploadMockState.getDriveItemProperties.mockResolvedValue({
|
||||
eTag: '"{ITEM-123},1"',
|
||||
webDavUrl: "https://sharepoint.example.com/item123",
|
||||
name: "retry.txt",
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
sendActivity: createRecordedSendActivity(attempts),
|
||||
@@ -463,14 +418,14 @@ describe("msteams messenger", () => {
|
||||
tokenProvider: {
|
||||
getAccessToken: async () => "token",
|
||||
},
|
||||
sharePointSiteId: "site-123",
|
||||
retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
|
||||
onRetry: (e) => retryEvents.push({ nextAttempt: e.nextAttempt, delayMs: e.delayMs }),
|
||||
});
|
||||
|
||||
expect(uploadAttempts).toBe(2);
|
||||
expect(attempts).toHaveLength(1);
|
||||
expect(attempts[0]).toContain("📎 [retry.txt]");
|
||||
expect(ids).toEqual([`id:${attempts[0]}`]);
|
||||
expect(attempts).toEqual(["one"]);
|
||||
expect(ids).toEqual(["id:one"]);
|
||||
expect(retryEvents).toEqual([{ nextAttempt: 2, delayMs: 0 }]);
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
|
||||
@@ -19,7 +19,7 @@ import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-
|
||||
import { buildTeamsFileInfoCard } from "./graph-chat.js";
|
||||
import {
|
||||
getDriveItemProperties,
|
||||
uploadAndShareOneDrive,
|
||||
requireMSTeamsSharePointSiteId,
|
||||
uploadAndShareSharePoint,
|
||||
} from "./graph-upload.js";
|
||||
import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js";
|
||||
@@ -30,7 +30,7 @@ import { getMSTeamsRuntime } from "./runtime.js";
|
||||
|
||||
/**
|
||||
* MSTeams-specific media size limit (100MB).
|
||||
* Higher than the default because OneDrive upload handles large files well.
|
||||
* Higher than the default to support Teams file-consent and SharePoint uploads.
|
||||
*/
|
||||
const MSTEAMS_MAX_MEDIA_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
@@ -342,27 +342,27 @@ export async function buildActivity(
|
||||
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.
|
||||
// Use the cached Graph-native chat ID when available — Bot Framework conversation IDs
|
||||
// for personal DMs use a format (e.g. `a:1xxx`) that Graph API rejects.
|
||||
const chatId = conversationRef.graphChatId ?? conversationRef.conversation?.id;
|
||||
if (!isPersonal && !isImage) {
|
||||
// Non-images in group chats/channels require SharePoint because an
|
||||
// application token has no signed-in `/me/drive` to fall back to.
|
||||
const siteId = requireMSTeamsSharePointSiteId(sharePointSiteId);
|
||||
if (!tokenProvider) {
|
||||
throw new Error("MS Teams Graph token provider unavailable for SharePoint file send");
|
||||
}
|
||||
const chatId = conversationRef.conversation?.id;
|
||||
|
||||
// Upload to SharePoint
|
||||
const uploaded = await uploadAndShareSharePoint({
|
||||
buffer: media.buffer,
|
||||
filename: fileName,
|
||||
contentType,
|
||||
tokenProvider,
|
||||
siteId: sharePointSiteId,
|
||||
siteId,
|
||||
chatId: chatId ?? undefined,
|
||||
usePerUserSharing: conversationType === "groupchat",
|
||||
});
|
||||
|
||||
// Get driveItem properties needed for native file card attachment
|
||||
const driveItem = await getDriveItemProperties({
|
||||
siteId: sharePointSiteId,
|
||||
siteId,
|
||||
itemId: uploaded.itemId,
|
||||
tokenProvider,
|
||||
});
|
||||
@@ -374,22 +374,6 @@ export async function buildActivity(
|
||||
return activity;
|
||||
}
|
||||
|
||||
if (!isPersonal && media.kind !== "image" && 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})`;
|
||||
const existingText = typeof activity.text === "string" ? activity.text : undefined;
|
||||
activity.text = existingText ? `${existingText}\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}`;
|
||||
@@ -416,7 +400,7 @@ export async function sendMSTeamsMessages(params: {
|
||||
messages: MSTeamsRenderedMessage[];
|
||||
retry?: false | MSTeamsSendRetryOptions;
|
||||
onRetry?: (event: MSTeamsSendRetryEvent) => void;
|
||||
/** Token provider for OneDrive/SharePoint uploads in group chats/channels */
|
||||
/** Token provider for SharePoint uploads in group chats/channels */
|
||||
tokenProvider?: MSTeamsAccessTokenProvider;
|
||||
/** SharePoint site ID for file uploads in group chats/channels */
|
||||
sharePointSiteId?: string;
|
||||
|
||||
@@ -5,9 +5,12 @@ vi.mock("../attachments.js", () => ({
|
||||
downloadMSTeamsAttachments: vi.fn(async () => []),
|
||||
downloadMSTeamsGraphMedia: vi.fn(async () => ({ media: [] })),
|
||||
downloadMSTeamsBotFrameworkAttachments: vi.fn(async () => ({ media: [], attachmentCount: 0 })),
|
||||
buildMSTeamsGraphMessageUrls: vi.fn(() => [
|
||||
"https://graph.microsoft.com/v1.0/chats/c/messages/m",
|
||||
]),
|
||||
buildMSTeamsGraphMessageUrl: vi.fn(
|
||||
(params: { conversationType: string; teamAadGroupId?: string }) =>
|
||||
params.conversationType.toLowerCase() === "channel" && params.teamAadGroupId === undefined
|
||||
? undefined
|
||||
: "https://graph.microsoft.com/v1.0/teams/team-aad-guid/channels/chan/messages/m",
|
||||
),
|
||||
extractMSTeamsHtmlAttachmentIds: vi.fn(() => ["att-0", "att-1"]),
|
||||
isBotFrameworkPersonalChatId: vi.fn((id: string | null | undefined) => {
|
||||
if (typeof id !== "string") {
|
||||
@@ -18,7 +21,7 @@ vi.mock("../attachments.js", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
buildMSTeamsGraphMessageUrls,
|
||||
buildMSTeamsGraphMessageUrl,
|
||||
downloadMSTeamsAttachments,
|
||||
downloadMSTeamsBotFrameworkAttachments,
|
||||
downloadMSTeamsGraphMedia,
|
||||
@@ -26,15 +29,35 @@ import {
|
||||
} from "../attachments.js";
|
||||
import { resolveMSTeamsInboundMedia, resolveMSTeamsInboundMediaBody } from "./inbound-media.js";
|
||||
|
||||
// Channel context by default: the Graph fallback is a channel/group code path,
|
||||
// so its trigger tests must run against a channel conversation, not a DM.
|
||||
const baseParams = {
|
||||
maxBytes: 1024 * 1024,
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
|
||||
conversationType: "personal",
|
||||
conversationId: "19:user_bot@unq.gbl.spaces",
|
||||
activity: { id: "msg-1", replyToId: undefined, channelData: {} },
|
||||
conversationType: "channel",
|
||||
conversationId: "19:channel-thread@thread.tacv2",
|
||||
teamAadGroupId: "team-aad-guid",
|
||||
activity: {
|
||||
id: "msg-1",
|
||||
replyToId: undefined,
|
||||
channelData: {
|
||||
team: { id: "19:team-general@thread.tacv2", aadGroupId: "team-aad-guid" },
|
||||
channel: { id: "19:channel-thread@thread.tacv2" },
|
||||
},
|
||||
},
|
||||
log: { debug: vi.fn() },
|
||||
};
|
||||
|
||||
const htmlSummary = {
|
||||
htmlAttachments: 1,
|
||||
imgTags: 0,
|
||||
dataImages: 0,
|
||||
cidImages: 0,
|
||||
srcHosts: [],
|
||||
attachmentTags: 0,
|
||||
attachmentIds: [],
|
||||
};
|
||||
|
||||
function firstGraphMediaCall() {
|
||||
const [call] = vi.mocked(downloadMSTeamsGraphMedia).mock.calls;
|
||||
if (!call) {
|
||||
@@ -102,30 +125,93 @@ describe("resolveMSTeamsInboundMedia graph fallback trigger", () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(buildMSTeamsGraphMessageUrls).toHaveBeenCalled();
|
||||
expect(buildMSTeamsGraphMessageUrl).toHaveBeenCalled();
|
||||
expect(downloadMSTeamsGraphMedia).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT trigger Graph fallback for mention-only HTML (no <attachment> tags)", async () => {
|
||||
it("triggers opted-in Graph fallback for text plus a tagless channel file stub", async () => {
|
||||
vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]);
|
||||
// Mention cards include `<at>` markers but no `<attachment id="...">`,
|
||||
// so the extractor returns an empty ID list. The fallback must skip.
|
||||
vi.mocked(extractMSTeamsHtmlAttachmentIds).mockReturnValueOnce([]);
|
||||
vi.mocked(downloadMSTeamsGraphMedia).mockClear();
|
||||
vi.mocked(buildMSTeamsGraphMessageUrls).mockClear();
|
||||
vi.mocked(buildMSTeamsGraphMessageUrl).mockClear();
|
||||
|
||||
await resolveMSTeamsInboundMedia({
|
||||
...baseParams,
|
||||
graphMediaFallback: true,
|
||||
htmlSummary,
|
||||
attachments: [
|
||||
{
|
||||
contentType: "text/html",
|
||||
content: '<div><at id="0">Bot</at> hello there</div>',
|
||||
contentType: "Text/HTML; charset=utf-8",
|
||||
content: '<div><at id="0">Bot</at></div>',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(buildMSTeamsGraphMessageUrl).toHaveBeenCalled();
|
||||
expect(downloadMSTeamsGraphMedia).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps marker-free Graph fallback disabled by default", async () => {
|
||||
vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]);
|
||||
vi.mocked(extractMSTeamsHtmlAttachmentIds).mockReturnValueOnce([]);
|
||||
vi.mocked(downloadMSTeamsGraphMedia).mockClear();
|
||||
vi.mocked(buildMSTeamsGraphMessageUrl).mockClear();
|
||||
|
||||
await resolveMSTeamsInboundMedia({
|
||||
...baseParams,
|
||||
htmlSummary,
|
||||
attachments: [
|
||||
{
|
||||
contentType: "text/html",
|
||||
content: '<div><at id="0">Bot</at> Describe the attached image file</div>',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(buildMSTeamsGraphMessageUrl).not.toHaveBeenCalled();
|
||||
expect(downloadMSTeamsGraphMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("triggers Graph fallback for a tagless group-chat HTML attachment", async () => {
|
||||
vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]);
|
||||
vi.mocked(extractMSTeamsHtmlAttachmentIds).mockReturnValueOnce([]);
|
||||
vi.mocked(downloadMSTeamsGraphMedia).mockClear();
|
||||
vi.mocked(buildMSTeamsGraphMessageUrl).mockClear();
|
||||
|
||||
await resolveMSTeamsInboundMedia({
|
||||
...baseParams,
|
||||
graphMediaFallback: true,
|
||||
conversationType: "groupChat",
|
||||
conversationId: "19:group-chat@thread.v2",
|
||||
teamAadGroupId: undefined,
|
||||
activity: { id: "msg-1", replyToId: undefined, channelData: {} },
|
||||
htmlSummary,
|
||||
attachments: [{ contentType: "text/html", content: "<div>file stub</div>" }],
|
||||
});
|
||||
|
||||
expect(buildMSTeamsGraphMessageUrl).toHaveBeenCalled();
|
||||
expect(downloadMSTeamsGraphMedia).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not widen marker-free fallback to a Graph-compatible personal chat", async () => {
|
||||
vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]);
|
||||
vi.mocked(extractMSTeamsHtmlAttachmentIds).mockReturnValueOnce([]);
|
||||
vi.mocked(downloadMSTeamsGraphMedia).mockClear();
|
||||
vi.mocked(buildMSTeamsGraphMessageUrl).mockClear();
|
||||
|
||||
await resolveMSTeamsInboundMedia({
|
||||
...baseParams,
|
||||
graphMediaFallback: true,
|
||||
conversationType: "personal",
|
||||
conversationId: "19:real-graph-chat@unq.gbl.spaces",
|
||||
teamAadGroupId: undefined,
|
||||
activity: { id: "msg-1", replyToId: undefined, channelData: {} },
|
||||
htmlSummary,
|
||||
attachments: [{ contentType: "text/html", content: "<div>mention only</div>" }],
|
||||
});
|
||||
|
||||
expect(buildMSTeamsGraphMessageUrl).not.toHaveBeenCalled();
|
||||
expect(downloadMSTeamsGraphMedia).not.toHaveBeenCalled();
|
||||
expect(buildMSTeamsGraphMessageUrls).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT trigger Graph fallback when no attachments are text/html", async () => {
|
||||
@@ -133,10 +219,11 @@ describe("resolveMSTeamsInboundMedia graph fallback trigger", () => {
|
||||
// No HTML attachments at all → extractor returns [].
|
||||
vi.mocked(extractMSTeamsHtmlAttachmentIds).mockReturnValueOnce([]);
|
||||
vi.mocked(downloadMSTeamsGraphMedia).mockClear();
|
||||
vi.mocked(buildMSTeamsGraphMessageUrls).mockClear();
|
||||
vi.mocked(buildMSTeamsGraphMessageUrl).mockClear();
|
||||
|
||||
await resolveMSTeamsInboundMedia({
|
||||
...baseParams,
|
||||
graphMediaFallback: true,
|
||||
attachments: [
|
||||
{ contentType: "image/png", contentUrl: "https://example.com/img.png" },
|
||||
{ contentType: "application/pdf", contentUrl: "https://example.com/doc.pdf" },
|
||||
@@ -146,6 +233,85 @@ describe("resolveMSTeamsInboundMedia graph fallback trigger", () => {
|
||||
expect(downloadMSTeamsGraphMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not resolve Graph team identity when direct media succeeds", async () => {
|
||||
vi.mocked(downloadMSTeamsAttachments).mockResolvedValueOnce([
|
||||
{ path: "/tmp/direct.png", contentType: "image/png", placeholder: "<media:image>" },
|
||||
]);
|
||||
const resolveTeamAadGroupId = vi.fn(async () => "team-aad-guid");
|
||||
|
||||
await resolveMSTeamsInboundMedia({
|
||||
...baseParams,
|
||||
teamAadGroupId: undefined,
|
||||
resolveTeamAadGroupId,
|
||||
attachments: [{ contentType: "image/png", contentUrl: "https://example.com/direct.png" }],
|
||||
});
|
||||
|
||||
expect(resolveTeamAadGroupId).not.toHaveBeenCalled();
|
||||
expect(downloadMSTeamsGraphMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards canonical channel reply identifiers to the URL builder", async () => {
|
||||
vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]);
|
||||
vi.mocked(extractMSTeamsHtmlAttachmentIds).mockReturnValueOnce(["att-0"]);
|
||||
vi.mocked(buildMSTeamsGraphMessageUrl).mockClear();
|
||||
vi.mocked(downloadMSTeamsGraphMedia).mockResolvedValue({ media: [] });
|
||||
|
||||
await resolveMSTeamsInboundMedia({
|
||||
...baseParams,
|
||||
conversationMessageId: "conversation-root",
|
||||
teamAadGroupId: "entra-team-id",
|
||||
activity: {
|
||||
id: "reply-id",
|
||||
replyToId: "activity-root",
|
||||
channelData: {
|
||||
team: { id: "bot-framework-team-id", aadGroupId: "stale-activity-value" },
|
||||
channel: { id: "channel-id" },
|
||||
},
|
||||
},
|
||||
attachments: [
|
||||
{
|
||||
contentType: "text/html",
|
||||
content: '<attachment id="att-0"></attachment>',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(buildMSTeamsGraphMessageUrl).toHaveBeenCalledWith({
|
||||
conversationType: "channel",
|
||||
conversationId: "19:channel-thread@thread.tacv2",
|
||||
messageId: "reply-id",
|
||||
threadRootMessageId: "conversation-root",
|
||||
teamAadGroupId: "entra-team-id",
|
||||
channelId: "channel-id",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when a channel AAD group ID could not be resolved", async () => {
|
||||
vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]);
|
||||
vi.mocked(extractMSTeamsHtmlAttachmentIds).mockReturnValueOnce(["att-0"]);
|
||||
vi.mocked(buildMSTeamsGraphMessageUrl).mockClear();
|
||||
vi.mocked(downloadMSTeamsGraphMedia).mockClear();
|
||||
|
||||
await resolveMSTeamsInboundMedia({
|
||||
...baseParams,
|
||||
teamAadGroupId: undefined,
|
||||
attachments: [
|
||||
{
|
||||
contentType: "text/html",
|
||||
content: '<attachment id="att-0"></attachment>',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(buildMSTeamsGraphMessageUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamAadGroupId: undefined,
|
||||
channelId: "19:channel-thread@thread.tacv2",
|
||||
}),
|
||||
);
|
||||
expect(downloadMSTeamsGraphMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT trigger Graph fallback when direct download succeeds", async () => {
|
||||
vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([
|
||||
{ path: "/tmp/img.png", contentType: "image/png", placeholder: "[image]" },
|
||||
@@ -188,16 +354,12 @@ describe("resolveMSTeamsInboundMedia graph fallback trigger", () => {
|
||||
// message fetch failures instead of swallowing them (#51749).
|
||||
expect(call?.logger).toBe(log);
|
||||
expect(log.debug).toHaveBeenCalledWith("graph media fetch empty", {
|
||||
attempts: [
|
||||
{
|
||||
url: "https://graph.microsoft.com/v1.0/chats/c/messages/m",
|
||||
hostedStatus: undefined,
|
||||
attachmentStatus: undefined,
|
||||
hostedCount: undefined,
|
||||
attachmentCount: undefined,
|
||||
tokenError: undefined,
|
||||
},
|
||||
],
|
||||
messageUrl: "https://graph.microsoft.com/v1.0/teams/team-aad-guid/channels/chan/messages/m",
|
||||
hostedStatus: undefined,
|
||||
attachmentStatus: undefined,
|
||||
hostedCount: undefined,
|
||||
attachmentCount: undefined,
|
||||
tokenError: undefined,
|
||||
attachmentIdCount: 1,
|
||||
});
|
||||
});
|
||||
@@ -209,6 +371,7 @@ describe("resolveMSTeamsInboundMedia bot framework DM routing", () => {
|
||||
conversationType: "personal",
|
||||
conversationId: "a:1dRsHCobZ1AxURzY05Dc",
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer/",
|
||||
activity: { id: "msg-1", replyToId: undefined, channelData: {} },
|
||||
};
|
||||
|
||||
it("routes 'a:' conversation IDs through the Bot Framework attachment endpoint", async () => {
|
||||
@@ -245,7 +408,7 @@ describe("resolveMSTeamsInboundMedia bot framework DM routing", () => {
|
||||
expect(mediaList[0].path).toBe("/tmp/report.pdf");
|
||||
});
|
||||
|
||||
it("skips the Graph fallback entirely for 'a:' conversation IDs", async () => {
|
||||
it("skips Graph fallback for an 'a:' conversation without an exact Graph chat ID", async () => {
|
||||
vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]);
|
||||
vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockClear();
|
||||
vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockResolvedValue({
|
||||
@@ -253,7 +416,7 @@ describe("resolveMSTeamsInboundMedia bot framework DM routing", () => {
|
||||
attachmentCount: 1,
|
||||
});
|
||||
vi.mocked(downloadMSTeamsGraphMedia).mockClear();
|
||||
vi.mocked(buildMSTeamsGraphMessageUrls).mockClear();
|
||||
vi.mocked(buildMSTeamsGraphMessageUrl).mockClear();
|
||||
|
||||
await resolveMSTeamsInboundMedia({
|
||||
...dmParams,
|
||||
@@ -266,7 +429,7 @@ describe("resolveMSTeamsInboundMedia bot framework DM routing", () => {
|
||||
});
|
||||
|
||||
expect(downloadMSTeamsBotFrameworkAttachments).toHaveBeenCalled();
|
||||
expect(buildMSTeamsGraphMessageUrls).not.toHaveBeenCalled();
|
||||
expect(buildMSTeamsGraphMessageUrl).not.toHaveBeenCalled();
|
||||
expect(downloadMSTeamsGraphMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -277,8 +440,10 @@ describe("resolveMSTeamsInboundMedia bot framework DM routing", () => {
|
||||
|
||||
await resolveMSTeamsInboundMedia({
|
||||
...baseParams,
|
||||
conversationType: "personal",
|
||||
conversationId: "19:abc@thread.tacv2",
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer/",
|
||||
activity: { id: "msg-1", replyToId: undefined, channelData: {} },
|
||||
attachments: [
|
||||
{
|
||||
contentType: "text/html",
|
||||
@@ -318,7 +483,7 @@ describe("resolveMSTeamsInboundMedia bot framework DM routing", () => {
|
||||
vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]);
|
||||
vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockClear();
|
||||
vi.mocked(downloadMSTeamsGraphMedia).mockClear();
|
||||
vi.mocked(buildMSTeamsGraphMessageUrls).mockClear();
|
||||
vi.mocked(buildMSTeamsGraphMessageUrl).mockClear();
|
||||
const log = { debug: vi.fn() };
|
||||
|
||||
await resolveMSTeamsInboundMedia({
|
||||
@@ -326,6 +491,7 @@ describe("resolveMSTeamsInboundMedia bot framework DM routing", () => {
|
||||
log,
|
||||
conversationType: "personal",
|
||||
conversationId: "a:bf-dm-id",
|
||||
activity: { id: "msg-1", replyToId: undefined, channelData: {} },
|
||||
attachments: [
|
||||
{
|
||||
contentType: "text/html",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Msteams plugin module implements inbound media behavior.
|
||||
import { formatInboundMediaUnavailableText } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import {
|
||||
buildMSTeamsGraphMessageUrls,
|
||||
buildMSTeamsGraphMessageUrl,
|
||||
downloadMSTeamsAttachments,
|
||||
downloadMSTeamsBotFrameworkAttachments,
|
||||
downloadMSTeamsGraphMedia,
|
||||
@@ -12,13 +12,22 @@ import {
|
||||
type MSTeamsHtmlAttachmentSummary,
|
||||
type MSTeamsInboundMedia,
|
||||
} from "../attachments.js";
|
||||
import type { MSTeamsAttachmentDownloadLogger } from "../attachments/shared.js";
|
||||
import type { MSTeamsRequestDeadline } from "../request-timeout.js";
|
||||
import type { MSTeamsTurnContext } from "../sdk-types.js";
|
||||
|
||||
type MSTeamsLogger = {
|
||||
debug?: (message: string, meta?: Record<string, unknown>) => void;
|
||||
warn?: (message: string, meta?: Record<string, unknown>) => void;
|
||||
error?: (message: string, meta?: Record<string, unknown>) => void;
|
||||
};
|
||||
export function shouldAttemptMSTeamsGraphMediaFallback(params: {
|
||||
conversationType: string;
|
||||
htmlSummary?: MSTeamsHtmlAttachmentSummary;
|
||||
graphMediaFallback?: boolean;
|
||||
}): boolean {
|
||||
const conversationType = params.conversationType.trim().toLowerCase();
|
||||
return (
|
||||
params.graphMediaFallback === true &&
|
||||
(conversationType === "channel" || conversationType === "groupchat") &&
|
||||
(params.htmlSummary?.htmlAttachments ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMSTeamsInboundMediaBody(params: {
|
||||
body: string;
|
||||
@@ -52,9 +61,15 @@ export async function resolveMSTeamsInboundMedia(params: {
|
||||
conversationType: string;
|
||||
conversationId: string;
|
||||
conversationMessageId?: string;
|
||||
teamAadGroupId?: string;
|
||||
/** Resolve canonical channel identity only if direct media recovery misses. */
|
||||
resolveTeamAadGroupId?: () => Promise<string | undefined>;
|
||||
serviceUrl?: string;
|
||||
activity: Pick<MSTeamsTurnContext["activity"], "id" | "replyToId" | "channelData">;
|
||||
log: MSTeamsLogger;
|
||||
log: MSTeamsAttachmentDownloadLogger;
|
||||
deadline?: MSTeamsRequestDeadline;
|
||||
/** Opt into Graph lookup when Teams strips file markers from channel/group HTML. */
|
||||
graphMediaFallback?: boolean;
|
||||
/** When true, embeds original filename in stored path for later extraction. */
|
||||
preserveFilenames?: boolean;
|
||||
}): Promise<MSTeamsInboundMedia[]> {
|
||||
@@ -67,6 +82,7 @@ export async function resolveMSTeamsInboundMedia(params: {
|
||||
conversationType,
|
||||
conversationId,
|
||||
conversationMessageId,
|
||||
teamAadGroupId,
|
||||
serviceUrl,
|
||||
activity,
|
||||
log,
|
||||
@@ -80,23 +96,28 @@ export async function resolveMSTeamsInboundMedia(params: {
|
||||
allowHosts,
|
||||
authAllowHosts: params.authAllowHosts,
|
||||
preserveFilenames,
|
||||
deadline: params.deadline,
|
||||
logger: log,
|
||||
});
|
||||
|
||||
if (mediaList.length === 0) {
|
||||
// Gate the Graph/Bot Framework media fallback on the presence of real
|
||||
// `<attachment id="...">` tags inside any `text/html` attachment. Teams
|
||||
// delivers @mention cards and other chrome as `text/html` attachments
|
||||
// too, so keying off contentType alone produces spurious 404 diagnostics
|
||||
// for every mention-only message and masks real file attachments (#58617).
|
||||
// Explicit attachment markers remain the fallback gate for personal chats.
|
||||
// Channel and group-chat activities can omit them while Graph holds a file.
|
||||
const attachmentIds = extractMSTeamsHtmlAttachmentIds(attachments);
|
||||
const hasHtmlFileAttachment = attachmentIds.length > 0;
|
||||
const hasChannelOrGroupHtml = shouldAttemptMSTeamsGraphMediaFallback({
|
||||
conversationType,
|
||||
htmlSummary,
|
||||
graphMediaFallback: params.graphMediaFallback,
|
||||
});
|
||||
const shouldFetchGraphMessage = hasHtmlFileAttachment || hasChannelOrGroupHtml;
|
||||
const isBotFrameworkPersonalChat = isBotFrameworkPersonalChatId(conversationId);
|
||||
|
||||
// Personal DMs with the bot use Bot Framework conversation IDs (`a:...`
|
||||
// or `8:orgid:...`) which Graph's `/chats/{id}` endpoint rejects with
|
||||
// "Invalid ThreadId". Fetch media via the Bot Framework v3 attachments
|
||||
// endpoint instead, which speaks the same identifier space.
|
||||
if (hasHtmlFileAttachment && isBotFrameworkPersonalChatId(conversationId)) {
|
||||
if (hasHtmlFileAttachment && isBotFrameworkPersonalChat) {
|
||||
if (!serviceUrl) {
|
||||
log.debug?.("bot framework attachment skipped (missing serviceUrl)", {
|
||||
conversationType,
|
||||
@@ -111,6 +132,7 @@ export async function resolveMSTeamsInboundMedia(params: {
|
||||
allowHosts,
|
||||
authAllowHosts: params.authAllowHosts,
|
||||
preserveFilenames,
|
||||
deadline: params.deadline,
|
||||
});
|
||||
if (bfMedia.media.length > 0) {
|
||||
mediaList = bfMedia.media;
|
||||
@@ -123,20 +145,20 @@ export async function resolveMSTeamsInboundMedia(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
hasHtmlFileAttachment &&
|
||||
mediaList.length === 0 &&
|
||||
!isBotFrameworkPersonalChatId(conversationId)
|
||||
) {
|
||||
const messageUrls = buildMSTeamsGraphMessageUrls({
|
||||
if (shouldFetchGraphMessage && mediaList.length === 0 && !isBotFrameworkPersonalChat) {
|
||||
const graphTeamAadGroupId =
|
||||
conversationType.trim().toLowerCase() === "channel" && !teamAadGroupId
|
||||
? await params.resolveTeamAadGroupId?.()
|
||||
: teamAadGroupId;
|
||||
const messageUrl = buildMSTeamsGraphMessageUrl({
|
||||
conversationType,
|
||||
conversationId,
|
||||
messageId: activity.id ?? undefined,
|
||||
replyToId: activity.replyToId ?? undefined,
|
||||
conversationMessageId,
|
||||
channelData: activity.channelData,
|
||||
threadRootMessageId: conversationMessageId ?? activity.replyToId,
|
||||
teamAadGroupId: graphTeamAadGroupId,
|
||||
channelId: activity.channelData?.channel?.id,
|
||||
});
|
||||
if (messageUrls.length === 0) {
|
||||
if (!messageUrl) {
|
||||
log.debug?.("graph message url unavailable", {
|
||||
conversationType,
|
||||
hasChannelData: Boolean(activity.channelData),
|
||||
@@ -144,44 +166,27 @@ export async function resolveMSTeamsInboundMedia(params: {
|
||||
replyToId: activity.replyToId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
const attempts: Array<{
|
||||
url: string;
|
||||
hostedStatus?: number;
|
||||
attachmentStatus?: number;
|
||||
hostedCount?: number;
|
||||
attachmentCount?: number;
|
||||
tokenError?: boolean;
|
||||
}> = [];
|
||||
for (const messageUrl of messageUrls) {
|
||||
const graphMedia = await downloadMSTeamsGraphMedia({
|
||||
const graphMedia = await downloadMSTeamsGraphMedia({
|
||||
messageUrl,
|
||||
tokenProvider,
|
||||
maxBytes,
|
||||
allowHosts,
|
||||
authAllowHosts: params.authAllowHosts,
|
||||
preserveFilenames,
|
||||
deadline: params.deadline,
|
||||
logger: log,
|
||||
});
|
||||
if (graphMedia.media.length > 0) {
|
||||
mediaList = graphMedia.media;
|
||||
}
|
||||
if (mediaList.length === 0) {
|
||||
log.debug?.("graph media fetch empty", {
|
||||
messageUrl,
|
||||
tokenProvider,
|
||||
maxBytes,
|
||||
allowHosts,
|
||||
authAllowHosts: params.authAllowHosts,
|
||||
preserveFilenames,
|
||||
log,
|
||||
logger: log,
|
||||
});
|
||||
attempts.push({
|
||||
url: messageUrl,
|
||||
hostedStatus: graphMedia.hostedStatus,
|
||||
attachmentStatus: graphMedia.attachmentStatus,
|
||||
hostedCount: graphMedia.hostedCount,
|
||||
attachmentCount: graphMedia.attachmentCount,
|
||||
tokenError: graphMedia.tokenError,
|
||||
});
|
||||
if (graphMedia.media.length > 0) {
|
||||
mediaList = graphMedia.media;
|
||||
break;
|
||||
}
|
||||
if (graphMedia.tokenError) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (mediaList.length === 0) {
|
||||
log.debug?.("graph media fetch empty", {
|
||||
attempts,
|
||||
attachmentIdCount: attachmentIds.length,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,13 +78,16 @@ vi.mock("../graph-thread.js", () => {
|
||||
return {
|
||||
stripHtmlFromTeamsMessage,
|
||||
formatThreadContext,
|
||||
resolveTeamGroupId: graphThreadMockState.resolveTeamGroupId,
|
||||
fetchChannelMessage: graphThreadMockState.fetchChannelMessage,
|
||||
fetchThreadReplies: graphThreadMockState.fetchThreadReplies,
|
||||
fetchChatMessageText: graphThreadMockState.fetchChatMessageText,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../team-identity.js", () => ({
|
||||
resolveTeamGroupId: graphThreadMockState.resolveTeamGroupId,
|
||||
}));
|
||||
|
||||
describe("msteams monitor handler authz", () => {
|
||||
function createDeps(
|
||||
cfg: OpenClawConfig,
|
||||
@@ -1061,6 +1064,10 @@ describe("msteams monitor handler authz", () => {
|
||||
"token",
|
||||
"19:dm@thread.v2",
|
||||
"message-1",
|
||||
expect.objectContaining({
|
||||
label: "MS Teams inbound preprocessing",
|
||||
timeoutMs: 10_000,
|
||||
}),
|
||||
);
|
||||
expect(recordFromMockCall(firstSettledDispatch().ctxPayload).SupplementalContext).toMatchObject(
|
||||
{
|
||||
|
||||
@@ -1,55 +1,68 @@
|
||||
// Msteams tests cover message handlerm media plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { translateMSTeamsDmConversationIdForGraph } from "../inbound.js";
|
||||
// Msteams tests cover personal-chat media identifier routing.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../runtime-api.js";
|
||||
import type { resolveMSTeamsInboundMedia } from "./inbound-media.js";
|
||||
import "./message-handler-mock-support.test-support.js";
|
||||
|
||||
describe("translateMSTeamsDmConversationIdForGraph", () => {
|
||||
it("translates a: conversation ID to Graph format for DMs", () => {
|
||||
const result = translateMSTeamsDmConversationIdForGraph({
|
||||
isDirectMessage: true,
|
||||
conversationId: "a:1abc2def3",
|
||||
aadObjectId: "user-aad-id",
|
||||
appId: "bot-app-id",
|
||||
});
|
||||
expect(result).toBe("19:user-aad-id_bot-app-id@unq.gbl.spaces");
|
||||
const inboundMediaMockState = vi.hoisted(() => ({
|
||||
resolve: vi.fn<typeof resolveMSTeamsInboundMedia>(),
|
||||
}));
|
||||
|
||||
vi.mock("./inbound-media.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./inbound-media.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveMSTeamsInboundMedia: inboundMediaMockState.resolve,
|
||||
};
|
||||
});
|
||||
|
||||
import { createMSTeamsMessageHandler } from "./message-handler.js";
|
||||
import { buildChannelActivity, createMessageHandlerDeps } from "./message-handler.test-support.js";
|
||||
|
||||
const cfg = {
|
||||
channels: { msteams: { dmPolicy: "open", allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
|
||||
function buildPersonalAttachmentActivity() {
|
||||
return buildChannelActivity({
|
||||
text: "please inspect this file",
|
||||
conversation: { id: "a:bot-framework-dm", conversationType: "personal" },
|
||||
channelData: {},
|
||||
attachments: [
|
||||
{
|
||||
contentType: "text/html",
|
||||
content: '<div><attachment id="attachment-1"></attachment></div>',
|
||||
},
|
||||
],
|
||||
entities: [],
|
||||
});
|
||||
}
|
||||
|
||||
function firstInboundMediaParams(): Record<string, unknown> {
|
||||
const [call] = inboundMediaMockState.resolve.mock.calls;
|
||||
if (!call?.[0] || typeof call[0] !== "object") {
|
||||
throw new Error("expected inbound media parameters");
|
||||
}
|
||||
return call[0] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("msteams personal media identifier routing", () => {
|
||||
beforeEach(() => {
|
||||
inboundMediaMockState.resolve.mockReset();
|
||||
inboundMediaMockState.resolve.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("passes through non-a: conversation IDs unchanged", () => {
|
||||
const result = translateMSTeamsDmConversationIdForGraph({
|
||||
isDirectMessage: true,
|
||||
conversationId: "19:existing@unq.gbl.spaces",
|
||||
aadObjectId: "user-aad-id",
|
||||
appId: "bot-app-id",
|
||||
});
|
||||
expect(result).toBe("19:existing@unq.gbl.spaces");
|
||||
});
|
||||
it("preserves the raw Bot Framework ID for personal attachment recovery", async () => {
|
||||
const { deps } = createMessageHandlerDeps(cfg);
|
||||
const handler = createMSTeamsMessageHandler(deps);
|
||||
|
||||
it("passes through when aadObjectId is missing", () => {
|
||||
const result = translateMSTeamsDmConversationIdForGraph({
|
||||
isDirectMessage: true,
|
||||
conversationId: "a:1abc2def3",
|
||||
aadObjectId: null,
|
||||
appId: "bot-app-id",
|
||||
});
|
||||
expect(result).toBe("a:1abc2def3");
|
||||
});
|
||||
await handler({
|
||||
activity: buildPersonalAttachmentActivity(),
|
||||
sendActivity: vi.fn(async () => undefined),
|
||||
} as unknown as Parameters<typeof handler>[0]);
|
||||
|
||||
it("passes through when appId is missing", () => {
|
||||
const result = translateMSTeamsDmConversationIdForGraph({
|
||||
isDirectMessage: true,
|
||||
conversationId: "a:1abc2def3",
|
||||
aadObjectId: "user-aad-id",
|
||||
appId: null,
|
||||
});
|
||||
expect(result).toBe("a:1abc2def3");
|
||||
});
|
||||
|
||||
it("passes through for non-DM conversations even with a: prefix", () => {
|
||||
const result = translateMSTeamsDmConversationIdForGraph({
|
||||
isDirectMessage: false,
|
||||
conversationId: "a:1abc2def3",
|
||||
aadObjectId: "user-aad-id",
|
||||
appId: "bot-app-id",
|
||||
});
|
||||
expect(result).toBe("a:1abc2def3");
|
||||
const params = firstInboundMediaParams();
|
||||
expect(params).toMatchObject({ conversationId: "a:bot-framework-dm" });
|
||||
expect(params).not.toHaveProperty("graphChatId");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// Msteams tests cover message handler media recovery behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../runtime-api.js";
|
||||
import type { resolveMSTeamsInboundMedia } from "./inbound-media.js";
|
||||
import "./message-handler-mock-support.test-support.js";
|
||||
import { getRuntimeApiMockState } from "./message-handler-mock-support.test-support.js";
|
||||
|
||||
const inboundMediaMockState = vi.hoisted(() => ({
|
||||
resolve: vi.fn<typeof resolveMSTeamsInboundMedia>(),
|
||||
}));
|
||||
|
||||
vi.mock("./inbound-media.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./inbound-media.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveMSTeamsInboundMedia: inboundMediaMockState.resolve,
|
||||
};
|
||||
});
|
||||
|
||||
import { createMSTeamsMessageHandler } from "./message-handler.js";
|
||||
import { buildChannelActivity, createMessageHandlerDeps } from "./message-handler.test-support.js";
|
||||
|
||||
const runtimeApiMockState = getRuntimeApiMockState();
|
||||
const taglessHtmlAttachment = {
|
||||
contentType: "text/html",
|
||||
content: "<div><at>Bot</at></div>",
|
||||
};
|
||||
|
||||
function firstDispatchedContext(): Record<string, unknown> {
|
||||
const call = runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mock.calls[0];
|
||||
const params = call?.[0] as { ctxPayload?: unknown } | undefined;
|
||||
if (!params?.ctxPayload || typeof params.ctxPayload !== "object") {
|
||||
throw new Error("expected dispatched Teams context");
|
||||
}
|
||||
return params.ctxPayload as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("msteams message handler Graph media recovery", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
msteams: { groupPolicy: "open", requireMention: false, graphMediaFallback: true },
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
inboundMediaMockState.resolve.mockReset();
|
||||
runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mockClear();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "channel",
|
||||
conversation: { id: "19:channel@thread.tacv2", conversationType: "channel" },
|
||||
channelData: {
|
||||
team: { id: "19:team@thread.skype" },
|
||||
channel: { id: "19:channel@thread.tacv2" },
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "group chat",
|
||||
conversation: { id: "19:group@thread.v2", conversationType: "groupChat" },
|
||||
channelData: {},
|
||||
},
|
||||
])(
|
||||
"dispatches a tagless $label file with instruction text after Graph recovery",
|
||||
async (entry) => {
|
||||
inboundMediaMockState.resolve.mockResolvedValue([
|
||||
{
|
||||
path: "/tmp/from-graph.pdf",
|
||||
contentType: "application/pdf",
|
||||
placeholder: "<media:document>",
|
||||
},
|
||||
]);
|
||||
const { deps, getTeamDetails } = createMessageHandlerDeps(cfg);
|
||||
const handler = createMSTeamsMessageHandler(deps);
|
||||
|
||||
await handler({
|
||||
activity: buildChannelActivity({
|
||||
text: "<at>Bot</at> Describe the attached image file",
|
||||
conversation: entry.conversation,
|
||||
channelData: entry.channelData,
|
||||
attachments: [taglessHtmlAttachment],
|
||||
}),
|
||||
getTeamDetails,
|
||||
sendActivity: vi.fn(async () => undefined),
|
||||
} as unknown as Parameters<typeof handler>[0]);
|
||||
|
||||
expect(inboundMediaMockState.resolve).toHaveBeenCalledTimes(1);
|
||||
expect(getTeamDetails).toHaveBeenCalledTimes(entry.label === "channel" ? 1 : 0);
|
||||
expect(inboundMediaMockState.resolve).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
graphMediaFallback: true,
|
||||
teamAadGroupId: undefined,
|
||||
resolveTeamAadGroupId: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
expect(firstDispatchedContext()).toMatchObject({
|
||||
BodyForAgent: "Describe the attached image file",
|
||||
MediaPaths: ["/tmp/from-graph.pdf"],
|
||||
NativeChannelId:
|
||||
entry.label === "channel" ? "team-aad-group/19:channel@thread.tacv2" : undefined,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps explicit attachment markers working without the opt-in fallback", async () => {
|
||||
inboundMediaMockState.resolve.mockResolvedValue([
|
||||
{
|
||||
path: "/tmp/explicit.pdf",
|
||||
contentType: "application/pdf",
|
||||
placeholder: "<media:document>",
|
||||
},
|
||||
]);
|
||||
const defaultCfg = {
|
||||
channels: { msteams: { groupPolicy: "open", requireMention: false } },
|
||||
} as OpenClawConfig;
|
||||
const { deps, getTeamDetails } = createMessageHandlerDeps(defaultCfg);
|
||||
const handler = createMSTeamsMessageHandler(deps);
|
||||
|
||||
await handler({
|
||||
activity: buildChannelActivity({
|
||||
text: "<at>Bot</at>",
|
||||
channelData: {
|
||||
team: { id: "19:team@thread.skype", aadGroupId: "team-aad" },
|
||||
channel: { id: "19:channel@thread.tacv2" },
|
||||
},
|
||||
attachments: [
|
||||
{
|
||||
contentType: "text/html",
|
||||
content: '<div><attachment id="file-1"></attachment></div>',
|
||||
},
|
||||
],
|
||||
}),
|
||||
getTeamDetails,
|
||||
sendActivity: vi.fn(async () => undefined),
|
||||
} as unknown as Parameters<typeof handler>[0]);
|
||||
|
||||
expect(inboundMediaMockState.resolve).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ graphMediaFallback: undefined }),
|
||||
);
|
||||
expect(firstDispatchedContext()).toMatchObject({
|
||||
BodyForAgent: "<media:document>",
|
||||
MediaPaths: ["/tmp/explicit.pdf"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not dispatch or enqueue a ghost event when Graph recovery is empty", async () => {
|
||||
inboundMediaMockState.resolve.mockResolvedValue([]);
|
||||
const { deps, enqueueSystemEvent, getTeamDetails } = createMessageHandlerDeps(cfg);
|
||||
const handler = createMSTeamsMessageHandler(deps);
|
||||
|
||||
await handler({
|
||||
activity: buildChannelActivity({
|
||||
text: "<at>Bot</at>",
|
||||
channelData: {
|
||||
team: { id: "19:team@thread.skype", aadGroupId: "team-aad" },
|
||||
channel: { id: "19:channel@thread.tacv2" },
|
||||
},
|
||||
attachments: [taglessHtmlAttachment],
|
||||
}),
|
||||
getTeamDetails,
|
||||
sendActivity: vi.fn(async () => undefined),
|
||||
} as unknown as Parameters<typeof handler>[0]);
|
||||
|
||||
expect(inboundMediaMockState.resolve).toHaveBeenCalledTimes(1);
|
||||
expect(getTeamDetails).not.toHaveBeenCalled();
|
||||
expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).not.toHaveBeenCalled();
|
||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps ordinary text when the Teams API cannot resolve the channel AAD group ID", async () => {
|
||||
inboundMediaMockState.resolve.mockResolvedValue([]);
|
||||
const getTeamDetails = vi.fn(async () => {
|
||||
throw new Error("Teams API unavailable");
|
||||
});
|
||||
const { deps } = createMessageHandlerDeps(cfg, { getTeamDetails });
|
||||
const handler = createMSTeamsMessageHandler(deps);
|
||||
|
||||
await handler({
|
||||
activity: buildChannelActivity({
|
||||
text: "<at>Bot</at> keep this text",
|
||||
channelData: {
|
||||
team: { id: "19:team-unresolved@thread.skype" },
|
||||
channel: { id: "19:channel@thread.tacv2" },
|
||||
},
|
||||
attachments: [taglessHtmlAttachment],
|
||||
}),
|
||||
getTeamDetails,
|
||||
sendActivity: vi.fn(async () => undefined),
|
||||
} as unknown as Parameters<typeof handler>[0]);
|
||||
|
||||
expect(getTeamDetails).toHaveBeenCalledWith("19:team-unresolved@thread.skype");
|
||||
expect(inboundMediaMockState.resolve).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ teamAadGroupId: undefined }),
|
||||
);
|
||||
expect(firstDispatchedContext()).toMatchObject({
|
||||
BodyForAgent: "keep this text",
|
||||
NativeChannelId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the canonical AAD group ID for ordinary channel action context", async () => {
|
||||
inboundMediaMockState.resolve.mockResolvedValue([]);
|
||||
const { deps, getTeamDetails } = createMessageHandlerDeps(cfg);
|
||||
const handler = createMSTeamsMessageHandler(deps);
|
||||
|
||||
await handler({
|
||||
activity: buildChannelActivity({
|
||||
text: "ordinary channel message",
|
||||
channelData: {
|
||||
team: { id: "19:raw-team@thread.skype" },
|
||||
channel: { id: "19:channel@thread.tacv2" },
|
||||
},
|
||||
attachments: [],
|
||||
}),
|
||||
getTeamDetails,
|
||||
sendActivity: vi.fn(async () => undefined),
|
||||
} as unknown as Parameters<typeof handler>[0]);
|
||||
|
||||
expect(getTeamDetails).toHaveBeenCalledWith("19:raw-team@thread.skype");
|
||||
expect(firstDispatchedContext()).toMatchObject({
|
||||
NativeChannelId: "team-aad-group/19:general@thread.tacv2",
|
||||
});
|
||||
expect(JSON.stringify(firstDispatchedContext())).not.toContain("19:raw-team@thread.skype/");
|
||||
});
|
||||
|
||||
it("does not create a ghost event for unmentioned empty HTML", async () => {
|
||||
const mentionCfg = {
|
||||
channels: { msteams: { groupPolicy: "open", requireMention: true } },
|
||||
} as OpenClawConfig;
|
||||
inboundMediaMockState.resolve.mockResolvedValue([]);
|
||||
const { deps, enqueueSystemEvent, getTeamDetails } = createMessageHandlerDeps(mentionCfg);
|
||||
const handler = createMSTeamsMessageHandler(deps);
|
||||
|
||||
await handler({
|
||||
activity: buildChannelActivity({
|
||||
text: "",
|
||||
entities: [],
|
||||
attachments: [{ contentType: "text/html", content: "<div></div>" }],
|
||||
}),
|
||||
getTeamDetails,
|
||||
sendActivity: vi.fn(async () => undefined),
|
||||
} as unknown as Parameters<typeof handler>[0]);
|
||||
|
||||
expect(getTeamDetails).not.toHaveBeenCalled();
|
||||
expect(inboundMediaMockState.resolve).not.toHaveBeenCalled();
|
||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ type MessageHandlerDepsOptions = {
|
||||
shouldHandleTextCommands?: PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"];
|
||||
createInboundDebouncer?: PluginRuntime["channel"]["debounce"]["createInboundDebouncer"];
|
||||
resolveInboundDebounceMs?: PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"];
|
||||
getTeamDetails?: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
export function createMessageHandlerDeps(
|
||||
@@ -39,6 +40,8 @@ export function createMessageHandlerDeps(
|
||||
lastRoutePolicy: "session" as const,
|
||||
matchedBy: "default" as const,
|
||||
}));
|
||||
const getTeamDetails =
|
||||
options.getTeamDetails ?? vi.fn(async () => ({ aadGroupId: "team-aad-group" }));
|
||||
|
||||
installMSTeamsTestRuntime({
|
||||
enqueueSystemEvent,
|
||||
@@ -57,7 +60,7 @@ export function createMessageHandlerDeps(
|
||||
});
|
||||
|
||||
const conversationStore = {
|
||||
get: vi.fn(async () => null),
|
||||
get: vi.fn<MSTeamsMessageHandlerDeps["conversationStore"]["get"]>(async () => null),
|
||||
upsert: vi.fn(async () => undefined),
|
||||
list: vi.fn(async () => []),
|
||||
remove: vi.fn(async () => false),
|
||||
@@ -94,6 +97,7 @@ export function createMessageHandlerDeps(
|
||||
upsertPairingRequest,
|
||||
recordInboundSession,
|
||||
resolveAgentRoute,
|
||||
getTeamDetails,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ const runtimeApiMockState = getRuntimeApiMockState();
|
||||
const fetchChannelMessageMock = vi.hoisted(() => vi.fn());
|
||||
const fetchThreadRepliesMock = vi.hoisted(() => vi.fn(async () => []));
|
||||
const fetchChatMessageTextMock = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const resolveTeamGroupIdMock = vi.hoisted(() => vi.fn(async () => "group-1"));
|
||||
const resolveTeamGroupIdMock = vi.hoisted(() =>
|
||||
vi.fn<() => Promise<string | undefined>>(async () => "group-1"),
|
||||
);
|
||||
|
||||
vi.mock("../graph-thread.js", () => {
|
||||
const stripHtmlFromTeamsMessage = (html: string) =>
|
||||
@@ -32,13 +34,16 @@ vi.mock("../graph-thread.js", () => {
|
||||
.trim();
|
||||
return {
|
||||
stripHtmlFromTeamsMessage,
|
||||
resolveTeamGroupId: resolveTeamGroupIdMock,
|
||||
fetchChannelMessage: fetchChannelMessageMock,
|
||||
fetchThreadReplies: fetchThreadRepliesMock,
|
||||
fetchChatMessageText: fetchChatMessageTextMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../team-identity.js", () => ({
|
||||
resolveTeamGroupId: resolveTeamGroupIdMock,
|
||||
}));
|
||||
|
||||
describe("msteams thread parent context injection", () => {
|
||||
type MessageHandler = ReturnType<typeof createMSTeamsMessageHandler>;
|
||||
type ParentSystemEventCall = [
|
||||
@@ -104,6 +109,21 @@ describe("msteams thread parent context injection", () => {
|
||||
expect(parentCall[1]?.contextKey).toContain("msteams:thread-parent:");
|
||||
expect(parentCall[1]?.contextKey).toContain("thread-root-123");
|
||||
expect(parentCall[1]).toMatchObject({});
|
||||
expect(fetchChannelMessageMock).toHaveBeenCalledWith(
|
||||
"token",
|
||||
"group-1",
|
||||
channelConversationId,
|
||||
"thread-root-123",
|
||||
expect.objectContaining({ label: "MS Teams inbound preprocessing" }),
|
||||
);
|
||||
expect(fetchThreadRepliesMock).toHaveBeenCalledWith(
|
||||
"token",
|
||||
"group-1",
|
||||
channelConversationId,
|
||||
"thread-root-123",
|
||||
50,
|
||||
expect.objectContaining({ label: "MS Teams inbound preprocessing" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("caches parent fetches across thread replies in the same session", async () => {
|
||||
@@ -190,6 +210,20 @@ describe("msteams thread parent context injection", () => {
|
||||
expect(enqueueSystemEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send the raw Bot Framework team ID to thread Graph paths", async () => {
|
||||
resolveTeamGroupIdMock.mockResolvedValueOnce(undefined);
|
||||
const { deps } = createMessageHandlerDeps(cfg);
|
||||
const handler = createMSTeamsMessageHandler(deps);
|
||||
|
||||
await dispatchThreadReply(handler, "msg-reply-no-aad-group");
|
||||
|
||||
expect(fetchChannelMessageMock).not.toHaveBeenCalled();
|
||||
expect(fetchThreadRepliesMock).not.toHaveBeenCalled();
|
||||
expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fetch parent for DM replyToId", async () => {
|
||||
fetchChannelMessageMock.mockResolvedValue({
|
||||
id: "x",
|
||||
|
||||
@@ -33,22 +33,21 @@ import { tryNormalizeBotFrameworkServiceUrl } from "../bot-framework-service-url
|
||||
import type { StoredConversationReference } from "../conversation-store.js";
|
||||
import { formatUnknownError } from "../errors.js";
|
||||
import {
|
||||
fetchChannelMessage,
|
||||
fetchChatMessageText,
|
||||
fetchThreadReplies,
|
||||
formatThreadContext,
|
||||
resolveTeamGroupId,
|
||||
type GraphThreadMessage,
|
||||
} from "../graph-thread.js";
|
||||
import { resolveGraphChatId } from "../graph-upload.js";
|
||||
import {
|
||||
extractMSTeamsConversationMessageId,
|
||||
extractMSTeamsQuoteInfo,
|
||||
normalizeMSTeamsConversationId,
|
||||
parseMSTeamsActivityTimestamp,
|
||||
stripMSTeamsMentionTags,
|
||||
translateMSTeamsDmConversationIdForGraph,
|
||||
wasMSTeamsBotMentioned,
|
||||
} from "../inbound.js";
|
||||
import { createMSTeamsInboundDeadline, withMSTeamsRequestDeadline } from "../request-timeout.js";
|
||||
import {
|
||||
fetchParentMessageCached,
|
||||
formatParentContextEvent,
|
||||
@@ -90,8 +89,13 @@ import {
|
||||
recordMSTeamsSentMessage,
|
||||
wasMSTeamsMessageSentWithPersistence,
|
||||
} from "../sent-message-cache.js";
|
||||
import { resolveTeamGroupId } from "../team-identity.js";
|
||||
import { resolveMSTeamsSenderAccess } from "./access.js";
|
||||
import { resolveMSTeamsInboundMedia, resolveMSTeamsInboundMediaBody } from "./inbound-media.js";
|
||||
import {
|
||||
resolveMSTeamsInboundMedia,
|
||||
resolveMSTeamsInboundMediaBody,
|
||||
shouldAttemptMSTeamsGraphMediaFallback,
|
||||
} from "./inbound-media.js";
|
||||
import { resolveMSTeamsRouteSessionKey } from "./thread-session.js";
|
||||
|
||||
function formatMSTeamsSenderReason(params: {
|
||||
@@ -461,7 +465,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rawBody) {
|
||||
const mayRecoverGraphMedia =
|
||||
Boolean(htmlSummary?.attachmentIds.length) ||
|
||||
shouldAttemptMSTeamsGraphMediaFallback({
|
||||
conversationType,
|
||||
htmlSummary: htmlSummary ?? undefined,
|
||||
graphMediaFallback: msteamsCfg?.graphMediaFallback,
|
||||
});
|
||||
if (!rawBody && !mayRecoverGraphMedia) {
|
||||
log.debug?.("skipping empty message after stripping mentions");
|
||||
return;
|
||||
}
|
||||
@@ -539,110 +550,124 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
requireMention,
|
||||
mentioned,
|
||||
});
|
||||
enqueuePrimaryMessageSystemEvent();
|
||||
createChannelHistoryWindow({ historyMap: conversationHistories }).record({
|
||||
historyKey: conversationId,
|
||||
limit: historyLimit,
|
||||
entry: {
|
||||
sender: senderName,
|
||||
body: rawBody,
|
||||
timestamp: timestamp?.getTime(),
|
||||
messageId: activity.id ?? undefined,
|
||||
},
|
||||
});
|
||||
if (rawBody) {
|
||||
enqueuePrimaryMessageSystemEvent();
|
||||
createChannelHistoryWindow({ historyMap: conversationHistories }).record({
|
||||
historyKey: conversationId,
|
||||
limit: historyLimit,
|
||||
entry: {
|
||||
sender: senderName,
|
||||
body: rawBody,
|
||||
timestamp: timestamp?.getTime(),
|
||||
messageId: activity.id ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
enqueuePrimaryMessageSystemEvent();
|
||||
let graphConversationId = translateMSTeamsDmConversationIdForGraph({
|
||||
isDirectMessage,
|
||||
conversationId,
|
||||
aadObjectId: from.aadObjectId,
|
||||
appId,
|
||||
});
|
||||
|
||||
// For personal DMs the Bot Framework conversation ID (`a:...`) and the
|
||||
// synthetic `19:{userId}_{appId}@unq.gbl.spaces` format produced by
|
||||
// translateMSTeamsDmConversationIdForGraph are not always accepted by the
|
||||
// Graph `/chats/{chatId}/messages` endpoint. Resolve the real Graph chat
|
||||
// ID via the API (with conversation store caching) so the Graph media
|
||||
// download fallback works when the direct Bot Framework download fails.
|
||||
if (isDirectMessage && conversationId.startsWith("a:")) {
|
||||
const cached = await conversationStore.get(conversationId);
|
||||
if (cached?.graphChatId) {
|
||||
graphConversationId = cached.graphChatId;
|
||||
} else {
|
||||
try {
|
||||
const resolved = await resolveGraphChatId({
|
||||
botFrameworkConversationId: conversationId,
|
||||
userAadObjectId: from.aadObjectId ?? undefined,
|
||||
tokenProvider,
|
||||
});
|
||||
if (resolved) {
|
||||
graphConversationId = resolved;
|
||||
conversationStore
|
||||
.upsert(conversationId, { ...conversationRef, graphChatId: resolved })
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
log.debug?.("failed to resolve Graph chat ID for inbound media", { conversationId });
|
||||
}
|
||||
const preprocessingDeadline = createMSTeamsInboundDeadline();
|
||||
let teamAadGroupId = activity.channelData?.team?.aadGroupId?.trim() || undefined;
|
||||
const conversationTeamId = isChannel ? teamId : undefined;
|
||||
let teamGroupIdPromise: Promise<string | undefined> | undefined;
|
||||
const resolveChannelTeamGroupId = async (): Promise<string | undefined> => {
|
||||
if (!conversationTeamId) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// The inbound Teams blockquote only carries a truncated `preview` snippet for
|
||||
// quote replies. When we have the quoted message id, fetch the complete text
|
||||
// via the app-only `GET /chats/{chatId}/messages/{id}` endpoint (allowed with
|
||||
// Chat.Read.All). Restricted to 1:1 DMs on purpose: in a group chat an
|
||||
// allowlisted sender could quote a non-allowlisted member, and the fetched
|
||||
// full body would bypass the supplemental-quote visibility allowlist applied
|
||||
// below. DMs have only two participants, so there is no third-party exposure.
|
||||
// Group/channel quotes keep the (now-surfaced) truncated preview from fix 1.
|
||||
// Any failure degrades to that preview, so message handling never breaks.
|
||||
let quoteBodyFull: string | undefined;
|
||||
if (quoteInfo?.id && isDirectMessage && graphConversationId.startsWith("19:")) {
|
||||
try {
|
||||
const graphToken = await tokenProvider.getAccessToken("https://graph.microsoft.com");
|
||||
quoteBodyFull = await fetchChatMessageText(graphToken, graphConversationId, quoteInfo.id);
|
||||
} catch (err) {
|
||||
log.debug?.("failed to fetch full quoted message text", {
|
||||
teamGroupIdPromise ??= resolveTeamGroupId({
|
||||
conversationTeamId,
|
||||
aadGroupId: teamAadGroupId,
|
||||
getTeamDetails: context.getTeamDetails,
|
||||
deadline: preprocessingDeadline,
|
||||
}).catch((err: unknown) => {
|
||||
log.debug?.("failed to resolve Teams AAD group ID", {
|
||||
teamId: conversationTeamId,
|
||||
error: formatUnknownError(err),
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
teamAadGroupId = await teamGroupIdPromise;
|
||||
return teamAadGroupId;
|
||||
};
|
||||
let mediaList = [] as Awaited<ReturnType<typeof resolveMSTeamsInboundMedia>>;
|
||||
try {
|
||||
mediaList = await withMSTeamsRequestDeadline({
|
||||
deadline: preprocessingDeadline,
|
||||
label: "MS Teams inbound media",
|
||||
work: () =>
|
||||
resolveMSTeamsInboundMedia({
|
||||
attachments,
|
||||
htmlSummary: htmlSummary ?? undefined,
|
||||
maxBytes: mediaMaxBytes,
|
||||
tokenProvider,
|
||||
allowHosts: msteamsCfg?.mediaAllowHosts,
|
||||
authAllowHosts: msteamsCfg?.mediaAuthAllowHosts,
|
||||
graphMediaFallback: msteamsCfg?.graphMediaFallback,
|
||||
conversationType,
|
||||
conversationId,
|
||||
conversationMessageId: conversationMessageId ?? undefined,
|
||||
teamAadGroupId,
|
||||
resolveTeamAadGroupId: resolveChannelTeamGroupId,
|
||||
serviceUrl: activity.serviceUrl,
|
||||
activity: {
|
||||
id: activity.id,
|
||||
replyToId: activity.replyToId,
|
||||
channelData: activity.channelData,
|
||||
},
|
||||
log,
|
||||
deadline: preprocessingDeadline,
|
||||
preserveFilenames: (cfg as { media?: { preserveFilenames?: boolean } }).media
|
||||
?.preserveFilenames,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
log.debug?.("failed to resolve inbound Teams media", {
|
||||
error: formatUnknownError(err),
|
||||
});
|
||||
}
|
||||
|
||||
const mediaList = await resolveMSTeamsInboundMedia({
|
||||
attachments,
|
||||
htmlSummary: htmlSummary ?? undefined,
|
||||
maxBytes: mediaMaxBytes,
|
||||
tokenProvider,
|
||||
allowHosts: msteamsCfg?.mediaAllowHosts,
|
||||
authAllowHosts: msteamsCfg?.mediaAuthAllowHosts,
|
||||
conversationType,
|
||||
conversationId: graphConversationId,
|
||||
conversationMessageId: conversationMessageId ?? undefined,
|
||||
serviceUrl: activity.serviceUrl,
|
||||
activity: {
|
||||
id: activity.id,
|
||||
replyToId: activity.replyToId,
|
||||
channelData: activity.channelData,
|
||||
},
|
||||
log,
|
||||
preserveFilenames: (cfg as { media?: { preserveFilenames?: boolean } }).media
|
||||
?.preserveFilenames,
|
||||
});
|
||||
|
||||
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
||||
const materializedMediaPlaceholder = resolveMSTeamsInboundAttachmentPresentation(
|
||||
mediaList.map((media) => ({ contentType: media.contentType, name: media.path })),
|
||||
).placeholder;
|
||||
const agentBody = resolveMSTeamsInboundMediaBody({
|
||||
body: rawBody,
|
||||
body: rawBody || materializedMediaPlaceholder,
|
||||
mediaPlaceholder: attachmentPlaceholder,
|
||||
materializedMediaPlaceholder,
|
||||
expectedMediaCount: attachmentPresentation.expectedMediaCount,
|
||||
mediaCount: mediaList.length,
|
||||
});
|
||||
if (!agentBody) {
|
||||
log.debug?.("skipping empty message after Graph media recovery");
|
||||
return;
|
||||
}
|
||||
enqueuePrimaryMessageSystemEvent();
|
||||
teamAadGroupId = await resolveChannelTeamGroupId();
|
||||
|
||||
// Media is the primary payload, so optional quote enrichment only gets the
|
||||
// remaining preprocessing budget. DMs alone may fetch the full quote: group
|
||||
// and channel quotes retain their visibility-filtered preview.
|
||||
let quoteBodyFull: string | undefined;
|
||||
const quoteMessageId = quoteInfo?.id;
|
||||
if (quoteMessageId && isDirectMessage && conversationId.startsWith("19:")) {
|
||||
try {
|
||||
const graphToken = await withMSTeamsRequestDeadline({
|
||||
deadline: preprocessingDeadline,
|
||||
label: "MS Teams quote token",
|
||||
work: () => tokenProvider.getAccessToken("https://graph.microsoft.com"),
|
||||
});
|
||||
quoteBodyFull = await withMSTeamsRequestDeadline({
|
||||
deadline: preprocessingDeadline,
|
||||
label: "MS Teams quote lookup",
|
||||
work: () =>
|
||||
fetchChatMessageText(graphToken, conversationId, quoteMessageId, preprocessingDeadline),
|
||||
});
|
||||
} catch (err) {
|
||||
log.debug?.("failed to fetch full quoted message text", {
|
||||
error: formatUnknownError(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch thread history when the message is a reply inside a Teams channel thread.
|
||||
// This is a best-effort enhancement; errors are logged and do not block the reply.
|
||||
@@ -653,16 +678,46 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
// Parent fetches are cached (5 min LRU, 100 entries) and per-session deduped so
|
||||
// consecutive replies in the same thread do not re-inject identical context.
|
||||
let threadContext: string | undefined;
|
||||
if (activity.replyToId && isChannel && teamId) {
|
||||
const threadParentId = activity.replyToId;
|
||||
const channelGroupId = teamAadGroupId;
|
||||
if (threadParentId && isChannel && channelGroupId) {
|
||||
try {
|
||||
const graphToken = await tokenProvider.getAccessToken("https://graph.microsoft.com");
|
||||
const groupId = await resolveTeamGroupId(graphToken, teamId);
|
||||
const graphToken = await withMSTeamsRequestDeadline({
|
||||
deadline: preprocessingDeadline,
|
||||
label: "MS Teams thread token",
|
||||
work: () => tokenProvider.getAccessToken("https://graph.microsoft.com"),
|
||||
});
|
||||
// Use allSettled so a failure in one fetch does not discard the other.
|
||||
// For example, reply-fetch 403 should not throw away a successful parent fetch.
|
||||
const [parentResult, repliesResult] = await Promise.allSettled([
|
||||
fetchParentMessageCached(graphToken, groupId, conversationId, activity.replyToId),
|
||||
fetchThreadReplies(graphToken, groupId, conversationId, activity.replyToId),
|
||||
]);
|
||||
const [parentResult, repliesResult] = await withMSTeamsRequestDeadline({
|
||||
deadline: preprocessingDeadline,
|
||||
label: "MS Teams thread history",
|
||||
work: () =>
|
||||
Promise.allSettled([
|
||||
fetchParentMessageCached(
|
||||
graphToken,
|
||||
channelGroupId,
|
||||
conversationId,
|
||||
threadParentId,
|
||||
(token, groupId, graphChannelId, messageId) =>
|
||||
fetchChannelMessage(
|
||||
token,
|
||||
groupId,
|
||||
graphChannelId,
|
||||
messageId,
|
||||
preprocessingDeadline,
|
||||
),
|
||||
),
|
||||
fetchThreadReplies(
|
||||
graphToken,
|
||||
channelGroupId,
|
||||
conversationId,
|
||||
threadParentId,
|
||||
50,
|
||||
preprocessingDeadline,
|
||||
),
|
||||
]),
|
||||
});
|
||||
const parentMsg = parentResult.status === "fulfilled" ? parentResult.value : undefined;
|
||||
const replies = repliesResult.status === "fulfilled" ? repliesResult.value : [];
|
||||
if (parentResult.status === "rejected") {
|
||||
@@ -696,13 +751,13 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
if (
|
||||
parentSummary &&
|
||||
visibleParentMessages.length > 0 &&
|
||||
shouldInjectParentContext(route.sessionKey, activity.replyToId)
|
||||
shouldInjectParentContext(route.sessionKey, threadParentId)
|
||||
) {
|
||||
core.system.enqueueSystemEvent(formatParentContextEvent(parentSummary), {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `msteams:thread-parent:${conversationId}:${activity.replyToId}`,
|
||||
contextKey: `msteams:thread-parent:${conversationId}:${threadParentId}`,
|
||||
});
|
||||
markParentContextInjected(route.sessionKey, activity.replyToId);
|
||||
markParentContextInjected(route.sessionKey, threadParentId);
|
||||
}
|
||||
const allMessages = parentMsg ? [parentMsg, ...replies] : replies;
|
||||
quoteSenderId = parentMsg?.from?.user?.id ?? parentMsg?.from?.application?.id ?? undefined;
|
||||
@@ -786,11 +841,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
: agentBody;
|
||||
|
||||
// For Teams *channel* messages (not group chats / DMs), preserve the
|
||||
// `teamId/channelId` pair on NativeChannelId so downstream action handlers
|
||||
// can route through `/teams/{teamId}/channels/{channelId}` via Graph API.
|
||||
// `aadGroupId/channelId` pair on NativeChannelId so downstream action handlers
|
||||
// can route through `/teams/{aadGroupId}/channels/{channelId}` via Graph API.
|
||||
// The bare conversation id (`19:...@thread.tacv2`) is insufficient on its
|
||||
// own because channel Graph endpoints require the owning team id too.
|
||||
const nativeChannelId = isChannel && teamId ? `${teamId}/${conversationId}` : undefined;
|
||||
const nativeChannelId =
|
||||
isChannel && teamAadGroupId ? `${teamAadGroupId}/${conversationId}` : undefined;
|
||||
const ctxPayload = buildChannelInboundEventContext({
|
||||
channel: "msteams",
|
||||
finalize: core.channel.reply.finalizeInboundContext,
|
||||
|
||||
@@ -681,8 +681,20 @@ describe("monitorMSTeamsProvider lifecycle", () => {
|
||||
throw new Error("expected registered Teams handler");
|
||||
}
|
||||
const run = vi.spyOn(registeredHandler, "run");
|
||||
await activityHandler({ activity });
|
||||
const getTeamDetails = vi.fn(async () => ({ aadGroupId: "activity-aad-group" }));
|
||||
await activityHandler({
|
||||
activity,
|
||||
api: { teams: { getById: getTeamDetails } },
|
||||
send: vi.fn(async () => undefined),
|
||||
});
|
||||
expect(run).toHaveBeenCalledWith(expect.objectContaining({ activity }));
|
||||
const adaptedContext = run.mock.calls[0]?.[0] as
|
||||
| { getTeamDetails?: (teamId: string) => Promise<{ aadGroupId?: string }> }
|
||||
| undefined;
|
||||
await expect(adaptedContext?.getTeamDetails?.("activity-team-id")).resolves.toEqual({
|
||||
aadGroupId: "activity-aad-group",
|
||||
});
|
||||
expect(getTeamDetails).toHaveBeenCalledWith("activity-team-id");
|
||||
|
||||
abort.abort();
|
||||
await task;
|
||||
|
||||
@@ -752,7 +752,11 @@ function adaptSdkContext(ctx: unknown, app: MSTeamsApp): MSTeamsTurnContext {
|
||||
return ctx as MSTeamsTurnContext;
|
||||
}
|
||||
const conversationId = sdkCtx.activity?.conversation?.id ?? "";
|
||||
const activityApi = sdkCtx.api ?? app.api;
|
||||
const inboundApi = sdkCtx.api;
|
||||
const activityApi = inboundApi ?? app.api;
|
||||
const getTeamDetails = inboundApi
|
||||
? (teamId: string) => inboundApi.teams.getById(teamId)
|
||||
: undefined;
|
||||
const conversationType = (sdkCtx.activity?.conversation?.conversationType ?? "").toLowerCase();
|
||||
const isThreadable = conversationType === "channel" || conversationType === "groupchat";
|
||||
// For Teams channels and group chats, use ctx.reply() so the SDK threads the
|
||||
@@ -779,6 +783,7 @@ function adaptSdkContext(ctx: unknown, app: MSTeamsApp): MSTeamsTurnContext {
|
||||
deleteActivity: async (activityId: string) => {
|
||||
return activityApi.conversations.activities(conversationId).delete(activityId);
|
||||
},
|
||||
getTeamDetails,
|
||||
stream: sdkCtx.stream,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Msteams tests cover shared inbound request deadlines.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withMSTeamsRequestDeadline } from "./request-timeout.js";
|
||||
|
||||
describe("withMSTeamsRequestDeadline", () => {
|
||||
it("does not start work after the operation deadline has expired", async () => {
|
||||
const work = vi.fn(async () => "late");
|
||||
|
||||
await expect(
|
||||
withMSTeamsRequestDeadline({
|
||||
deadline: {
|
||||
label: "MS Teams inbound preprocessing",
|
||||
timeoutMs: 10,
|
||||
deadlineAtMs: Date.now() - 1,
|
||||
},
|
||||
label: "late Teams lookup",
|
||||
work,
|
||||
}),
|
||||
).rejects.toThrow(/timed out/i);
|
||||
|
||||
expect(work).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// Msteams plugin module implements request deadline behavior.
|
||||
import {
|
||||
createProviderOperationDeadline,
|
||||
resolveProviderOperationTimeoutMs,
|
||||
type ProviderOperationDeadline,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { withTimeout } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
|
||||
export const MSTEAMS_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
// Cap optional enrichment before agent dispatch. The Teams SDK still holds the
|
||||
// webhook open for the agent turn, so this budget alone cannot prevent retries.
|
||||
export const MSTEAMS_INBOUND_PREPROCESS_TIMEOUT_MS = 10_000;
|
||||
|
||||
export type MSTeamsRequestDeadline = ProviderOperationDeadline;
|
||||
|
||||
export function createMSTeamsInboundDeadline(): MSTeamsRequestDeadline {
|
||||
return createProviderOperationDeadline({
|
||||
label: "MS Teams inbound preprocessing",
|
||||
timeoutMs: MSTEAMS_INBOUND_PREPROCESS_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveMSTeamsRequestTimeoutMs(deadline?: MSTeamsRequestDeadline): number {
|
||||
return deadline
|
||||
? resolveProviderOperationTimeoutMs({
|
||||
deadline,
|
||||
defaultTimeoutMs: MSTEAMS_REQUEST_TIMEOUT_MS,
|
||||
})
|
||||
: MSTEAMS_REQUEST_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
/** Bound non-abortable SDK and credential work to the same operation deadline as fetches. */
|
||||
export async function withMSTeamsRequestDeadline<T>(params: {
|
||||
deadline?: MSTeamsRequestDeadline;
|
||||
label: string;
|
||||
work: () => Promise<T>;
|
||||
}): Promise<T> {
|
||||
const timeoutMs = resolveMSTeamsRequestTimeoutMs(params.deadline);
|
||||
return await withTimeout(params.work(), timeoutMs, params.label);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ type MSTeamsActivity = {
|
||||
locale?: string;
|
||||
serviceUrl?: string;
|
||||
channelData?: {
|
||||
team?: { id?: string; name?: string };
|
||||
team?: { id?: string; aadGroupId?: string; name?: string };
|
||||
channel?: { id?: string; name?: string };
|
||||
tenant?: { id?: string };
|
||||
[key: string]: unknown;
|
||||
@@ -66,5 +66,7 @@ export type MSTeamsTurnContext = {
|
||||
sendActivities: (activities: Array<MSTeamsActivityParams>) => Promise<unknown>;
|
||||
updateActivity: (activity: MSTeamsActivityParams) => Promise<{ id?: string } | void>;
|
||||
deleteActivity: (activityId: string) => Promise<void>;
|
||||
/** Resolve Bot Framework team metadata through this activity's regional service URL. */
|
||||
getTeamDetails?: (teamId: string) => Promise<{ aadGroupId?: string }>;
|
||||
stream?: MSTeamsStreamer;
|
||||
};
|
||||
|
||||
@@ -145,6 +145,21 @@ describe("createMSTeamsApp", () => {
|
||||
expect(headers?.["User-Agent"]).toMatch(/^teams\.ts\[apps\]\/\S+ OpenClaw\/\S+$/);
|
||||
});
|
||||
|
||||
it("bounds Teams SDK API requests", async () => {
|
||||
const creds: MSTeamsCredentials = {
|
||||
type: "secret",
|
||||
appId: "test-app-id",
|
||||
appPassword: "test-secret",
|
||||
tenantId: "test-tenant",
|
||||
};
|
||||
|
||||
const app = await createMSTeamsApp(creds);
|
||||
const timeout = (app as unknown as { client?: { options?: { timeout?: number } } }).client
|
||||
?.options?.timeout;
|
||||
|
||||
expect(timeout).toBe(30_000);
|
||||
});
|
||||
|
||||
it("accepts custom messagingEndpoint", async () => {
|
||||
const creds: MSTeamsCredentials = {
|
||||
type: "secret",
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { normalizeBotFrameworkServiceUrl } from "./bot-framework-service-url.js";
|
||||
import type { MSTeamsCloudName } from "./cloud.js";
|
||||
import { MSTEAMS_REQUEST_TIMEOUT_MS } from "./request-timeout.js";
|
||||
import type { MSTeamsCredentials, MSTeamsFederatedCredentials } from "./token.js";
|
||||
import { buildOpenClawUserAgentFragment } from "./user-agent.js";
|
||||
|
||||
@@ -165,8 +166,12 @@ export type MSTeamsApp = {
|
||||
};
|
||||
api: {
|
||||
serviceUrl?: string;
|
||||
teams: {
|
||||
getById(teamId: string): Promise<{ aadGroupId?: string }>;
|
||||
};
|
||||
conversations: {
|
||||
activities(conversationId: string): {
|
||||
create(activity: unknown): Promise<{ id?: string }>;
|
||||
update(activityId: string, activity: unknown): Promise<unknown>;
|
||||
delete(activityId: string): Promise<unknown>;
|
||||
};
|
||||
@@ -289,6 +294,7 @@ export async function createMSTeamsApp(
|
||||
const appOptions: Record<string, unknown> = {
|
||||
client: options?.httpClient ?? {
|
||||
headers: { "User-Agent": buildOpenClawUserAgentFragment() },
|
||||
timeout: MSTEAMS_REQUEST_TIMEOUT_MS,
|
||||
},
|
||||
...(options?.httpServerAdapter ? { httpServerAdapter: options.httpServerAdapter } : {}),
|
||||
...(options?.messagingEndpoint ? { messagingEndpoint: options.messagingEndpoint } : {}),
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { StoredConversationReference } from "./conversation-store.js";
|
||||
import { resolveMSTeamsProactiveReplyStyle, resolveMSTeamsSendContext } from "./send-context.js";
|
||||
|
||||
const sendContextMockState = vi.hoisted(() => {
|
||||
const getAccessToken = vi.fn();
|
||||
const store = {
|
||||
upsert: vi.fn(),
|
||||
get: vi.fn(),
|
||||
@@ -16,7 +17,8 @@ const sendContextMockState = vi.hoisted(() => {
|
||||
return {
|
||||
store,
|
||||
loadMSTeamsSdkWithAuth: vi.fn(async () => ({ app: { id: "mock-app" } })),
|
||||
createMSTeamsTokenProvider: vi.fn(() => ({ getAccessToken: vi.fn() })),
|
||||
createMSTeamsTokenProvider: vi.fn(() => ({ getAccessToken })),
|
||||
getAccessToken,
|
||||
logWarn: vi.fn(),
|
||||
};
|
||||
});
|
||||
@@ -58,6 +60,7 @@ beforeEach(() => {
|
||||
sendContextMockState.store.findByUserId.mockReset();
|
||||
sendContextMockState.loadMSTeamsSdkWithAuth.mockClear();
|
||||
sendContextMockState.createMSTeamsTokenProvider.mockClear();
|
||||
sendContextMockState.getAccessToken.mockReset();
|
||||
sendContextMockState.logWarn.mockReset();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
@@ -123,6 +126,32 @@ describe("resolveMSTeamsSendContext", () => {
|
||||
|
||||
expect(sendContextMockState.store.remove).toHaveBeenCalledWith("19:channel@thread.tacv2");
|
||||
});
|
||||
|
||||
it("does not query Graph while resolving an opaque Bot Framework conversation", async () => {
|
||||
sendContextMockState.store.get.mockResolvedValue(
|
||||
channelRef({
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer/",
|
||||
conversation: { id: "a:personal", conversationType: "personal" },
|
||||
}),
|
||||
);
|
||||
|
||||
await resolveMSTeamsSendContext({
|
||||
cfg: {
|
||||
channels: {
|
||||
msteams: {
|
||||
enabled: true,
|
||||
appId: "app-id",
|
||||
appPassword: "app-password",
|
||||
tenantId: "tenant-id",
|
||||
sharePointSiteId: "site-id",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
to: "conversation:a:personal",
|
||||
});
|
||||
|
||||
expect(sendContextMockState.getAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMSTeamsProactiveReplyStyle", () => {
|
||||
|
||||
@@ -24,7 +24,6 @@ import type {
|
||||
StoredConversationReference,
|
||||
} from "./conversation-store.js";
|
||||
import { formatUnknownError } from "./errors.js";
|
||||
import { resolveGraphChatId } from "./graph-upload.js";
|
||||
import { resolveMSTeamsReplyPolicy, resolveMSTeamsRouteConfig } from "./policy.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
import type { MSTeamsApp } from "./sdk.js";
|
||||
@@ -45,19 +44,12 @@ export type MSTeamsProactiveContext = {
|
||||
replyStyle: MSTeamsReplyStyle;
|
||||
/** Teams SDK cloud/service endpoint used to validate proactive sends. */
|
||||
sdkCloudOptions: MSTeamsSdkCloudOptions;
|
||||
/** Token provider for Graph API / OneDrive operations */
|
||||
/** Token provider for Graph API / SharePoint 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;
|
||||
/**
|
||||
* Graph API-native chat ID for this conversation.
|
||||
* Bot Framework personal DM IDs (`a:1xxx` / `8:orgid:xxx`) cannot be used directly
|
||||
* with Graph chat endpoints. This field holds the resolved `19:xxx` format ID.
|
||||
* Null if resolution failed or not applicable.
|
||||
*/
|
||||
graphChatId?: string | null;
|
||||
};
|
||||
|
||||
export function resolveMSTeamsProactiveReplyStyle(params: {
|
||||
@@ -220,7 +212,7 @@ export async function resolveMSTeamsSendContext(params: {
|
||||
configuredServiceUrl: sdkCloudOptions.serviceUrl,
|
||||
});
|
||||
|
||||
// Create token provider adapter for Graph API / OneDrive operations
|
||||
// Create token provider adapter for Graph API / SharePoint operations
|
||||
const tokenProvider: MSTeamsAccessTokenProvider = createMSTeamsTokenProvider(app);
|
||||
|
||||
// Determine conversation type from stored reference
|
||||
@@ -252,45 +244,6 @@ export async function resolveMSTeamsSendContext(params: {
|
||||
resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
|
||||
});
|
||||
|
||||
// Resolve Graph API-native chat ID if needed for SharePoint per-user sharing.
|
||||
// Bot Framework personal DM conversation IDs (e.g. `a:1xxx` or `8:orgid:xxx`) cannot
|
||||
// be used directly with Graph /chats/{chatId} endpoints — the Graph API requires the
|
||||
// `19:xxx@thread.tacv2` or `19:xxx@unq.gbl.spaces` format.
|
||||
// We check the cached value first, then resolve via Graph API and cache for future sends.
|
||||
let graphChatId: string | null | undefined = safeRef.graphChatId ?? undefined;
|
||||
if (graphChatId === undefined && sharePointSiteId) {
|
||||
// Only resolve when SharePoint is configured (the only place chatId matters currently)
|
||||
try {
|
||||
const resolved = await resolveGraphChatId({
|
||||
botFrameworkConversationId: conversationId,
|
||||
userAadObjectId: safeRef.user?.aadObjectId,
|
||||
tokenProvider,
|
||||
});
|
||||
graphChatId = resolved;
|
||||
|
||||
// Cache in the conversation store so subsequent sends skip the Graph lookup.
|
||||
// NOTE: We intentionally do NOT cache null results. Transient Graph API failures
|
||||
// (network, 401, rate limit) should be retried on subsequent sends rather than
|
||||
// permanently blocking file uploads for this conversation.
|
||||
if (resolved) {
|
||||
await store.upsert(conversationId, { ...safeRef, graphChatId: resolved });
|
||||
} else {
|
||||
log.warn?.("could not resolve Graph chat ID; file uploads may fail for this conversation", {
|
||||
conversationId,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn?.(
|
||||
"failed to resolve Graph chat ID; file uploads may fall back to Bot Framework ID",
|
||||
{
|
||||
conversationId,
|
||||
error: formatUnknownError(err),
|
||||
},
|
||||
);
|
||||
graphChatId = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
appId: creds.appId,
|
||||
conversationId,
|
||||
@@ -303,6 +256,5 @@ export async function resolveMSTeamsSendContext(params: {
|
||||
tokenProvider,
|
||||
sharePointSiteId,
|
||||
mediaMaxBytes,
|
||||
graphChatId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,11 +83,14 @@ vi.mock("./runtime.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./graph-upload.js", () => ({
|
||||
uploadAndShareSharePoint: mockState.uploadAndShareSharePoint,
|
||||
getDriveItemProperties: mockState.getDriveItemProperties,
|
||||
uploadAndShareOneDrive: vi.fn(),
|
||||
}));
|
||||
vi.mock("./graph-upload.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./graph-upload.js")>();
|
||||
return {
|
||||
...actual,
|
||||
uploadAndShareSharePoint: mockState.uploadAndShareSharePoint,
|
||||
getDriveItemProperties: mockState.getDriveItemProperties,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./graph-chat.js", () => ({
|
||||
buildTeamsFileInfoCard: mockState.buildTeamsFileInfoCard,
|
||||
@@ -151,16 +154,11 @@ function mockProactiveSendContextFailure(error: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function createSharePointSendContext(params: {
|
||||
conversationId: string;
|
||||
graphChatId: string | null;
|
||||
siteId: string;
|
||||
}) {
|
||||
function createSharePointSendContext(params: { conversationId: string; siteId: string }) {
|
||||
return {
|
||||
app: createMockApp(),
|
||||
appId: "app-id",
|
||||
conversationId: params.conversationId,
|
||||
graphChatId: params.graphChatId,
|
||||
ref: {},
|
||||
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
conversationType: "groupChat" as const,
|
||||
@@ -382,46 +380,12 @@ describe("sendMessageMSTeams", () => {
|
||||
expect(firstObjectArg(mockState.sendMSTeamsMessages).replyStyle).toBe("top-level");
|
||||
});
|
||||
|
||||
it("uses graphChatId instead of conversationId when uploading to SharePoint", async () => {
|
||||
// Simulates a group chat where Bot Framework conversationId is valid but we have
|
||||
// a resolved Graph chat ID cached from a prior send.
|
||||
const graphChatId = "19:graph-native-chat-id@thread.tacv2";
|
||||
const botFrameworkConversationId = "19:bot-framework-id@thread.tacv2";
|
||||
it("uses the Graph-native group conversation ID for SharePoint sharing", async () => {
|
||||
const graphConversationId = "19:group-id@thread.v2";
|
||||
|
||||
mockState.resolveMSTeamsSendContext.mockResolvedValue(
|
||||
createSharePointSendContext({
|
||||
conversationId: botFrameworkConversationId,
|
||||
graphChatId,
|
||||
siteId: "site-123",
|
||||
}),
|
||||
);
|
||||
mockSharePointPdfUpload({
|
||||
bufferSize: 100,
|
||||
fileName: "doc.pdf",
|
||||
itemId: "item-1",
|
||||
uniqueId: "{GUID-123}",
|
||||
});
|
||||
|
||||
await sendMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "conversation:19:bot-framework-id@thread.tacv2",
|
||||
text: "here is a file",
|
||||
mediaUrl: "https://example.com/doc.pdf",
|
||||
});
|
||||
|
||||
// The Graph-native chatId must be passed to SharePoint upload, not the Bot Framework ID
|
||||
const uploadPayload = firstObjectArg(mockState.uploadAndShareSharePoint);
|
||||
expect(uploadPayload.chatId).toBe(graphChatId);
|
||||
expect(uploadPayload.siteId).toBe("site-123");
|
||||
});
|
||||
|
||||
it("falls back to conversationId when graphChatId is not available", async () => {
|
||||
const botFrameworkConversationId = "19:fallback-id@thread.tacv2";
|
||||
|
||||
mockState.resolveMSTeamsSendContext.mockResolvedValue(
|
||||
createSharePointSendContext({
|
||||
conversationId: botFrameworkConversationId,
|
||||
graphChatId: null,
|
||||
conversationId: graphConversationId,
|
||||
siteId: "site-456",
|
||||
}),
|
||||
);
|
||||
@@ -434,16 +398,41 @@ describe("sendMessageMSTeams", () => {
|
||||
|
||||
await sendMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "conversation:19:fallback-id@thread.tacv2",
|
||||
to: `conversation:${graphConversationId}`,
|
||||
text: "report",
|
||||
mediaUrl: "https://example.com/report.pdf",
|
||||
});
|
||||
|
||||
// Falls back to conversationId when graphChatId is null
|
||||
const uploadPayload = firstObjectArg(mockState.uploadAndShareSharePoint);
|
||||
expect(uploadPayload.chatId).toBe(botFrameworkConversationId);
|
||||
expect(uploadPayload.chatId).toBe(graphConversationId);
|
||||
expect(uploadPayload.siteId).toBe("site-456");
|
||||
});
|
||||
|
||||
it("fails clearly when a group file has no SharePoint site", async () => {
|
||||
mockState.resolveMSTeamsSendContext.mockResolvedValue({
|
||||
...createSharePointSendContext({
|
||||
conversationId: "19:group-id@thread.v2",
|
||||
siteId: "unused",
|
||||
}),
|
||||
sharePointSiteId: undefined,
|
||||
});
|
||||
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("pdf"),
|
||||
contentType: "application/pdf",
|
||||
fileName: "report.pdf",
|
||||
kind: "file",
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "conversation:19:group-id@thread.v2",
|
||||
text: "report",
|
||||
mediaUrl: "https://example.com/report.pdf",
|
||||
}),
|
||||
).rejects.toThrow("channels.msteams.sharePointSiteId is required");
|
||||
expect(mockState.uploadAndShareSharePoint).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MSTeams continueConversation failure handling", () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ import { prepareFileConsentActivityFs, requiresFileConsent } from "./file-consen
|
||||
import { buildTeamsFileInfoCard } from "./graph-chat.js";
|
||||
import {
|
||||
getDriveItemProperties,
|
||||
uploadAndShareOneDrive,
|
||||
requireMSTeamsSharePointSiteId,
|
||||
uploadAndShareSharePoint,
|
||||
} from "./graph-upload.js";
|
||||
import { extractFilename, extractMessageId } from "./media-helpers.js";
|
||||
@@ -59,7 +59,7 @@ 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.
|
||||
* Higher than the default to support Teams file-consent and SharePoint uploads.
|
||||
*/
|
||||
const MSTEAMS_MAX_MEDIA_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
@@ -144,7 +144,7 @@ type SendMSTeamsCardResult = {
|
||||
*
|
||||
* 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
|
||||
* - Group chats / channels: files require configured SharePoint storage
|
||||
*/
|
||||
export async function sendMessageMSTeams(
|
||||
params: SendMSTeamsMessageParams,
|
||||
@@ -251,101 +251,52 @@ export async function sendMessageMSTeams(
|
||||
}
|
||||
|
||||
if (isImage && !sharePointSiteId) {
|
||||
// Group chat/channel without SharePoint: send image inline (avoids OneDrive failures)
|
||||
// Group chat/channel images can be sent inline without SharePoint storage.
|
||||
const base64 = media.buffer.toString("base64");
|
||||
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
|
||||
return sendTextWithMedia(ctx, messageText, finalMediaUrl);
|
||||
}
|
||||
|
||||
// Group chat or channel: upload to SharePoint (if siteId configured) or OneDrive
|
||||
// Group chat or channel: upload to configured SharePoint storage.
|
||||
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,
|
||||
// Use the Graph-native chat ID (19:xxx format) — the Bot Framework conversationId
|
||||
// for personal DMs uses a different format that Graph API rejects.
|
||||
chatId: ctx.graphChatId ?? 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: messageText || undefined,
|
||||
attachments: [fileCardAttachment],
|
||||
};
|
||||
const messageId = await sendProactiveActivityRaw({
|
||||
app,
|
||||
ref,
|
||||
activity,
|
||||
serviceUrlBoundary: sdkCloudOptions,
|
||||
});
|
||||
|
||||
log.info("sent native file card", {
|
||||
conversationId,
|
||||
messageId,
|
||||
fileName: driveItem.name,
|
||||
});
|
||||
|
||||
return createMSTeamsSendResult({
|
||||
messageId,
|
||||
conversationId,
|
||||
kind: "media",
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: no SharePoint site configured, use OneDrive with markdown link
|
||||
log.debug?.("uploading to OneDrive (no SharePoint site configured)", {
|
||||
const siteId = requireMSTeamsSharePointSiteId(sharePointSiteId);
|
||||
log.debug?.("uploading to SharePoint for native file card", {
|
||||
fileName,
|
||||
conversationType,
|
||||
siteId,
|
||||
});
|
||||
|
||||
const uploaded = await uploadAndShareOneDrive({
|
||||
const uploaded = await uploadAndShareSharePoint({
|
||||
buffer: media.buffer,
|
||||
filename: fileName,
|
||||
contentType: media.contentType,
|
||||
tokenProvider,
|
||||
siteId,
|
||||
chatId: conversationId,
|
||||
usePerUserSharing: conversationType === "groupChat",
|
||||
});
|
||||
|
||||
log.debug?.("OneDrive upload complete", {
|
||||
log.debug?.("SharePoint 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 driveItem = await getDriveItemProperties({
|
||||
siteId,
|
||||
itemId: uploaded.itemId,
|
||||
tokenProvider,
|
||||
});
|
||||
|
||||
log.debug?.("driveItem properties retrieved", {
|
||||
eTag: driveItem.eTag,
|
||||
webDavUrl: driveItem.webDavUrl,
|
||||
});
|
||||
|
||||
const fileCardAttachment = buildTeamsFileInfoCard(driveItem);
|
||||
const activity = {
|
||||
type: "message",
|
||||
text: messageText ? `${messageText}\n\n${fileLink}` : fileLink,
|
||||
text: messageText || undefined,
|
||||
attachments: [fileCardAttachment],
|
||||
};
|
||||
const messageId = await sendProactiveActivityRaw({
|
||||
app,
|
||||
@@ -354,10 +305,10 @@ export async function sendMessageMSTeams(
|
||||
serviceUrlBoundary: sdkCloudOptions,
|
||||
});
|
||||
|
||||
log.info("sent message with OneDrive file link", {
|
||||
log.info("sent native file card", {
|
||||
conversationId,
|
||||
messageId,
|
||||
shareUrl: uploaded.shareUrl,
|
||||
fileName: driveItem.name,
|
||||
});
|
||||
|
||||
return createMSTeamsSendResult({
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// Msteams tests cover canonical team identity resolution.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { _teamGroupIdCacheForTest, resolveTeamGroupId } from "./team-identity.js";
|
||||
|
||||
describe("resolveTeamGroupId", () => {
|
||||
const getTeamDetails = vi.fn<(teamId: string) => Promise<{ aadGroupId?: string }>>();
|
||||
|
||||
beforeEach(() => {
|
||||
getTeamDetails.mockReset();
|
||||
getTeamDetails.mockResolvedValue({ aadGroupId: "group-guid" });
|
||||
_teamGroupIdCacheForTest.clear();
|
||||
});
|
||||
|
||||
it("uses and caches the activity AAD group ID without a Teams API lookup", async () => {
|
||||
const result = await resolveTeamGroupId({
|
||||
conversationTeamId: "team-123",
|
||||
aadGroupId: " group-guid-1 ",
|
||||
getTeamDetails,
|
||||
});
|
||||
const cached = await resolveTeamGroupId({
|
||||
conversationTeamId: "team-123",
|
||||
getTeamDetails,
|
||||
});
|
||||
|
||||
expect(result).toBe("group-guid-1");
|
||||
expect(cached).toBe("group-guid-1");
|
||||
expect(getTeamDetails).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves a missing AAD group ID through the Teams API", async () => {
|
||||
getTeamDetails.mockResolvedValueOnce({ aadGroupId: " group-guid-2 " });
|
||||
|
||||
const result = await resolveTeamGroupId({
|
||||
conversationTeamId: "19:team@thread.skype",
|
||||
getTeamDetails,
|
||||
});
|
||||
|
||||
expect(result).toBe("group-guid-2");
|
||||
expect(getTeamDetails).toHaveBeenCalledWith("19:team@thread.skype");
|
||||
});
|
||||
|
||||
it("returns cached value without calling the Teams API again", async () => {
|
||||
const params = { conversationTeamId: "team-456", getTeamDetails };
|
||||
|
||||
await resolveTeamGroupId(params);
|
||||
await resolveTeamGroupId(params);
|
||||
|
||||
expect(getTeamDetails).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("bounds a stalled Teams API identity lookup", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
getTeamDetails.mockImplementationOnce(() => new Promise(() => {}));
|
||||
const result = resolveTeamGroupId({
|
||||
conversationTeamId: "team-stalled",
|
||||
getTeamDetails,
|
||||
deadline: {
|
||||
label: "MS Teams inbound preprocessing",
|
||||
timeoutMs: 50,
|
||||
deadlineAtMs: Date.now() + 50,
|
||||
},
|
||||
});
|
||||
const assertion = expect(result).rejects.toThrow(/timed out/i);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(51);
|
||||
await assertion;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns undefined instead of sending a raw Bot Framework team ID to Graph", async () => {
|
||||
getTeamDetails.mockResolvedValueOnce({});
|
||||
|
||||
await expect(
|
||||
resolveTeamGroupId({
|
||||
conversationTeamId: "19:team@thread.skype",
|
||||
getTeamDetails,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when no per-activity Teams resolver is available", async () => {
|
||||
await expect(
|
||||
resolveTeamGroupId({ conversationTeamId: "19:team@thread.skype" }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("caps cache at 500 entries and evicts the oldest team", async () => {
|
||||
for (let i = 0; i < 500; i++) {
|
||||
await resolveTeamGroupId({ conversationTeamId: `team-${i}`, getTeamDetails });
|
||||
}
|
||||
|
||||
await resolveTeamGroupId({ conversationTeamId: "team-500", getTeamDetails });
|
||||
|
||||
expect(_teamGroupIdCacheForTest.size).toBe(500);
|
||||
expect(_teamGroupIdCacheForTest.has("team-0")).toBe(false);
|
||||
expect(_teamGroupIdCacheForTest.has("team-500")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// Msteams plugin module implements canonical team identity resolution.
|
||||
import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime";
|
||||
import { type MSTeamsRequestDeadline, withMSTeamsRequestDeadline } from "./request-timeout.js";
|
||||
|
||||
// Team AAD group IDs are stable metadata; a bounded process cache avoids a
|
||||
// regional Bot Connector lookup on every turn and refreshes on restart.
|
||||
const teamGroupIdCache = new Map<string, string>();
|
||||
const TEAM_GROUP_ID_CACHE_MAX_ENTRIES = 500;
|
||||
|
||||
function cacheTeamGroupId(conversationTeamId: string, groupId: string): void {
|
||||
teamGroupIdCache.set(conversationTeamId, groupId);
|
||||
pruneMapToMaxSize(teamGroupIdCache, TEAM_GROUP_ID_CACHE_MAX_ENTRIES);
|
||||
}
|
||||
|
||||
/** Resolve the Graph team GUID without ever treating a Bot Framework team ID as equivalent. */
|
||||
export async function resolveTeamGroupId(params: {
|
||||
conversationTeamId: string;
|
||||
aadGroupId?: string;
|
||||
getTeamDetails?: (teamId: string) => Promise<{ aadGroupId?: string }>;
|
||||
deadline?: MSTeamsRequestDeadline;
|
||||
}): Promise<string | undefined> {
|
||||
const activityGroupId = params.aadGroupId?.trim();
|
||||
if (activityGroupId) {
|
||||
cacheTeamGroupId(params.conversationTeamId, activityGroupId);
|
||||
return activityGroupId;
|
||||
}
|
||||
|
||||
const cached = teamGroupIdCache.get(params.conversationTeamId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const getTeamDetails = params.getTeamDetails;
|
||||
if (!getTeamDetails) {
|
||||
return undefined;
|
||||
}
|
||||
const team = await withMSTeamsRequestDeadline({
|
||||
deadline: params.deadline,
|
||||
label: "MS Teams team details",
|
||||
work: () => getTeamDetails(params.conversationTeamId),
|
||||
});
|
||||
const groupId = team.aadGroupId?.trim();
|
||||
if (!groupId) {
|
||||
return undefined;
|
||||
}
|
||||
cacheTeamGroupId(params.conversationTeamId, groupId);
|
||||
return groupId;
|
||||
}
|
||||
|
||||
// Exported for testing only.
|
||||
export { teamGroupIdCache as _teamGroupIdCacheForTest };
|
||||
File diff suppressed because one or more lines are too long
@@ -161,6 +161,12 @@ export type MSTeamsConfig = {
|
||||
* Use specific hosts only; avoid multi-tenant suffixes.
|
||||
*/
|
||||
mediaAuthAllowHosts?: Array<string>;
|
||||
/**
|
||||
* Query Graph for channel/group media when Bot Framework HTML omits file markers.
|
||||
* Requires the documented Graph permissions and adds one message lookup per
|
||||
* otherwise unresolved HTML activity. Default: false.
|
||||
*/
|
||||
graphMediaFallback?: boolean;
|
||||
/** Default: require @mention to respond in channels/groups. */
|
||||
requireMention?: boolean;
|
||||
/** Max group/channel messages to keep as history context (0 disables). */
|
||||
@@ -173,7 +179,7 @@ export type MSTeamsConfig = {
|
||||
replyStyle?: MSTeamsReplyStyle;
|
||||
/** Per-team config. Key is team ID (from the /team/ URL path segment). */
|
||||
teams?: Record<string, MSTeamsTeamConfig>;
|
||||
/** Max media size in MB (default: 100MB for OneDrive upload support). */
|
||||
/** Max inbound and outbound media size in MB (default: 100MB). */
|
||||
mediaMaxMb?: number;
|
||||
/** SharePoint site ID for file uploads in group chats/channels (e.g., "contoso.sharepoint.com,guid1,guid2"). */
|
||||
sharePointSiteId?: string;
|
||||
|
||||
@@ -1663,13 +1663,14 @@ export const MSTeamsConfigSchema = z
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
mediaAllowHosts: z.array(z.string()).optional(),
|
||||
mediaAuthAllowHosts: z.array(z.string()).optional(),
|
||||
graphMediaFallback: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
replyStyle: MSTeamsReplyStyleSchema.optional(),
|
||||
teams: z.record(z.string(), MSTeamsTeamSchema.optional()).optional(),
|
||||
/** Max media size in MB (default: 100MB for OneDrive upload support). */
|
||||
/** Max inbound and outbound media size in MB (default: 100MB). */
|
||||
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(),
|
||||
|
||||
Reference in New Issue
Block a user