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:
colton-octaria
2026-07-09 20:34:12 -05:00
committed by GitHub
parent c07fe5fe70
commit dc8c0aaaf2
55 changed files with 1744 additions and 1404 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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",
);
});
});

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);

View File

@@ -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();
}
});
});

View File

@@ -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,
});

View File

@@ -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.

View File

@@ -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: {

View File

@@ -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,
});
}

View File

@@ -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;
};

View File

@@ -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",

View File

@@ -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.',

View File

@@ -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 }

View File

@@ -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",
});

View File

@@ -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();

View File

@@ -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;
};

View File

@@ -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);

View File

@@ -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: {},

View File

@@ -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: [] });

View File

@@ -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.",
);
}

View File

@@ -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", () => {

View File

@@ -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 };

View File

@@ -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",
);
},
);

View File

@@ -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";

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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 });

View File

@@ -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;

View File

@@ -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",

View File

@@ -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,
});
}

View File

@@ -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(
{

View File

@@ -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");
});
});

View File

@@ -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();
});
});

View File

@@ -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,
};
}

View File

@@ -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",

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,
});
}

View File

@@ -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();
});
});

View File

@@ -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);
}

View File

@@ -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;
};

View File

@@ -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",

View File

@@ -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 } : {}),

View File

@@ -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", () => {

View File

@@ -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,
};
}

View File

@@ -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", () => {

View File

@@ -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({

View File

@@ -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);
});
});

View File

@@ -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

View File

@@ -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;

View File

@@ -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(),