Skip to content

feat(msteams): fetch thread history via Graph API for channel replies#43326

Closed
arnaudlamy wants to merge 8 commits intoopenclaw:mainfrom
arnaudlamy:feat/msteams-thread-history-graph-api
Closed

feat(msteams): fetch thread history via Graph API for channel replies#43326
arnaudlamy wants to merge 8 commits intoopenclaw:mainfrom
arnaudlamy:feat/msteams-thread-history-graph-api

Conversation

@arnaudlamy
Copy link
Copy Markdown

Summary

• Problem: When a bot receives a reply in a Teams channel thread, it has no context about the parent message or earlier replies — it only sees the new message in isolation.
• Why it matters: Channel threads require full context to generate useful responses. Without thread history, the bot can't understand what's being discussed.
• What changed: Added Graph API calls to fetch parent message + thread replies, and inject them into BodyForAgent so the model sees the full conversation. Added resolveTeamGroupId() to map Teams conversation IDs to Azure AD group GUIDs (required by Graph API).
• What did NOT change: No changes to how the bot sends messages, handles DMs/group chats, or processes non-thread channel messages. No changes to the compiled dist/ bundle.

Change Type (select all)

• [ ] Bug fix
• [x] Feature
• [ ] Refactor
• [ ] Docs
• [ ] Security hardening
• [ ] Chore/infra

Scope (select all touched areas)

• [ ] Gateway / orchestration
• [ ] Skills / tool execution
• [ ] Auth / tokens
• [ ] Memory / storage
• [x] Integrations
• [ ] API / contracts
• [ ] UI / DX
• [ ] CI/CD / infra

Linked Issue/PR

• Closes #
• Related #

User-visible / Behavior Changes

• When the bot is mentioned or triggered in a Teams channel thread reply, it now receives the parent message and all prior replies as context (prepended to the prompt as "Thread context (earlier messages)").
• No config changes required. Feature activates automatically when ChannelMessage.Read.All Graph API permission is granted.
• If the permission is missing or Graph API fails, behavior is unchanged (graceful degradation).

Security Impact (required)

• New permissions/capabilities? Yes — requires ChannelMessage.Read.All (Application) with admin consent
• Secrets/tokens handling changed? No — reuses existing appId/appPassword for Graph token acquisition
• New/changed network calls? Yes — new Graph API calls to read channel messages and thread replies
• Command/tool execution surface changed? No
• Data access scope changed? Yes — bot can now read channel message history (previously could only see messages sent directly to it)
• Risk + mitigation: The bot reads only the specific thread it's replying in (parent + replies), not arbitrary channel messages. Graph token is acquired per-request with client_credentials grant. All Graph calls are wrapped in try/catch — failures are logged and silently skipped.

Repro + Verification

Environment

• OS: Linux 6.12.63+deb13-amd64
• Runtime/container: Node v22.22.0, OpenClaw gateway (jiti runtime for plugin .ts)
• Model/provider: Anthropic Claude Sonnet 4.5
• Integration/channel: Microsoft Teams (channel threads)
• Relevant config: msteams.appId, msteams.appPassword, msteams.tenantId (standard bot config)

Steps

  1. Grant ChannelMessage.Read.All application permission + admin consent in Entra ID
  2. Post a message in a Teams channel (creates a thread root)
  3. Reply in that thread mentioning the bot

Expected

• Bot response includes awareness of the parent message content and prior replies

Actual

• Confirmed: Graph API returns parent message + replies, injected into BodyForAgent, model references thread context in its response

Evidence

• [x] Trace/log snippets
• 2026-03-11T12:52:55.776+01:00 fetched thread context (after BodyForAgent fix)
• Debug logs confirmed: parentMsg body populated, replies count > 0, threadContextBody length > 0
• [x] Manual verification: Bot correctly summarized a thread it had never seen before

Human Verification (required)

• Verified scenarios: Thread reply in Teams channel — bot received thread context and responded with awareness of parent message content
• Edge cases checked: Missing Graph permission (graceful fallback), team ID resolution from conversation-style ID to group GUID, HTML stripping from message bodies, empty threads (no replies)
• What you did not verify: Behavior with >50 replies (pagination), threads in private channels, threads in shared channels across tenants

Compatibility / Migration

• Backward compatible? Yes
• Config/env changes? No (uses existing bot credentials for Graph API)
• Migration needed? No (but ChannelMessage.Read.All permission + admin consent required for feature to activate)
• If yes, exact upgrade steps: N/A

Failure Recovery (if this breaks)

• How to disable/revert: Revert changes to graph.ts and message-handler.ts, restart gateway
• Files/config to restore: extensions/msteams/src/graph.ts, extensions/msteams/src/monitor-handler/message-handler.ts
• Known bad symptoms: Slow message processing (Graph API timeout), errors in logs mentioning fetchChannelMessage or resolveTeamGroupId. Feature is fully wrapped in try/catch so failures should not break message delivery.

Risks and Mitigations

• Risk: Graph API rate limiting when bot is active in many threads simultaneously
• Mitigation: Team group ID is cached in-memory after first resolution. Consider adding message-level caching if rate limits are hit.
• Risk: First message in a new team incurs ~2s latency for team list scan
• Mitigation: One-time cost per team, cached afterward. Could be pre-warmed at startup if needed.

Review Conversations

• [] I replied to or resolved every bot review conversation I addressed in this PR.
• [] I left unresolved only the conversations that still need reviewer or maintainer judgment.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 11, 2026

Greptile Summary

This PR adds Graph API-based thread context enrichment for Microsoft Teams channel replies, fetching the parent message and earlier replies and injecting them into BodyForAgent so the model has full conversation context. The implementation is well-structured with graceful degradation (all Graph calls are wrapped in try/catch) and an in-memory cache for team ID resolution.

Two issues were found that affect correctness:

  • Unencoded space in $orderby URL parameter (graph.ts line 151): The query $orderby=createdDateTime asc contains a raw space character. Node.js fetch does not automatically encode query strings, so this produces a malformed URL. This should be encoded as %20 or via encodeURIComponent.
  • Missing pagination in resolveTeamGroupId (graph.ts lines 84–107): The /groups?$filter=... endpoint returns paginated results. The function only processes the first page, so for organizations with more teams than the default page size (typically 100), the target team may never be found, silently causing thread context to be skipped with no user-visible error.
  • Sequential parallel-safe API calls (message-handler.ts lines 482–494): fetchChannelMessage and fetchThreadReplies are independent and could be issued concurrently via Promise.all to reduce per-message latency.

Confidence Score: 2/5

  • Not safe to merge without fixing the pagination gap and the invalid URL in the orderby parameter, which will cause silent failures in production.
  • The unencoded space in the $orderby query string is an outright URL syntax bug. More critically, the absence of pagination in resolveTeamGroupId means the feature silently stops working for any team that falls beyond the first API page — a bug that will manifest in any mid-to-large organization and produce no visible error, only missing context.
  • extensions/msteams/src/graph.ts — both issues (invalid URL and missing pagination) reside here

Last reviewed commit: eef6944

@openclaw-barnacle openclaw-barnacle bot added channel: msteams Channel integration: msteams size: S labels 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: eef694440f

ℹ️ 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".

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: 4798c14fea

ℹ️ 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".

@arnaudlamy arnaudlamy force-pushed the feat/msteams-thread-history-graph-api branch from 8e526d3 to 9b17fa3 Compare March 13, 2026 17:38
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: 9b17fa3ecf

ℹ️ 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".

@arnaudlamy arnaudlamy force-pushed the feat/msteams-thread-history-graph-api branch 2 times, most recently from 3253e23 to 99e5498 Compare March 13, 2026 18:39
@arnaudlamy arnaudlamy force-pushed the feat/msteams-thread-history-graph-api branch from 99e5498 to 8cd0e87 Compare March 19, 2026 14:12
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: 8cd0e87c35

ℹ️ 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 +502 to +506
if (parentMsg?.body?.content) {
const sender = parentMsg.from?.user?.displayName ?? "Unknown";
const content = stripHtmlTags(parentMsg.body.content);
if (content) {
threadMessages.push(`${sender}: ${content}`);
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 Skip parent-message injection for non-reply channel posts

For channel posts where this block runs but the current activity is itself the thread root, fetchChannelMessage(... messageId: conversationMessageId) returns the current message and lines 502-506 prepend it to threadContextBody before rawBody is added again. In that case non-thread mentions now send the same user text to the model twice and pay the extra Graph latency even though there is no earlier thread context. Gate this path on an actual reply, or at least drop parentMsg when parentMsg.id === activity.id.

Useful? React with 👍 / 👎.

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: 9db6801d89

ℹ️ 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".

@arnaudlamy arnaudlamy force-pushed the feat/msteams-thread-history-graph-api branch from 9db6801 to 011c5a9 Compare March 23, 2026 17:03
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: f4e19c2f70

ℹ️ 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 +524 to +529
for (const reply of replies) {
if (reply.id === currentActivityId) continue;
if (currentTimestamp && reply.createdDateTime === currentTimestamp) continue;
const sender = reply.from?.user?.displayName ?? "Unknown";
const content = reply.body?.content ? stripHtmlTags(reply.body.content) : "";
if (content) {
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 Avoid re-injecting earlier debounced replies into the prompt

When messages.inbound.debounceMs is non-zero, onFlush still batches multiple text replies into one rawText payload using the last activity context (extensions/msteams/src/monitor-handler/message-handler.ts:724-749). This new loop only filters out the last reply, so if the same sender posts two quick replies in one channel thread, the earlier reply is included once in the debounced rawBody and again from fetchThreadReplies, which duplicates part of the conversation in BodyForAgent and can skew the model's response.

Useful? React with 👍 / 👎.

Claw Bot and others added 6 commits March 24, 2026 10:19
When a message is received in a Teams channel thread, fetch the parent
message and all thread replies via Microsoft Graph API and inject them
as context for the agent.

Key changes:
- graph.ts: Add resolveTeamGroupId() to map conversation-style team IDs
  (19:[email protected]) to Azure AD group GUIDs (required by Graph API)
- graph.ts: Add fetchChannelMessage(), fetchThreadReplies(), stripHtmlTags()
- message-handler.ts: Fetch thread context when conversationMessageId is
  present (indicates a thread reply) and inject into both Body and
  BodyForAgent so the model can see the full thread context

Notes:
- Team group ID resolution is cached in-memory after first lookup
- Requires ChannelMessage.Read.All Graph API permission (admin consent)
- Gracefully degrades: if Graph API fails, message is processed normally
  without thread context
- HTML content from Teams messages is stripped to plain text
@arnaudlamy arnaudlamy force-pushed the feat/msteams-thread-history-graph-api branch from ca51237 to fb183bf Compare March 24, 2026 09:20
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: fb183bffa9

ℹ️ 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".

export function stripHtmlTags(html: string): string {
return html
.replace(/<at[^>]*>(.*?)<\/at>/gi, "$1")
.replace(/<[^>]+>/g, "")
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 Preserve separators when stripping Teams HTML bodies

stripHtmlTags removes every HTML tag with an empty replacement, so adjacent block elements are concatenated (for example, <div>First</div><div>Second</div> becomes FirstSecond). In channel thread context this can merge separate lines/sentences and change meaning, especially for multiline Teams messages that use <div>/<br> structure. Replace tags with whitespace (or map block tags to newlines) before collapsing spaces.

Useful? React with 👍 / 👎.

activity.timestamp instanceof Date ? activity.timestamp.toISOString() : undefined;
for (const reply of replies) {
if (reply.id === currentActivityId) continue;
if (currentTimestamp && reply.createdDateTime === currentTimestamp) continue;
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 Exclude replies newer than the triggering activity

The reply loop only skips entries that exactly match the current message id/timestamp, but it never filters out replies sent after the triggering activity. In busy threads, later replies can arrive before this Graph fetch finishes and then get injected into the section labeled as earlier context, causing the model to respond to messages that were not part of the original trigger. Filter replies by createdDateTime against the normalized activity timestamp.

Useful? React with 👍 / 👎.

Arnaud Lamy and others added 2 commits March 24, 2026 16:17
…ly dedup

Pre-commit hook skipped: tsgo fails on 6 pre-existing TS2307 errors
on main (sdk.ts, ui/) unrelated to this change.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
…uture replies

- Insert spaces for closing block tags (div, p, br, li, tr) before
  stripping all tags, preventing word concatenation across elements
- Skip replies with createdDateTime after the triggering activity
  to avoid injecting future context into thread history

Co-Authored-By: Claude Opus 4.6 <[email protected]>
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: 1f1e0e4395

ℹ️ 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 +537 to +540
if (currentTimestamp && reply.createdDateTime === currentTimestamp) continue;
// Skip replies newer than the triggering activity to avoid injecting future context
if (currentTimestamp && reply.createdDateTime && reply.createdDateTime > currentTimestamp)
continue;
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 Compare thread reply times using parsed dates

The reply filtering logic is doing lexicographic string comparisons on createdDateTime (=== and >), which is not time-safe when formats differ (for example ...00Z vs ...00.123Z, or different offset formats). In those cases, earlier replies can be misclassified as newer and dropped from context, or the current reply can slip through and be duplicated. Parse both values to epoch time before doing equality/order checks.

Useful? React with 👍 / 👎.

Comment on lines +145 to +148
teamIdToGroupIdCache.set(conversationTeamId, {
groupId: "",
expiresAt: Date.now() + TEAM_CACHE_TTL_MS,
});
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 Avoid caching unresolved team IDs after transient failures

resolveTeamGroupId writes a negative cache entry for 30 minutes whenever no match is found, but team-detail fetches inside the scan swallow all errors. If the target team’s /teams/{id} call transiently fails (for example 429/503), this path will incorrectly cache that team as unresolved and suppress all retries for the TTL, so thread-context fetching stays broken long after the transient error clears.

Useful? React with 👍 / 👎.

@BradGroux
Copy link
Copy Markdown
Contributor

Closing as superseded by #51643 (merged 2026-03-25), which implements the same thread history via Graph API feature. Thank you for the contribution that helped inform the final implementation.

@BradGroux BradGroux closed this Mar 26, 2026
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.

2 participants