@@ -2,7 +2,6 @@ import type { OpenClawConfig } from "../runtime-api.js";
22import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js" ;
33import { stripHtmlFromTeamsMessage } from "./graph-thread.js" ;
44import {
5- type GraphResponse ,
65 deleteGraphRequest ,
76 escapeOData ,
87 fetchGraphAbsoluteUrl ,
@@ -39,6 +38,11 @@ type GraphPinnedMessagesResponse = {
3938 "@odata.nextLink" ?: string ;
4039} ;
4140
41+ type GraphPagedMessagesResponse = {
42+ value ?: GraphMessage [ ] ;
43+ "@odata.nextLink" ?: string ;
44+ } ;
45+
4246/**
4347 * Resolve the Graph API path prefix for a conversation.
4448 * If `to` contains "/" it's a `teamId/channelId` (channel path),
@@ -486,6 +490,8 @@ export type SearchMessagesMSTeamsResult = {
486490
487491const SEARCH_DEFAULT_LIMIT = 25 ;
488492const SEARCH_MAX_LIMIT = 50 ;
493+ /** Graph caps `$top` at 50 on chat/channel message endpoints. */
494+ const SEARCH_GRAPH_PAGE_SIZE = 50 ;
489495const SEARCH_LIST_WINDOW_MIN = 50 ;
490496const SEARCH_LIST_WINDOW_MAX = 200 ;
491497const SEARCH_LIST_WINDOW_MULTIPLIER = 10 ;
@@ -500,6 +506,10 @@ const SEARCH_LIST_WINDOW_MULTIPLIER = 10;
500506 * configuration at the cost of only searching the recent window; full-archive
501507 * search requires Delegated auth and is out of scope here.
502508 *
509+ * The list window is retrieved in pages of 50 (Graph's `$top` max for these
510+ * endpoints) via `@odata.nextLink` until the configured window size is reached
511+ * or Graph runs out of messages.
512+ *
503513 * Sender filtering still pushes down to Graph via `$filter` when provided,
504514 * since `$filter` is supported for app-only on this endpoint.
505515 */
@@ -526,25 +536,42 @@ export async function searchMessagesMSTeams(
526536
527537 // Build query string manually (not URLSearchParams) to preserve literal $
528538 // in OData parameter names, consistent with other Graph calls in this module.
529- const parts = [ `$top=${ listWindow } ` ] ;
539+ const parts = [ `$top=${ SEARCH_GRAPH_PAGE_SIZE } ` ] ;
530540 if ( params . from ) {
531541 parts . push (
532542 `$filter=${ encodeURIComponent ( `from/user/displayName eq '${ escapeOData ( params . from ) } '` ) } ` ,
533543 ) ;
534544 }
535545
536546 const path = `${ basePath } /messages?${ parts . join ( "&" ) } ` ;
537- const res = await fetchGraphJson < GraphResponse < GraphMessage > > ( { token, path } ) ;
547+ const maxPages = Math . ceil ( listWindow / SEARCH_GRAPH_PAGE_SIZE ) ;
548+ const fetched : GraphMessage [ ] = [ ] ;
549+ let page = await fetchGraphJson < GraphPagedMessagesResponse > ( { token, path } ) ;
550+ for ( let i = 1 ; i <= maxPages ; i ++ ) {
551+ for ( const msg of page . value ?? [ ] ) {
552+ fetched . push ( msg ) ;
553+ if ( fetched . length >= listWindow ) {
554+ break ;
555+ }
556+ }
557+ const nextLink = page [ "@odata.nextLink" ] ;
558+ if ( ! nextLink || fetched . length >= listWindow || i >= maxPages ) {
559+ break ;
560+ }
561+ page = await fetchGraphAbsoluteUrl < GraphPagedMessagesResponse > ( { token, url : nextLink } ) ;
562+ }
538563
564+ // Note: Graph's `contentType` is "html" or "text". The production docs list
565+ // "text" as the default, but real responses often omit the field. Treat any
566+ // non-"text" body as HTML so queries always match rendered text rather than
567+ // raw markup (e.g. "bold" must not match "<b>old"). This intentionally
568+ // differs from `stripHtmlFromTeamsMessage`'s caller in graph-thread.ts,
569+ // which is display-facing and prefers the documented default.
539570 const matches =
540571 needle . length === 0
541- ? ( res . value ?? [ ] )
542- : ( res . value ?? [ ] ) . filter ( ( msg ) => {
572+ ? fetched
573+ : fetched . filter ( ( msg ) => {
543574 const raw = msg . body ?. content ?? "" ;
544- // Teams bodies default to HTML — strip tags so queries match the
545- // rendered text rather than raw markup (e.g. "bold" would otherwise
546- // match "<b>old"). Only skip stripping for explicit plain-text bodies;
547- // missing contentType falls through to the HTML-safe path.
548575 const text = msg . body ?. contentType === "text" ? raw : stripHtmlFromTeamsMessage ( raw ) ;
549576 return text . toLowerCase ( ) . includes ( needle ) ;
550577 } ) ;
0 commit comments