Skip to content

Commit 0e0d02c

Browse files
author
Christof Salis
committed
Teams: batch Graph primary-channel fallback
1 parent c4fad06 commit 0e0d02c

2 files changed

Lines changed: 109 additions & 26 deletions

File tree

extensions/msteams/src/graph-thread.test.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
22

33
const mockState = vi.hoisted(() => ({
44
fetchGraphJson: vi.fn(),
5+
postGraphJson: vi.fn(),
56
}));
67

78
type GraphThreadModule = typeof import("./graph-thread.js");
@@ -10,6 +11,7 @@ async function loadGraphThreadModule(): Promise<GraphThreadModule> {
1011
vi.resetModules();
1112
vi.doMock("./graph.js", () => ({
1213
fetchGraphJson: mockState.fetchGraphJson,
14+
postGraphJson: mockState.postGraphJson,
1315
}));
1416
return await import("./graph-thread.js");
1517
}
@@ -99,20 +101,61 @@ describe("resolveTeamGroupId", () => {
99101
"@odata.nextLink":
100102
"https://graph.microsoft.com/v1.0/groups?$skiptoken=page-2&$select=id&$top=999",
101103
})
102-
.mockRejectedValueOnce(new Error("no match on page 1"))
103104
.mockResolvedValueOnce({
104105
value: [{ id: "group-guid-2" }],
106+
});
107+
mockState.postGraphJson
108+
.mockResolvedValueOnce({
109+
responses: [{ id: "0", status: 200, body: { id: "different-primary" } }],
105110
})
106-
.mockResolvedValueOnce({ id: "team-runtime-key" });
111+
.mockResolvedValueOnce({
112+
responses: [{ id: "0", status: 200, body: { id: "team-runtime-key" } }],
113+
});
107114

108115
const result = await module.resolveTeamGroupId("tok", "team-runtime-key");
109116

110117
expect(result).toBe("group-guid-2");
111-
expect(mockState.fetchGraphJson).toHaveBeenNthCalledWith(4, {
118+
expect(mockState.fetchGraphJson).toHaveBeenNthCalledWith(3, {
112119
token: "tok",
113120
path: "/groups?$skiptoken=page-2&$select=id&$top=999",
114121
});
115122
});
123+
124+
it("batches primary-channel lookups instead of fetching each team sequentially", async () => {
125+
const module = await loadGraphThreadModule();
126+
module._teamGroupIdCacheForTest.clear();
127+
mockState.fetchGraphJson.mockRejectedValueOnce(new Error("not found")).mockResolvedValueOnce({
128+
value: [{ id: "group-guid-1" }, { id: "group-guid-2" }],
129+
});
130+
mockState.postGraphJson.mockResolvedValueOnce({
131+
responses: [
132+
{ id: "0", status: 200, body: { id: "different-primary" } },
133+
{ id: "1", status: 200, body: { id: "team-runtime-key" } },
134+
],
135+
});
136+
137+
const result = await module.resolveTeamGroupId("tok", "team-runtime-key");
138+
139+
expect(result).toBe("group-guid-2");
140+
expect(mockState.postGraphJson).toHaveBeenCalledWith({
141+
token: "tok",
142+
path: "/$batch",
143+
body: {
144+
requests: [
145+
{
146+
id: "0",
147+
method: "GET",
148+
url: "/teams/group-guid-1/primaryChannel?$select=id",
149+
},
150+
{
151+
id: "1",
152+
method: "GET",
153+
url: "/teams/group-guid-2/primaryChannel?$select=id",
154+
},
155+
],
156+
},
157+
});
158+
});
116159
});
117160

118161
describe("fetchChannelMessage", () => {

extensions/msteams/src/graph-thread.ts

Lines changed: 63 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fetchGraphJson, type GraphResponse } from "./graph.js";
1+
import { fetchGraphJson, postGraphJson, type GraphResponse } from "./graph.js";
22

33
export type GraphThreadMessage = {
44
id?: string;
@@ -18,6 +18,16 @@ type GraphPagedResponse<T> = GraphResponse<T> & {
1818
"@odata.nextLink"?: string;
1919
};
2020

21+
type GraphBatchResponse<T> = {
22+
responses?: Array<{
23+
id?: string;
24+
status?: number;
25+
body?: T;
26+
}>;
27+
};
28+
29+
const GRAPH_BATCH_MAX_REQUESTS = 20;
30+
2131
// Graph team ids are Azure AD group ids. We keep this check intentionally broad
2232
// because some tenants surface uppercase GUIDs and Graph ids are the only raw
2333
// team ids we can safely reuse without another directory lookup.
@@ -38,6 +48,45 @@ function graphPathFromNextLink(nextLink: string): string | null {
3848
}
3949
}
4050

51+
function chunkArray<T>(values: T[], size: number): T[][] {
52+
const chunks: T[][] = [];
53+
for (let index = 0; index < values.length; index += size) {
54+
chunks.push(values.slice(index, index + size));
55+
}
56+
return chunks;
57+
}
58+
59+
async function findGraphTeamIdByPrimaryChannel(params: {
60+
token: string;
61+
conversationTeamId: string;
62+
candidateTeamIds: string[];
63+
}): Promise<string | null> {
64+
for (const teamIds of chunkArray(params.candidateTeamIds, GRAPH_BATCH_MAX_REQUESTS)) {
65+
const response = await postGraphJson<GraphBatchResponse<{ id?: string }>>({
66+
token: params.token,
67+
path: "/$batch",
68+
body: {
69+
requests: teamIds.map((graphTeamId, index) => ({
70+
id: String(index),
71+
method: "GET",
72+
url: `/teams/${encodeURIComponent(graphTeamId)}/primaryChannel?$select=id`,
73+
})),
74+
},
75+
});
76+
const responses = new Map(
77+
(response.responses ?? []).map((entry) => [entry.id ?? "", entry] as const),
78+
);
79+
for (const [index, graphTeamId] of teamIds.entries()) {
80+
const candidate = responses.get(String(index));
81+
const primaryId = candidate?.status === 200 ? candidate.body?.id?.trim() : undefined;
82+
if (primaryId && primaryId === params.conversationTeamId) {
83+
return graphTeamId;
84+
}
85+
}
86+
}
87+
return null;
88+
}
89+
4190
/**
4291
* Strip HTML tags from Teams message content, preserving @mention display names.
4392
* Teams wraps mentions in <at>Name</at> tags.
@@ -104,28 +153,19 @@ export async function resolveTeamGroupId(
104153
let path = `/groups?$filter=${encodeURIComponent("resourceProvisioningOptions/Any(x:x eq 'Team')")}&$select=id&$top=999`;
105154
while (path) {
106155
const teams = await fetchGraphJson<GraphPagedResponse<{ id?: string }>>({ token, path });
107-
for (const candidate of teams.value ?? []) {
108-
const graphTeamId = candidate.id?.trim();
109-
if (!graphTeamId) {
110-
continue;
111-
}
112-
try {
113-
const primary = await fetchGraphJson<{ id?: string }>({
114-
token,
115-
path: `/teams/${encodeURIComponent(graphTeamId)}/primaryChannel?$select=id`,
116-
});
117-
const primaryId = primary.id?.trim();
118-
if (!primaryId || primaryId !== conversationTeamId) {
119-
continue;
120-
}
121-
teamGroupIdCache.set(conversationTeamId, {
122-
groupId: graphTeamId,
123-
expiresAt: Date.now() + CACHE_TTL_MS,
124-
});
125-
return graphTeamId;
126-
} catch {
127-
continue;
128-
}
156+
const graphTeamId = await findGraphTeamIdByPrimaryChannel({
157+
token,
158+
conversationTeamId,
159+
candidateTeamIds: (teams.value ?? [])
160+
.map((candidate) => candidate.id?.trim())
161+
.filter((value): value is string => Boolean(value)),
162+
});
163+
if (graphTeamId) {
164+
teamGroupIdCache.set(conversationTeamId, {
165+
groupId: graphTeamId,
166+
expiresAt: Date.now() + CACHE_TTL_MS,
167+
});
168+
return graphTeamId;
129169
}
130170
path = teams["@odata.nextLink"]
131171
? (graphPathFromNextLink(teams["@odata.nextLink"]) ?? "")

0 commit comments

Comments
 (0)