Skip to content

Commit 98d4731

Browse files
committed
fix(msteams): cap $top at 50 and paginate to fill list window (#70127)
1 parent 3257cb3 commit 98d4731

2 files changed

Lines changed: 83 additions & 18 deletions

File tree

extensions/msteams/src/graph-messages.search.test.ts

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ describe("searchMessagesMSTeams", () => {
115115
expect(call.headers).toBeUndefined();
116116
});
117117

118-
it("requests a wider list window than the limit to allow local filtering", async () => {
118+
it("requests Graph's max page size ($top=50) regardless of limit", async () => {
119119
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
120120

121121
await searchMessagesMSTeams({
@@ -126,23 +126,61 @@ describe("searchMessagesMSTeams", () => {
126126
});
127127

128128
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
129-
// list window = clamp(limit*10, 50, 200) = 50 for limit=5
129+
// Graph caps $top at 50 on chat/channel message endpoints; wider windows
130+
// must come from @odata.nextLink pagination, not a larger $top.
130131
expect(calledPath).toContain("$top=50");
132+
expect(calledPath).not.toContain("$top=200");
131133
});
132134

133-
it("caps the list window at 200 even for the max limit", async () => {
134-
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
135+
it("pages through @odata.nextLink until the list window is reached", async () => {
136+
const page1Value = Array.from({ length: 50 }, (_, i) => ({
137+
id: `msg-${i}`,
138+
body: { content: `no match ${i}` },
139+
}));
140+
const page2Value = Array.from({ length: 50 }, (_, i) => ({
141+
id: `msg-${50 + i}`,
142+
body: { content: i === 5 ? "match needle here" : `no match ${50 + i}` },
143+
}));
144+
mockState.fetchGraphJson.mockResolvedValue({
145+
value: page1Value,
146+
"@odata.nextLink": "https://graph.microsoft.com/v1.0/chats/next-page",
147+
});
148+
mockState.fetchGraphAbsoluteUrl.mockResolvedValue({ value: page2Value });
149+
150+
const result = await searchMessagesMSTeams({
151+
cfg: {} as OpenClawConfig,
152+
to: CHAT_ID,
153+
query: "needle",
154+
limit: 50,
155+
});
156+
157+
expect(mockState.fetchGraphAbsoluteUrl).toHaveBeenCalledTimes(1);
158+
expect(mockState.fetchGraphAbsoluteUrl.mock.calls[0][0].url).toBe(
159+
"https://graph.microsoft.com/v1.0/chats/next-page",
160+
);
161+
expect(result.messages).toHaveLength(1);
162+
expect(result.messages[0].id).toBe("msg-55");
163+
});
164+
165+
it("stops paginating once the list window is filled", async () => {
166+
// With limit=5, list window = 50; page 1 already returns 50 items, so
167+
// we should NOT follow @odata.nextLink.
168+
mockState.fetchGraphJson.mockResolvedValue({
169+
value: Array.from({ length: 50 }, (_, i) => ({
170+
id: `msg-${i}`,
171+
body: { content: `ping ${i}` },
172+
})),
173+
"@odata.nextLink": "https://graph.microsoft.com/v1.0/chats/next-page",
174+
});
135175

136176
await searchMessagesMSTeams({
137177
cfg: {} as OpenClawConfig,
138178
to: CHAT_ID,
139-
query: "test",
140-
limit: 100,
179+
query: "ping",
180+
limit: 5,
141181
});
142182

143-
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
144-
// limit clamps to 50, list window = min(200, 50*10) = 200
145-
expect(calledPath).toContain("$top=200");
183+
expect(mockState.fetchGraphAbsoluteUrl).not.toHaveBeenCalled();
146184
});
147185

148186
it("caps returned matches at the effective limit after local filtering", async () => {

extensions/msteams/src/graph-messages.ts

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type { OpenClawConfig } from "../runtime-api.js";
22
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
33
import { stripHtmlFromTeamsMessage } from "./graph-thread.js";
44
import {
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

487491
const SEARCH_DEFAULT_LIMIT = 25;
488492
const SEARCH_MAX_LIMIT = 50;
493+
/** Graph caps `$top` at 50 on chat/channel message endpoints. */
494+
const SEARCH_GRAPH_PAGE_SIZE = 50;
489495
const SEARCH_LIST_WINDOW_MIN = 50;
490496
const SEARCH_LIST_WINDOW_MAX = 200;
491497
const 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

Comments
 (0)