Skip to content

fix(msteams): fix image attachment download for channel and DM messages#40463

Open
hddevteam wants to merge 7 commits intoopenclaw:mainfrom
hddevteam:fix/msteams-hostedcontent-value
Open

fix(msteams): fix image attachment download for channel and DM messages#40463
hddevteam wants to merge 7 commits intoopenclaw:mainfrom
hddevteam:fix/msteams-hostedcontent-value

Conversation

@hddevteam
Copy link
Copy Markdown

@hddevteam hddevteam commented Mar 9, 2026

Problem

When Teams users send images or file attachments, the bot silently drops them. This PR fixes multiple root causes discovered in a cross-tenant deployment (Bot App registered in Azure subscription tenant A, Teams conversations in M365 org tenant B). All fixes have been validated in production.


Fixes

1. Fetch hostedContents via /$value when contentBytes not inlined

The Graph API /hostedContents collection endpoint never returns contentBytes inline in the list response. The original code treated an empty contentBytes as "no content" and continued, so every attachment was skipped unconditionally.

Fix: when contentBytes is absent but the item has an id, fall back to fetching the binary via /{id}/$value, which reliably returns the file content. The Content-Type header from that response is also captured for accurate MIME detection.

2. some() instead of every() for HTML attachment detection (inbound-media.ts)

When a channel message contains a mix of image and text/html attachments, every() required ALL attachments to be html before attempting the Graph path. This silently skipped image attachments in mixed messages.

Fix: changed to some() so the Graph path is attempted whenever at least one attachment has a text/html content type.

3. Add trafficmanager.net to DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST (shared.ts)

DM image attachments use direct download URLs on smba.trafficmanager.net (SMBA = Service Messaging Bot Attachments, served via Azure Traffic Manager). This domain was missing from the auth token allowlist, so requests were sent without a Bearer token, resulting in 401 and silent download failures.

Fix: added "trafficmanager.net" to DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST.

4. Prefer channelData.chatId for DM/group chats; add /chats/ fallback for channel messages (graph.ts)

Two related fixes in buildMSTeamsGraphMessageUrls:

  • DM/group: conversationId may be a Bot Framework conversation ID (a:xxx) not accepted by Graph. Prefer channelData.chatId (the proper Graph chat GUID) when available.
  • Channel: channelData.team.id is sometimes a Teams thread ID (19:[email protected]) instead of a GUID. The /teams/{guid}/channels/ endpoint rejects this with 400. /chats/{threadId}/messages/ accepts thread-format IDs, so /chats/ URLs are now appended as fallback candidates after the /teams/ URLs.

Testing

  • All 72 msteams unit tests pass (15 test files fail due to a pre-existing missing @mariozechner/pi-ai dependency unrelated to this PR).
  • Validated end-to-end in production: channel images ✅, DM images ✅.

Copilot AI review requested due to automatic review settings March 9, 2026 02:19
@openclaw-barnacle openclaw-barnacle bot added channel: msteams Channel integration: msteams size: XS labels Mar 9, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 9, 2026

Greptile Summary

This PR fixes a real bug where Teams image/file attachments were silently dropped because the Microsoft Graph API never returns contentBytes inline in the /hostedContents collection response. The fix adds a fallback to fetch individual content via the /{id}/$value endpoint when contentBytes is absent, correctly capturing the MIME type from the response content-type header.

Key changes in extensions/msteams/src/attachments/graph.ts:

  • buffer is now Buffer | undefined with a final !buffer guard before use — safe and correct.
  • The /$value fetch is wrapped in a try...finally ensuring valRelease() is always called, including when continue is hit on a non-ok response.
  • encodeURIComponent(item.id) properly sanitises the hosted-content ID in the constructed URL.
  • The SSRF policy and access token are forwarded consistently with the rest of the file.
  • mime ?? itemContentType ?? undefined on the saveMediaBuffer call has a redundant trailing ?? undefined (since itemContentType is already string | undefined), but this is harmless.

Confidence Score: 4/5

  • This PR is safe to merge — it correctly implements the Microsoft Graph /$value fallback with proper resource release, SSRF protection, and error handling.
  • The logic is straightforward and well-contained to a single function. valRelease() is always called via finally, encodeURIComponent handles special characters in item IDs, and the SSRF policy is consistently applied. No critical bugs were found. Score is 4 rather than 5 only because the change adds sequential per-item HTTP requests (one per attachment) with no streaming size guard, which could be slow or memory-intensive for messages with many large attachments — but this is a performance consideration rather than a correctness issue.
  • No files require special attention.

Last reviewed commit: 830077f

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 830077f60f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

}
const ct = valResp.headers.get("content-type");
if (ct) itemContentType = ct;
buffer = Buffer.from(await valResp.arrayBuffer());
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce maxBytes before buffering hosted content

valResp.arrayBuffer() reads the entire /hostedContents/{id}/$value payload into memory before the size guard runs (buffer.byteLength > params.maxBytes), so oversized attachments are still fully downloaded and buffered. In environments where users can send large Teams files, this can cause avoidable memory spikes or OOM despite maxBytes being configured; the response body should be size-limited while reading (or rejected from headers before buffering).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes MSTeams Graph hosted content downloads by handling the Graph API behavior where contentBytes is not inlined in /hostedContents list responses, ensuring Teams images/files aren’t silently dropped.

Changes:

  • Decode hosted content from contentBytes when present; otherwise fetch bytes via .../hostedContents/{id}/$value.
  • Capture content-type from the $value response and feed it into MIME detection / storage.

Comment on lines +208 to +216
const valueUrl = `${params.messageUrl}/hostedContents/${encodeURIComponent(item.id)}/$value`;
const fetchFn = params.fetchFn ?? fetch;
const { response: valResp, release: valRelease } = await fetchWithSsrFGuard({
url: valueUrl,
fetchImpl: fetchFn,
init: { headers: { Authorization: `Bearer ${params.accessToken}` } },
policy: params.ssrfPolicy,
auditContext: "msteams.graph.hostedcontent.value",
});
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fetchWithSsrFGuard(...) call in the item.id branch can throw (network error, SSRF block, invalid URL). Because it's not wrapped in a try/catch, a single hosted content download failure would reject downloadGraphHostedContent and abort all subsequent attachment handling for the message. Consider catching errors around this fetch and continueing so failures remain per-item, consistent with the rest of this loop's error-handling strategy.

Copilot uses AI. Check for mistakes.
continue;
}
const ct = valResp.headers.get("content-type");
if (ct) itemContentType = ct;
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Buffer.from(await valResp.arrayBuffer()) reads the full response into memory before enforcing maxBytes. This bypasses the repo's usual max-bytes protection (e.g., readResponseWithLimit / fetchRemoteMedia patterns) and could allow oversized hosted content to cause high memory usage even though you later continue when buffer.byteLength > params.maxBytes. Prefer enforcing maxBytes while reading (e.g., check content-length and/or read the body with a byte limit) so large payloads are rejected early.

Suggested change
if (ct) itemContentType = ct;
if (ct) itemContentType = ct;
const contentLengthHeader = valResp.headers.get("content-length");
if (contentLengthHeader) {
const contentLength = Number(contentLengthHeader);
if (!Number.isNaN(contentLength) && contentLength > params.maxBytes) {
continue;
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +221 to +223
const ct = valResp.headers.get("content-type");
if (ct) itemContentType = ct;
buffer = Buffer.from(await valResp.arrayBuffer());
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When overriding itemContentType from the response header (content-type), consider normalizing/trimming it (e.g., via normalizeContentType) before passing it into mime detection / saveMediaBuffer. This keeps behavior consistent with normalizeGraphAttachment and avoids propagating header values with unexpected whitespace/casing.

Copilot uses AI. Check for mistakes.
Comment on lines +205 to +210
} else if (item.id) {
// Graph API does not inline contentBytes in the collection response.
// Fetch the actual bytes via the /$value endpoint instead.
const valueUrl = `${params.messageUrl}/hostedContents/${encodeURIComponent(item.id)}/$value`;
const fetchFn = params.fetchFn ?? fetch;
const { response: valResp, release: valRelease } = await fetchWithSsrFGuard({
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are existing tests for hostedContents when contentBytes is inlined, but this new fallback path (item.id -> /{id}/$value) isn’t covered. Please add a unit test that simulates a hostedContents list item with id but no contentBytes, and asserts that the code fetches .../hostedContents/{id}/$value and stores the returned bytes/content-type.

Copilot uses AI. Check for mistakes.
@hddevteam
Copy link
Copy Markdown
Author

The two failing CI checks are pre-existing issues unrelated to this PR:

  1. check (format:check)src/cli/daemon-cli/lifecycle.test.ts has a formatting issue. This file is not touched by this PR.
  2. checks-windows (6/6)src/secrets/runtime.test.ts fails with ACL verification unavailable on Windows — a Windows-specific secrets path permission issue, also unrelated to this change.

Both failures reproduce on upstream/main and were present before this branch was created.

@BradGroux
Copy link
Copy Markdown
Contributor

Hi @hddevteam — thanks for the submission. I’m the new Microsoft Teams maintainer for OpenClaw. Please give me a day or two to work through the open Teams backlog. Also, join the Twitter community for daily MS Teams feedback + updates: https://x.com/i/communities/2031170403607974228

@hddevteam hddevteam force-pushed the fix/msteams-hostedcontent-value branch from 2090dd9 to eec664e Compare March 11, 2026 01:01
@hddevteam hddevteam changed the title fix(msteams): fetch hostedContents via /$value when contentBytes not inlined fix(msteams): fix image attachment download for channel and DM messages Mar 11, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eec664e0cb

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

"graph.microsoft.de",
"graph.microsoft.cn",
// Bot Framework attachment service (smba.trafficmanager.net) used for DM images
"trafficmanager.net",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict auth token forwarding to specific Traffic Manager host

Adding "trafficmanager.net" to the auth allowlist causes fetchWithAuthFallback to attach bearer tokens to any https://*.trafficmanager.net URL after a 401/403, because host checks are suffix-based (isUrlAllowed). Since Traffic Manager subdomains can be tenant-controlled, a malicious attachment URL like https://attacker.trafficmanager.net/... can trigger token forwarding and exfiltrate Graph/Bot Framework access tokens; this should be narrowed to the exact Microsoft endpoint(s) needed (for example smba.trafficmanager.net) rather than the whole parent domain.

Useful? React with 👍 / 👎.

@hddevteam
Copy link
Copy Markdown
Author

Totally understand, @BradGroux — no rush at all! Thanks for taking on the Teams maintainer role and for letting us know. Looking forward to your review whenever you get a chance. 🙏

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4039a52195

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +247 to +251
const contentLength = Number(valResp.headers.get("content-length") ?? "0");
if (contentLength > 0 && contentLength > params.maxBytes) {
continue;
}
buffer = Buffer.from(await valResp.arrayBuffer());
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce byte limit when /$value lacks Content-Length

The new hosted-content fallback only rejects oversized payloads when content-length is present, then calls arrayBuffer() unconditionally; if the server omits or misreports that header (valid for chunked responses), an attachment larger than maxBytes is still fully buffered in memory before being discarded. This defeats the configured size guard for that response class and can cause avoidable memory spikes under large DM/channel attachments.

Useful? React with 👍 / 👎.

Ming added 6 commits March 17, 2026 14:42
…inlined

Graph API's /hostedContents collection endpoint does not return contentBytes
inline in the response — the field is always null/absent. The previous code
would skip every item with an empty contentBytes, causing all image/file
attachments sent via Teams to be silently dropped.

Fix: when contentBytes is absent but the item has an id, fall back to fetching
the actual bytes via the individual /{id}/$value endpoint, which always returns
the binary content. The content-type header from that response is also used to
set itemContentType for accurate MIME detection downstream.

Verified working against Microsoft Teams (channel messages) with both image
and file attachments.
When a Teams channel message contains a mix of image and html attachments,
every() required ALL attachments to be html before trying the Graph path.
This caused image attachments to be silently skipped.

Use some() so the Graph path is attempted whenever at least one attachment
has a text/html content type.
…LIST

DM image attachments in Microsoft Teams use a direct download URL on
smba.trafficmanager.net (the Bot Framework attachment service, SMBA =
Service Messaging Bot Attachments). This domain was absent from the auth
token allowlist, so download requests were sent without a Bearer token,
resulting in 401 responses and silent download failures.

Add "trafficmanager.net" so that Bot Framework tokens are automatically
attached to requests targeting this domain.
…lback for channels

Two related fixes for buildMSTeamsGraphMessageUrls:

1. DM/group chat: prefer channelData.chatId (the proper Graph chat GUID)
   over conversationId, which may be a Bot Framework conversation ID in
   the form "a:xxx" that Graph does not recognise.

2. Channel messages: Teams channel IDs surfaced in channelData.team.id
   are sometimes in thread format ("19:[email protected]") rather than a
   GUID. The /teams/{guid}/channels/ Graph endpoint rejects these with
   400. /chats/{threadId}/messages/ accepts thread-format IDs, so append
   /chats/ candidate URLs as a fallback after the /teams/ URLs.
@hddevteam hddevteam force-pushed the fix/msteams-hostedcontent-value branch from 4039a52 to f43d155 Compare March 17, 2026 06:43
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 442621bb7e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +324 to +328
accessToken = await provider.getAccessToken(
{
clientId: provider.connectionSettings.clientId,
clientSecret: provider.connectionSettings.clientSecret,
tenantId: conversationTenantId,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use token provider API contract when fetching tenant token

downloadMSTeamsGraphMedia accepts tokenProvider as MSTeamsAccessTokenProvider (single-argument getAccessToken(scope)), but this branch calls getAccessToken(authConfig, scope) whenever conversationTenantId differs. If a provider follows the declared interface yet exposes connectionSettings metadata, the first argument becomes an object instead of a scope string, so token acquisition can fail and Graph media fallback returns tokenError for cross-tenant messages. This should be gated by an explicit capability check or a dedicated tenant-scoped provider path rather than invoking an undeclared overload.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: msteams Channel integration: msteams size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants