Skip to content

fix(msteams): drop unsupported $search on msteams:search (AI-assisted)#70287

Open
gaurav0107 wants to merge 4 commits intoopenclaw:mainfrom
gaurav0107:fix/msteams-search-app-permissions
Open

fix(msteams): drop unsupported $search on msteams:search (AI-assisted)#70287
gaurav0107 wants to merge 4 commits intoopenclaw:mainfrom
gaurav0107:fix/msteams-search-app-permissions

Conversation

@gaurav0107
Copy link
Copy Markdown

Summary

  • Problem: msteams:search action hits HTTP 400: Search is not supported because Graph API blocks $search on /chats/{id}/messages and /teams/*/channels/*/messages when called with Application permissions (the default for bot auth).
  • Why it matters: The action is unusable for any bot running on app-only credentials — which is the most common msteams deployment.
  • What changed: Replace $search with a list-and-local-filter path that works under Application permissions. Sender filtering still pushes down via $filter (which Graph supports app-only). Local filter strips HTML bodies so queries match rendered text.
  • What did NOT change (scope boundary): Delegated-auth code paths, other msteams actions, Graph token resolution, or /search/query callers elsewhere in the repo. Search range is now bounded to the recent list window rather than the full archive — full-archive search requires Delegated auth and is out of scope for this fix.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

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

Linked Issue/PR

Root Cause

  • Root cause: The original implementation of searchMessagesMSTeams used $search in the Graph query string. Graph API documentation is explicit that $search on message collections is not available for Application permissions — only /search/query with Delegated auth supports it for chat messages, and even that blocks the chatMessage entity for app-only.
  • Missing detection / guardrail: No integration test hit a Graph-like 400 for $search. Unit tests mocked successful responses, so the permission mismatch never surfaced in CI.
  • Contributing context: The original PR (msteams: add search message action #54832, merged) assumed $search worked because it does on some other Graph collections under app-only.

Regression Test Plan

  • Coverage level that should have caught this:
    • Unit test
  • Target test or file: extensions/msteams/src/graph-messages.search.test.ts
  • Scenario the test should lock in: The outgoing path must never contain $search, regardless of query value. New test does not use Graph $search (unsupported under Application permissions) asserts this directly.
  • Why this is the smallest reliable guardrail: The failure mode is a URL-shape contract with Graph. Locking the path-building logic at the unit seam catches any regression to $search without needing a live Graph call.
  • Existing test that already covers this (if any): None — prior tests only verified happy-path response parsing.

User-visible / Behavior Changes

  • msteams:search now works on app-only (bot) credentials instead of returning Graph 400.
  • Search range is now the most recent N messages (clamped 50–200 depending on requested limit) rather than the full conversation archive. This is a reduction in coverage for the (previously broken) Delegated-auth case, but the action was not functional under app-only at all before.
  • Local filter treats message bodies as HTML by default (stripping tags before matching), and only skips the strip for contentType: "text".

Diagram

Before (broken for app-only):
search(query="hi") -> GET /chats/{id}/messages?$search="hi"  -> HTTP 400: Search not supported

After:
search(query="hi") -> GET /chats/{id}/messages?$top=50 (+ optional $filter)
                   -> stripHtml(body) when contentType=html
                   -> local includes("hi") -> return up to limit

Security Impact (required)

  • New permissions/capabilities? No — uses the same Graph scopes already required for the action.
  • Secrets/tokens handling changed? No
  • New/changed network calls? No new endpoints. Same /chats/{id}/messages and /teams/*/channels/*/messages endpoints, different query string.
  • Command/tool execution surface changed? No
  • Data access scope changed? No — identical data returned, just filtered locally instead of server-side.

Repro + Verification

Environment

  • OS: macOS 24.5.0 (Darwin)
  • Runtime/container: Node v25.9.0, pnpm
  • Model/provider: N/A (API-path fix, no model interaction)
  • Integration/channel: msteams bundled plugin, Application permissions (app-only bot token)
  • Relevant config: N/A

Steps

  1. Configure an msteams bot with app-only (Application-permission) credentials.
  2. Send messages to a chat where the bot is a member.
  3. Invoke the msteams:search action against that chat with any query.

Expected

  • Matching messages returned.

Actual (before this PR)

  • Graph returned HTTP 400: Search is not supported on this organization regardless of query.

Actual (after this PR)

  • Matching messages returned. Reproduced via unit tests covering $search removal, HTML stripping, $filter pushdown for sender, pagination window sizing, and user: target resolution.

Evidence

  • Failing test/log before + passing after: extensions/msteams/src/graph-messages.search.test.ts now has 17 assertions locking the new contract; one specifically asserts the path never contains $search.
  • Targeted run: pnpm test extensions/msteams/src/graph-messages.search.test.ts → 17/17 passing.
  • Full extension lane: pnpm test extensions/msteams → 874/874 passing (68 files).
  • Typecheck: pnpm tsgo:prod and pnpm check:test-types both green.
  • Lint: pnpm lint:extensions → 0 warnings, 0 errors.
  • Format: pnpm format:check extensions/msteams/src/graph-messages*.ts → clean.

Human Verification (required)

  • Verified scenarios:
    • $search is absent from the outgoing query string (asserted by unit test)
    • Case-insensitive local filtering works
    • HTML-tagged bodies are stripped before matching (query <b> does not match <b>world</b>)
    • @mention display names are preserved for matching
    • contentType: "text" bodies pass through unstripped
    • user:<aadId> targets resolve through the conversation store
    • Sender $filter escaping handles single quotes (e.g., O'BrienO''Brien)
  • Edge cases checked:
    • Empty query (after quote strip) returns the full list window
    • Missing contentType defaults to HTML-safe (strip) rather than raw
    • Channel target (teamId/channelId) routes through channel path
    • Result cap honors the effective limit after local filtering
  • What I did NOT verify: Live Graph API round-trip against a real tenant (no test Graph tenant available). The Graph-behavior claim ($search blocked under Application permissions) matches Microsoft's documented constraint referenced by the issue reporter and the Microsoft Graph known issue.

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.

Compatibility / Migration

  • Backward compatible? Yes — same action shape, same result type, same caller contract.
  • Config/env changes? No
  • Migration needed? No

Risks and Mitigations

  • Risk: Search now only covers the most recent 50–200 messages instead of the full archive.
    • Mitigation: Documented in the JSDoc on searchMessagesMSTeams. Full-archive search requires Delegated auth and is a separate follow-up. The prior behavior was fully broken under app-only, so this is net-positive.
  • Risk: HTML-strip default broadens — any non-text contentType now goes through stripHtmlFromTeamsMessage.
    • Mitigation: The strip function is regex-based and a no-op on non-tagged content. A dedicated unit test asserts contentType: "text" bodies are NOT stripped.

AI/Vibe-Coded PR Disclosure

  • Marked as AI-assisted — built with Claude Code.
  • Degree of testing: Fully tested locally (unit + typecheck + lint + format) but not verified against a live Graph tenant.
  • Confirm I understand what the code does: Yes.
  • Bot review conversations will be addressed or replied to.

CC msteams maintainers per CONTRIBUTING: @onutc @osolmaz

@openclaw-barnacle openclaw-barnacle Bot added channel: msteams Channel integration: msteams size: M labels Apr 22, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 22, 2026

Greptile Summary

Replaces the unsupported $search OData parameter on /chats/{id}/messages and /teams/*/channels/*/messages with a list-then-local-filter strategy. The fix fetches a windowed batch of recent messages (50–200 via $top), strips HTML tags with the existing stripHtmlFromTeamsMessage helper before case-insensitive substring matching, and still pushes sender filtering down to Graph via $filter. The approach is sound and well-tested; the only remaining notes are pre-existing or cosmetic (raw HTML in returned text, minor contentType-default divergence from graph-thread.ts).

Confidence Score: 5/5

Safe to merge — the fix is targeted, all 17 new unit tests pass, and no P0/P1 issues were found.

All remaining findings are P2: the raw-HTML-in-text field is pre-existing behavior, and the contentType-default divergence is intentional and documented. Neither blocks the primary fix.

No files require special attention.

Comments Outside Diff (1)

  1. extensions/msteams/src/graph-messages.ts, line 552-557 (link)

    P2 Raw HTML returned in text while filtering on stripped text

    The local filter correctly strips HTML before matching, but the text field in the returned messages still carries msg.body?.content — the raw, tag-full HTML. A caller who searches for "hello world" and renders the result will see <p>Hello <b>world</b></p> rather than the plain text the query matched against. This was the same before the PR, but the new local-filter path makes the mismatch observable in more places (e.g. when the matched substring only exists in stripped form). Consider returning the stripped text, or at minimum documenting that text is the raw Graph body.content.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: extensions/msteams/src/graph-messages.ts
    Line: 552-557
    
    Comment:
    **Raw HTML returned in `text` while filtering on stripped text**
    
    The local filter correctly strips HTML before matching, but the `text` field in the returned messages still carries `msg.body?.content` — the raw, tag-full HTML. A caller who searches for `"hello world"` and renders the result will see `<p>Hello <b>world</b></p>` rather than the plain text the query matched against. This was the same before the PR, but the new local-filter path makes the mismatch observable in more places (e.g. when the matched substring only exists in stripped form). Consider returning the stripped text, or at minimum documenting that `text` is the raw Graph `body.content`.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/msteams/src/graph-messages.ts
Line: 552-557

Comment:
**Raw HTML returned in `text` while filtering on stripped text**

The local filter correctly strips HTML before matching, but the `text` field in the returned messages still carries `msg.body?.content` — the raw, tag-full HTML. A caller who searches for `"hello world"` and renders the result will see `<p>Hello <b>world</b></p>` rather than the plain text the query matched against. This was the same before the PR, but the new local-filter path makes the mismatch observable in more places (e.g. when the matched substring only exists in stripped form). Consider returning the stripped text, or at minimum documenting that `text` is the raw Graph `body.content`.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/msteams/src/graph-messages.ts
Line: 542-550

Comment:
**Default `contentType` handling differs from `graph-thread.ts`**

`graph-thread.ts` (line 133) defaults a missing `contentType` to `"text"` (no stripping), but here a missing `contentType` falls through to the HTML-safe strip path because the check is `=== "text"`. The two helpers therefore treat the same message differently. The PR notes this divergence is intentional, but it may surprise readers who see `"text"` as the documented default in Graph API documentation. A brief inline comment (or extracting the logic to a shared helper) would make the intent explicit and prevent future callers from accidentally re-aligning with the `graph-thread` default.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(msteams): default unknown message co..." | Re-trigger Greptile

Comment on lines +542 to +550
: (res.value ?? []).filter((msg) => {
const raw = msg.body?.content ?? "";
// Teams bodies default to HTML — strip tags so queries match the
// rendered text rather than raw markup (e.g. "bold" would otherwise
// match "<b>old"). Only skip stripping for explicit plain-text bodies;
// missing contentType falls through to the HTML-safe path.
const text = msg.body?.contentType === "text" ? raw : stripHtmlFromTeamsMessage(raw);
return text.toLowerCase().includes(needle);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Default contentType handling differs from graph-thread.ts

graph-thread.ts (line 133) defaults a missing contentType to "text" (no stripping), but here a missing contentType falls through to the HTML-safe strip path because the check is === "text". The two helpers therefore treat the same message differently. The PR notes this divergence is intentional, but it may surprise readers who see "text" as the documented default in Graph API documentation. A brief inline comment (or extracting the logic to a shared helper) would make the intent explicit and prevent future callers from accidentally re-aligning with the graph-thread default.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/msteams/src/graph-messages.ts
Line: 542-550

Comment:
**Default `contentType` handling differs from `graph-thread.ts`**

`graph-thread.ts` (line 133) defaults a missing `contentType` to `"text"` (no stripping), but here a missing `contentType` falls through to the HTML-safe strip path because the check is `=== "text"`. The two helpers therefore treat the same message differently. The PR notes this divergence is intentional, but it may surprise readers who see `"text"` as the documented default in Graph API documentation. A brief inline comment (or extracting the logic to a shared helper) would make the intent explicit and prevent future callers from accidentally re-aligning with the `graph-thread` default.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in 3c2d5a5589 by expanding the inline comment above the filter predicate to explicitly call out the divergence from graph-thread.ts and the rationale. The two helpers serve different audiences: graph-thread.ts is display-facing and prefers the Graph-documented "text" default, while search needs to be safe against raw markup leaking through missing contentType, so it defaults to HTML-strip. Extracting to a shared helper would widen the seam for a two-line branch — the comment makes the intent explicit without the extra surface.

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: 1057674184

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


// Build query string manually (not URLSearchParams) to preserve literal $
// in OData parameter names, consistent with other Graph calls in this module.
const parts = [`$search=${encodeURIComponent(`"${sanitizedQuery}"`)}`];
parts.push(`$top=${top}`);
const parts = [`$top=${listWindow}`];
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 Cap list window so Graph $top never exceeds 50

searchMessagesMSTeams can issue GET .../messages?$top=200 (for example, the default limit of 25 computes listWindow=200), but Microsoft Graph documents a maximum $top of 50 for both chat and channel message-list endpoints. In environments where Graph enforces that limit, this turns normal searches into 400 responses; even if tolerated, the intended 200-message local-filter window is never actually retrieved, so recall is lower than this implementation assumes. Please clamp the requested $top to 50 and use paging if a wider search window is required.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch — fixed in 3c2d5a5589. The outgoing $top is now hard-capped at 50 (Graph's documented max for chat/channel message collections), and a pagination loop follows @odata.nextLink until the configured list window is filled or Graph runs out of messages. New tests assert $top=50 is always on the outgoing URL, $top=200 never appears, and the nextLink is followed when one page doesn't fill the window (and NOT followed once it does).

gaurav0107 and others added 4 commits April 22, 2026 23:32
)

Graph rejects `$search` on `/chats/{id}/messages` and
`/teams/{id}/channels/{id}/messages` under Application permissions with
"Query option 'Search' is not allowed", so every `msteams:search` call from an
app-only bot returns 400. The alternative `/search/query` endpoint also blocks
Application permissions for `chatMessage`, leaving no app-only-compatible
server-side search path.

Switch `searchMessagesMSTeams` to a list-and-local-filter implementation:
fetch up to a capped recent window (clamp(limit * 10, 50, 200)) via
`/messages?$top=...`, keep the `$filter` pushdown for sender (still supported
app-only), and apply a case-insensitive `includes` against `body.content`
locally before slicing to the requested limit. Drop the `ConsistencyLevel`
header since it was only required for `$search`. Document the trade-off in
the function JSDoc: full-archive search would require Delegated auth and is
out of scope here.

Update `graph-messages.search.test.ts` to lock in the new behavior — that
`$search` is never emitted, that the server-side `$top` grows past the
caller's limit to allow local filtering, and that returned messages are
filtered and capped locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gaurav0107 gaurav0107 force-pushed the fix/msteams-search-app-permissions branch from 3c2d5a5 to 98d4731 Compare April 22, 2026 18:04
@gaurav0107
Copy link
Copy Markdown
Author

Failing CI checks are pre-existing on upstream/main

Four failing checks on this PR (check-additional, check-additional-boundaries, checks-node-core, checks-node-core-runtime-infra) are unrelated to this change — they fail on upstream/main@5ad06d0b20 itself.

Evidence — same-commit check-runs on upstream head:

  • check-additional-boundaries on upstream/main@5ad06d0b20: fails — flagged file is extensions/slack/src/outbound-delivery.test.ts.
  • checks-node-core-runtime-infra on upstream/main@5ad06d0b20: fails — flagged test is src/hooks/message-hook-mappers.test.ts > maps canonical inbound context to plugin/internal received payloads.

Scoped proof this PR doesn't touch either area:

$ git diff upstream/main HEAD -- extensions/slack/ src/hooks/
# (empty — 0 bytes)

The three commits on this branch only touch CHANGELOG.md, extensions/msteams/src/graph-messages.ts, and extensions/msteams/src/graph-messages.search.test.ts.

Passing checks that do cover this change:

  • check-prod-types / check-test-types: pass
  • check-lint: pass
  • checks-node-extensions-shard-* (1–6): all pass — the msteams tests live in these shards
  • check-additional-extension-bundled, check-additional-extension-package-boundary: pass — the extension boundary rules this PR honors

Locally pnpm test extensions/msteams reports 875/875 passing.

Happy to rebase again once main goes green; otherwise marking this as noise.

@clawsweeper
Copy link
Copy Markdown
Contributor

clawsweeper Bot commented Apr 29, 2026

Codex review: needs changes before merge.

What this changes:

The branch updates MS Teams message search to fetch a bounded recent chat/channel window with $top=50 pagination, locally match stripped message text, expand regression tests, and add a changelog entry for #70127.

Required change before merge:

The blockers are narrow and mechanical on a maintainer-editable contributor PR: remove the unsupported sender $filter, add local sender filtering/tests, and fix the changelog attribution.

Security review:

Security review cleared: The diff changes existing MS Teams Graph query construction, regression tests, and changelog only; it adds no dependencies, CI execution, secrets handling, permissions, or package/runtime execution surface.

Review findings:

  • [P2] Filter senders locally after listing messages — extensions/msteams/src/graph-messages.ts:540-543
  • [P3] Credit the changelog entry — CHANGELOG.md:25
Review details

Best possible solution:

Land a corrected version that uses only app-only-supported chat/channel message-list parameters, applies both text and sender matching locally over the bounded recent window, keeps the no-$search regression tests focused on URL shape and local filters, and credits the reporter/contributor in the changelog.

Do we have a high-confidence way to reproduce the issue?

Yes. The original bug is reproducible at the URL-construction seam on current main because the helper and tests still build $search; the remaining PR defect is reproducible by passing from, which still adds a sender $filter to the replacement list request.

Is this the best way to solve the issue?

No, not yet. Removing $search and paging recent messages is the right direction, but sender filtering should happen locally after listing rather than through Graph, and the changelog needs attribution before merge.

Full review comments:

  • [P2] Filter senders locally after listing messages — extensions/msteams/src/graph-messages.ts:540-543
    The replacement path still appends $filter=from/user/displayName ... whenever from is set. Microsoft chat-message lists only support date-range filters with matching ordering, and channel-message lists do not support $filter, so sender-filtered searches can still 400 on the app-only path this PR is meant to fix. Fetch the bounded recent window with $top/paging only, then apply sender matching locally.
    Confidence: 0.91
  • [P3] Credit the changelog entry — CHANGELOG.md:25
    The added user-facing MS Teams fix bullet lacks the required Thanks @... attribution. Please add the credited reporter/contributor username(s) so the changelog follows repo policy before this lands.
    Confidence: 0.96

Overall correctness: patch is incorrect
Overall confidence: 0.9

Acceptance criteria:

  • pnpm test extensions/msteams/src/graph-messages.search.test.ts
  • pnpm test extensions/msteams
  • pnpm exec oxfmt --check --threads=1 extensions/msteams/src/graph-messages.ts extensions/msteams/src/graph-messages.search.test.ts CHANGELOG.md

What I checked:

  • Current main still emits Graph $search: searchMessagesMSTeams on current main builds /messages?$search=..., appends $top, optionally appends sender $filter, and sends ConsistencyLevel: eventual. (extensions/msteams/src/graph-messages.ts:510, a256745323ab)
  • Current tests lock the old URL contract: The current unit test expects $search=, $top=25, and decoded $search="meeting notes", so current main has not absorbed the PR behavior. (extensions/msteams/src/graph-messages.search.test.ts:49, a256745323ab)
  • Search action reaches this helper: The MS Teams action handler routes search through loadMSTeamsChannelRuntime().searchMessagesMSTeams, making this helper the observable msteams:search path. (extensions/msteams/src/channel.ts:891, a256745323ab)
  • PR still pushes down sender filtering: The PR head removes $search, adds pagination/local matching, but still appends $filter=from/user/displayName ... when params.from is present. (extensions/msteams/src/graph-messages.ts:540, 98d473152f1f)
  • Graph message-list query contract conflicts with sender filter: Microsoft Graph chat message listing supports $top up to 50 and only date-range $filter with matching $orderby; channel message listing supports $top and $expand, with other OData parameters unsupported. citeturn1view0turn2search0.
  • Changelog attribution is missing: The added user-facing MS Teams fix entry has no Thanks @... attribution, which repo policy requires for changelog additions unless only forbidden bot/maintainer handles are available. (CHANGELOG.md:25, 98d473152f1f)

Likely related people:

  • sudie-codes: Authored the merged MS Teams Graph message action series and the commits that added searchMessagesMSTeams plus its $search URL construction in msteams: add search message action #54832. (role: introduced behavior; confidence: high; commits: e6a200b54e3f, 6160c7159046; files: extensions/msteams/src/graph-messages.ts, extensions/msteams/src/graph-messages.search.test.ts)
  • BradGroux: Approved and merged msteams: add search message action #54832 after reviewing the Graph message actions, and is also maintaining a Microsoft ecosystem tracker that includes this PR. (role: reviewer/merger; confidence: medium; commits: 6329edfb8d12; files: extensions/msteams/src/graph-messages.ts, extensions/msteams/src/channel.ts)
  • onutc: CONTRIBUTING lists Onur Solmaz for MS Teams ownership, and this PR explicitly CCs the MS Teams maintainers with @onutc and @osolmaz. (role: documented domain owner; confidence: medium; files: CONTRIBUTING.md, extensions/msteams/src/graph-messages.ts)
  • osolmaz: CONTRIBUTING lists the same MS Teams ownership under this handle, and the PR timeline shows this handle was mentioned/subscribed for MS Teams maintainer review. (role: documented domain owner; confidence: medium; files: CONTRIBUTING.md, extensions/msteams/src/graph-messages.ts)

Remaining risk / open question:

  • Sender-filtered searches can still fail on the app-only Graph path until sender filtering is moved local to the fetched message window.
  • The branch will need targeted MS Teams tests and normal CI after the repair; this read-only review did not run tests or a live tenant call.

Codex review notes: model gpt-5.5, reasoning high; reviewed against a256745323ab.

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.

[Bug]: msteams search action fails — $search not supported on /chats/messages with Application permissions

1 participant