mcp-teams 0.2.13 → 0.2.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/bridge.js +289 -30
- package/dist/bridge.js.map +1 -1
- package/dist/fairqueue.js +83 -0
- package/dist/fairqueue.js.map +1 -0
- package/dist/tools.js +20 -18
- package/dist/tools.js.map +1 -1
- package/extension/background.js +409 -107
- package/extension/manifest.json +1 -1
- package/extension/teams.js +18 -0
- package/package.json +1 -1
package/extension/background.js
CHANGED
|
@@ -182,9 +182,12 @@ const SERVICE_HELP = {
|
|
|
182
182
|
|
|
183
183
|
/**
|
|
184
184
|
* Runs inside the Teams page (MAIN world) so cookies and origin are the page's own.
|
|
185
|
-
* status 0: no answer (network error or
|
|
185
|
+
* status 0: no answer (network error, or the deadline passed). `deadline` is absolute
|
|
186
|
+
* (ms since 1970): a tab that runs this only later (frozen or discarded) sends nothing after it.
|
|
186
187
|
*/
|
|
187
|
-
async function pageFetch(url, method, headers, body,
|
|
188
|
+
async function pageFetch(url, method, headers, body, deadline) {
|
|
189
|
+
const timeoutMs = deadline - Date.now();
|
|
190
|
+
if (timeoutMs <= 0) return { status: 0, text: "", error: "timeout" };
|
|
188
191
|
try {
|
|
189
192
|
const res = await fetch(url, {
|
|
190
193
|
method,
|
|
@@ -193,53 +196,167 @@ async function pageFetch(url, method, headers, body, timeoutMs) {
|
|
|
193
196
|
body: body == null ? undefined : JSON.stringify(body),
|
|
194
197
|
signal: AbortSignal.timeout(timeoutMs),
|
|
195
198
|
});
|
|
196
|
-
return { status: res.status, text: await res.text(), location: res.headers.get("location") };
|
|
199
|
+
return { status: res.status, text: await res.text(), location: res.headers.get("location"), retryAfter: res.headers.get("retry-after") };
|
|
197
200
|
} catch (err) {
|
|
198
201
|
return { status: 0, text: "", error: err?.name === "TimeoutError" ? "timeout" : String(err?.message ?? err) };
|
|
199
202
|
}
|
|
200
203
|
}
|
|
201
204
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
205
|
+
/** 60, 0.5: seconds for a message. */
|
|
206
|
+
const secondsOf = (ms) => +(ms / 1000).toFixed(1);
|
|
207
|
+
|
|
208
|
+
/** How long after its deadline a tab script may take to report back before the tab counts as frozen. */
|
|
209
|
+
const SCRIPT_MARGIN_MS = 1_000;
|
|
210
|
+
|
|
211
|
+
/** An error after which the request may have reached Teams (no answer came): `outcomeUnknown`. */
|
|
212
|
+
function unanswered(message) {
|
|
213
|
+
return Object.assign(new Error(message), { outcomeUnknown: true });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* One fetch in the tab, by REQUEST_TIMEOUT_MS or the request's deadline (ctx), whichever
|
|
218
|
+
* comes first. Nothing starts once the request was withdrawn, and a tab that doesn't run
|
|
219
|
+
* the script in time (frozen, discarded) fails the call instead of holding it.
|
|
220
|
+
*/
|
|
221
|
+
async function runInTab(tab, url, method, headers, body, ctx = null) {
|
|
222
|
+
ctx?.check();
|
|
223
|
+
const start = Date.now();
|
|
224
|
+
const deadline = Math.min(start + REQUEST_TIMEOUT_MS, ctx?.deadline ?? Infinity);
|
|
225
|
+
const seconds = secondsOf(deadline - start);
|
|
226
|
+
let timer;
|
|
227
|
+
const scriptTimeout = new Promise((_, reject) => {
|
|
228
|
+
timer = setTimeout(
|
|
229
|
+
() => reject(unanswered(`The Teams tab did not run the request within ${seconds} s (it may be frozen or asleep). Reload the Teams tab and try again.`)),
|
|
230
|
+
deadline - start + SCRIPT_MARGIN_MS,
|
|
231
|
+
);
|
|
209
232
|
});
|
|
233
|
+
const run = chrome.scripting
|
|
234
|
+
.executeScript({
|
|
235
|
+
target: { tabId: tab.id },
|
|
236
|
+
world: "MAIN",
|
|
237
|
+
func: pageFetch,
|
|
238
|
+
// executeScript can't pass undefined.
|
|
239
|
+
args: [url, method, headers, body ?? null, deadline],
|
|
240
|
+
})
|
|
241
|
+
// E.g. the tab closed or reloaded while the request ran: it may have reached Teams.
|
|
242
|
+
.catch((err) => {
|
|
243
|
+
throw unanswered(String(err?.message ?? err));
|
|
244
|
+
});
|
|
245
|
+
let injection;
|
|
246
|
+
try {
|
|
247
|
+
[injection] = await guarded(Promise.race([run, scriptTimeout]), ctx);
|
|
248
|
+
} finally {
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
}
|
|
210
251
|
const res = injection?.result;
|
|
211
|
-
if (!res) throw
|
|
252
|
+
if (!res) throw unanswered("Could not run inside the Teams tab. Reload the Teams tab and try again.");
|
|
212
253
|
if (res.status === 0) {
|
|
213
|
-
|
|
254
|
+
ctx?.check(); // the deadline passed: say that rather than "Teams did not answer"
|
|
255
|
+
// A fetch that failed may have been sent already (a reset connection, no answer in time).
|
|
256
|
+
throw unanswered(
|
|
214
257
|
res.error === "timeout"
|
|
215
|
-
? `Teams did not answer within ${
|
|
258
|
+
? `Teams did not answer within ${seconds} s. Reload the Teams tab and try again.`
|
|
216
259
|
: `Could not reach Teams: ${res.error}`,
|
|
217
260
|
);
|
|
218
261
|
}
|
|
219
262
|
return res;
|
|
220
263
|
}
|
|
221
264
|
|
|
265
|
+
// ------------------------------------------------------------ throttling
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Gate key -> when it may be sent to again (ms since 1970), after a 429. A gate is per service
|
|
269
|
+
* and where it lives: its captured base for Teams' services (another region or account gets its
|
|
270
|
+
* own), the origin (one tenant's OneDrive) for SharePoint.
|
|
271
|
+
*/
|
|
272
|
+
const gates = new Map();
|
|
273
|
+
const gateKey = (service, where) => `${service} ${where}`;
|
|
274
|
+
|
|
275
|
+
/** How long the gate `key` is still shut, in ms (0: open). */
|
|
276
|
+
function gatedFor(key) {
|
|
277
|
+
const ms = (gates.get(key) ?? 0) - Date.now();
|
|
278
|
+
if (ms > 0) return ms;
|
|
279
|
+
gates.delete(key);
|
|
280
|
+
return 0;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const busySeconds = (ms) => Math.max(1, Math.ceil(ms / 1000));
|
|
284
|
+
|
|
285
|
+
/** The error for a call Teams throttles until `until` (ms since 1970). */
|
|
286
|
+
function busy(until) {
|
|
287
|
+
return Object.assign(new Error(`Teams is busy (throttled) for ${busySeconds(until - Date.now())} s; try again after that.`), { throttled: true, until });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// A wait that would end this close to the request's deadline leaves no time for the request itself.
|
|
291
|
+
const GATE_MARGIN_MS = 250;
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Before anything goes to a throttled service: waits until Teams' Retry-After is over when
|
|
295
|
+
* `wait` and that fits before the request's deadline, else fails at once as busy. A request
|
|
296
|
+
* that holds one of the hub's turns gives it back while it waits, and goes on once it has one
|
|
297
|
+
* again (35), so calls to services that aren't throttled run meanwhile.
|
|
298
|
+
*/
|
|
299
|
+
async function passGate(key, ctx, wait) {
|
|
300
|
+
let gaveBack = false;
|
|
301
|
+
try {
|
|
302
|
+
for (let ms = gatedFor(key); ms > 0; ms = gatedFor(key)) {
|
|
303
|
+
const deadline = Math.min(ctx?.deadline ?? Infinity, Date.now() + REQUEST_TIMEOUT_MS);
|
|
304
|
+
if (!wait || Date.now() + ms + GATE_MARGIN_MS > deadline) throw busy(Date.now() + ms);
|
|
305
|
+
if (ctx?.turn && !gaveBack) gaveBack = (ctx.turn.giveBack(), true);
|
|
306
|
+
await guarded(sleep(ms), ctx);
|
|
307
|
+
}
|
|
308
|
+
} finally {
|
|
309
|
+
// Also before a busy error: the handler may go on without this service.
|
|
310
|
+
if (gaveBack && !ctx.cancelled) await guarded(ctx.turn.takeAgain(), ctx);
|
|
311
|
+
}
|
|
312
|
+
ctx?.check();
|
|
313
|
+
// The gate may have shut again while it waited for its turn back.
|
|
314
|
+
if (gaveBack && gatedFor(key) > 0) return passGate(key, ctx, wait);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* runInTab through the gate `key`. A 429 closes the gate for its Retry-After. Teams refused
|
|
319
|
+
* that request without running it, so a read (a GET, or a POST that only reads: `repeatable`)
|
|
320
|
+
* goes once more after the wait (when `wait` and it fits the deadline). Anything else, such as
|
|
321
|
+
* a post, is never sent again: the caller hears that Teams is busy.
|
|
322
|
+
*/
|
|
323
|
+
async function sendTo(key, tab, url, method, headers, body, ctx, wait, repeatable = method === "GET") {
|
|
324
|
+
for (let attempt = 0; ; attempt++) {
|
|
325
|
+
await passGate(key, ctx, wait);
|
|
326
|
+
const res = await runInTab(tab, url, method, headers, body, ctx);
|
|
327
|
+
if (res.status !== 429) return res;
|
|
328
|
+
const until = Math.max(gates.get(key) ?? 0, Date.now() + teams.retryAfterMs(res.retryAfter));
|
|
329
|
+
gates.set(key, until);
|
|
330
|
+
if (!repeatable || attempt > 0) throw busy(until);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
222
334
|
/**
|
|
223
335
|
* One request to a Teams service. `path` is relative to the service's base URL (or a
|
|
224
|
-
* full URL on it). Returns the parsed JSON (null for an empty answer).
|
|
336
|
+
* full URL on it). Returns the parsed JSON (null for an empty answer). While Teams throttles
|
|
337
|
+
* the service, the call waits that out if it fits the deadline; `wait: false` (for lookups
|
|
338
|
+
* that can be done without) fails at once as busy instead. `repeatable`: a POST that only
|
|
339
|
+
* reads (a search, a profile lookup), sent again after a 429 like a GET.
|
|
225
340
|
*/
|
|
226
|
-
async function api(service, path, { method = "GET", body, raw = false, withLocation = false } = {}) {
|
|
341
|
+
async function api(service, path, { method = "GET", body, raw = false, withLocation = false, ctx = null, wait = true, repeatable = method === "GET" } = {}) {
|
|
342
|
+
ctx?.check();
|
|
227
343
|
let cap = await getCapture(service);
|
|
228
|
-
|
|
344
|
+
// Opening a tab or waiting for a session can take a minute; a withdrawn request stops waiting.
|
|
345
|
+
let tab = await guarded(findTeamsTab(cap ? new URL(cap.base).origin : null), ctx);
|
|
229
346
|
if (!cap) {
|
|
230
|
-
cap = await refreshSession(service, tab, { quickWaitMs: service === "substrate" ? 2_000 : 4_000 });
|
|
347
|
+
cap = await guarded(refreshSession(service, tab, { quickWaitMs: service === "substrate" ? 2_000 : 4_000 }), ctx);
|
|
231
348
|
if (!cap) throw new Error(SERVICE_HELP[service]);
|
|
232
349
|
}
|
|
233
350
|
const urlFor = (c) => teams.serviceUrl(path, c.base);
|
|
234
|
-
let res = await
|
|
351
|
+
let res = await sendTo(gateKey(service, cap.base), tab, urlFor(cap), method, cap.headers, body, ctx, wait, repeatable);
|
|
235
352
|
if (res.status === 401) {
|
|
236
353
|
// Rejected before running (expired token), so trying once more is safe. A 403 is a
|
|
237
354
|
// real answer (e.g. no longer a member of that chat).
|
|
238
355
|
meetingSessionsCache.clear(); // the session (maybe the account) is changing
|
|
239
|
-
cap = await refreshSession(service, tab, { quickWaitMs: 0 });
|
|
356
|
+
cap = await guarded(refreshSession(service, tab, { quickWaitMs: 0 }), ctx);
|
|
240
357
|
if (!cap) throw new Error(SERVICE_HELP[service]);
|
|
241
|
-
tab = await findTeamsTab(new URL(cap.base).origin);
|
|
242
|
-
res = await
|
|
358
|
+
tab = await guarded(findTeamsTab(new URL(cap.base).origin), ctx);
|
|
359
|
+
res = await sendTo(gateKey(service, cap.base), tab, urlFor(cap), method, cap.headers, body, ctx, wait, repeatable);
|
|
243
360
|
}
|
|
244
361
|
if (raw) return res;
|
|
245
362
|
if (res.status >= 400) {
|
|
@@ -272,54 +389,106 @@ async function myMri() {
|
|
|
272
389
|
}
|
|
273
390
|
|
|
274
391
|
const LIST_CACHE_MS = 60_000;
|
|
275
|
-
let listCache = null; // {
|
|
392
|
+
let listCache = null; // { until, data: { teams, chats, meetings, namesMissing } }
|
|
276
393
|
|
|
277
|
-
/**
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
394
|
+
/**
|
|
395
|
+
* Teams, channels and chats (csa), with names for untitled chats. Cached for a minute, but
|
|
396
|
+
* when names are missing (`namesMissing`: { chats, organizers }, see lookupNames) only until
|
|
397
|
+
* Teams' throttle is over, or not at all after another failure, so they are looked up again.
|
|
398
|
+
* A meeting whose organizer's name is missing that way has `organizerNameMissing`.
|
|
399
|
+
*/
|
|
400
|
+
async function teamsAndChats(fresh = false, ctx = null) {
|
|
401
|
+
if (!fresh && listCache && Date.now() < listCache.until) return listCache.data;
|
|
402
|
+
const json = await api("csa", "/api/v1/teams/users/me?isPrefetch=false&enableMembershipSummary=true&enableRC2Fetch=false", { ctx });
|
|
281
403
|
const data = { teams: teams.parseTeams(json), chats: teams.parseCsaChats(json), meetings: teams.parseCsaMeetings(json) };
|
|
282
|
-
await nameChats(data.chats);
|
|
283
|
-
await lookupNames(data.meetings.map((m) => m.organizer));
|
|
284
|
-
for (const m of data.meetings)
|
|
285
|
-
|
|
404
|
+
const chats = await nameChats(data.chats, ctx);
|
|
405
|
+
const organizers = await lookupNames(data.meetings.map((m) => m.organizer), ctx);
|
|
406
|
+
for (const m of data.meetings) {
|
|
407
|
+
m.organizerName = names.get(m.organizer) ?? null;
|
|
408
|
+
if (!m.organizerName && organizers?.mris.has(m.organizer)) m.organizerNameMissing = organizers;
|
|
409
|
+
}
|
|
410
|
+
data.namesMissing = { chats, organizers };
|
|
411
|
+
const missing = [chats, organizers].filter(Boolean);
|
|
412
|
+
const until = missing.some((x) => !x.throttled) ? 0 : missing.length ? Math.max(...missing.map((x) => x.until)) : Date.now() + LIST_CACHE_MS;
|
|
413
|
+
listCache = { until, data };
|
|
286
414
|
return data;
|
|
287
415
|
}
|
|
288
416
|
|
|
417
|
+
/** A result's note when names are missing (`missing`, see lookupNames): throttled, or why. */
|
|
418
|
+
function namesNote(what, missing) {
|
|
419
|
+
if (!missing.throttled) return `${what} missing: some people couldn't be looked up, so they are shown without their names. Try again. The lookup failed with: ${missing.error}`;
|
|
420
|
+
const wait = missing.until - Date.now();
|
|
421
|
+
return `${what} missing (throttled): Teams asked to slow down, so some people couldn't be looked up and are shown without their names.${wait > 0 ? ` Try again in ${busySeconds(wait)} s.` : " Try again."}`;
|
|
422
|
+
}
|
|
423
|
+
|
|
289
424
|
const names = new Map(); // mri -> display name; bounded, starts over when full
|
|
290
425
|
const NAMES_MAX = 5000;
|
|
291
426
|
|
|
292
|
-
/**
|
|
293
|
-
|
|
427
|
+
/**
|
|
428
|
+
* Looks up display names for MRIs not seen yet (mt short profiles, 100 at a time). Returns
|
|
429
|
+
* null when it could ask for all of them, else why some were left out (the caller says so):
|
|
430
|
+
* { throttled: true, until } while Teams throttles mt, or { error } for another failure, with
|
|
431
|
+
* `mris`: those it couldn't ask for (the failed batches, and the ones a throttle left out).
|
|
432
|
+
* Names are a nicety, so it doesn't wait out a throttle unless `wait`.
|
|
433
|
+
*/
|
|
434
|
+
async function lookupNames(mris, ctx = null, { wait = false } = {}) {
|
|
294
435
|
const unknown = [...new Set(mris)].filter((m) => m && !names.has(m) && /^8:/.test(m));
|
|
436
|
+
let missing = null;
|
|
437
|
+
const notAsked = new Set();
|
|
295
438
|
for (let i = 0; i < unknown.length; i += 100) {
|
|
439
|
+
if (missing?.throttled) {
|
|
440
|
+
for (const m of unknown.slice(i)) notAsked.add(m);
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
// Names that can't be looked up are left out, but a withdrawn request ends here (and isn't cached).
|
|
444
|
+
ctx?.check();
|
|
296
445
|
const batch = unknown.slice(i, i + 100);
|
|
297
446
|
const json = await api("mt", "/beta/users/fetchShortProfile?isMailAddress=false&enableGuest=true&includeIBBarredUsers=true&skypeTeamsInfo=true", {
|
|
298
447
|
method: "POST",
|
|
299
448
|
body: batch,
|
|
300
|
-
|
|
449
|
+
ctx,
|
|
450
|
+
wait,
|
|
451
|
+
repeatable: true,
|
|
452
|
+
}).catch((err) => {
|
|
453
|
+
ctx?.check(); // a withdrawn request ends here (and isn't cached)
|
|
454
|
+
missing = err?.throttled ? { throttled: true, until: err.until } : (missing ?? { error: err?.message ?? String(err) });
|
|
455
|
+
for (const m of batch) notAsked.add(m);
|
|
456
|
+
return null;
|
|
457
|
+
});
|
|
301
458
|
if (names.size > NAMES_MAX) names.clear();
|
|
302
459
|
for (const p of teams.parseProfiles(json)) if (p.mri && p.name) names.set(p.mri, p.name);
|
|
303
460
|
}
|
|
461
|
+
ctx?.check();
|
|
462
|
+
return missing && { ...missing, mris: notAsked };
|
|
304
463
|
}
|
|
305
464
|
|
|
306
|
-
|
|
465
|
+
/** Names untitled chats by their members; returns why names are missing, if they are (see lookupNames). */
|
|
466
|
+
async function nameChats(chats, ctx = null) {
|
|
307
467
|
const me = await myMri();
|
|
308
|
-
await lookupNames(chats.filter((c) => !c.title).flatMap((c) => c.members));
|
|
468
|
+
const missing = await lookupNames(chats.filter((c) => !c.title).flatMap((c) => c.members), ctx);
|
|
309
469
|
for (const c of chats) {
|
|
310
470
|
c.memberNames = c.members.filter((m) => m !== me).map((m) => names.get(m)).filter(Boolean);
|
|
311
471
|
c.title = teams.chatTitle(c, names, me);
|
|
312
472
|
}
|
|
473
|
+
return missing;
|
|
313
474
|
}
|
|
314
475
|
|
|
315
|
-
async function resolve(conversation) {
|
|
476
|
+
async function resolve(conversation, ctx = null) {
|
|
316
477
|
const q = String(conversation ?? "").trim();
|
|
317
478
|
if (!q) throw new Error("Say which chat or channel: an id, 'Team/Channel', a chat title or a person's name.");
|
|
318
479
|
if (/^(19|48):/.test(q)) {
|
|
319
|
-
const data = await teamsAndChats().catch(() => ({ teams: [], chats: [] }));
|
|
480
|
+
const data = await teamsAndChats(false, ctx).catch(() => ({ teams: [], chats: [] }));
|
|
481
|
+
ctx?.check();
|
|
320
482
|
return describe(q.split(";messageid=")[0], data);
|
|
321
483
|
}
|
|
322
|
-
|
|
484
|
+
const data = await teamsAndChats(false, ctx);
|
|
485
|
+
try {
|
|
486
|
+
return teams.resolveConversation(q, data);
|
|
487
|
+
} catch (err) {
|
|
488
|
+
// Untitled chats are found by their members' names: without them a match can be missed.
|
|
489
|
+
if (data.namesMissing?.chats) err.message += `\n${namesNote("Chat names", data.namesMissing.chats)}`;
|
|
490
|
+
throw err;
|
|
491
|
+
}
|
|
323
492
|
}
|
|
324
493
|
|
|
325
494
|
/** What we know about a conversation id from the lists. */
|
|
@@ -335,7 +504,7 @@ function describe(id, { teams: ts, chats }) {
|
|
|
335
504
|
}
|
|
336
505
|
|
|
337
506
|
/** People search as in Teams' people picker (substrate suggestions). */
|
|
338
|
-
async function searchPeople(query, limit) {
|
|
507
|
+
async function searchPeople(query, limit, ctx = null) {
|
|
339
508
|
const body = {
|
|
340
509
|
EntityRequests: [{ Query: { QueryString: query, DisplayQueryString: query }, EntityType: "People", Size: limit, Fields: ["Id", "MRI", "DisplayName", "EmailAddresses", "PeopleType", "PeopleSubtype", "UserPrincipalName", "GivenName", "Surname", "JobTitle", "Department", "CompanyName", "OfficeLocation", "ExternalDirectoryObjectId"] }],
|
|
341
510
|
cvid: crypto.randomUUID(),
|
|
@@ -343,26 +512,28 @@ async function searchPeople(query, limit) {
|
|
|
343
512
|
AppName: "Microsoft Teams",
|
|
344
513
|
Scenario: { Name: "peoplepicker.newChat" },
|
|
345
514
|
};
|
|
346
|
-
return teams.parseSuggestions(await api("substrate", "/search/api/v1/suggestions?scenario=peoplepicker.newChat", { method: "POST", body }));
|
|
515
|
+
return teams.parseSuggestions(await api("substrate", "/search/api/v1/suggestions?scenario=peoplepicker.newChat", { method: "POST", body, ctx, repeatable: true }));
|
|
347
516
|
}
|
|
348
517
|
|
|
349
518
|
/** Each name or address as the one person it means (see teams.pickPerson). */
|
|
350
|
-
async function resolvePeople(queries, me) {
|
|
519
|
+
async function resolvePeople(queries, me, ctx = null) {
|
|
351
520
|
const people = [];
|
|
352
521
|
for (const q of queries) {
|
|
353
522
|
const query = String(q).trim();
|
|
354
523
|
if (/^8:orgid:/i.test(query)) {
|
|
355
|
-
|
|
524
|
+
// The window must show who it is: wait out a throttle if the deadline allows, else fail as busy.
|
|
525
|
+
const missing = await lookupNames([query], ctx, { wait: true });
|
|
526
|
+
if (missing?.throttled) throw busy(missing.until);
|
|
356
527
|
people.push({ mri: query, name: names.get(query) ?? query, email: null });
|
|
357
528
|
continue;
|
|
358
529
|
}
|
|
359
|
-
let found = await searchPeople(query, 10);
|
|
530
|
+
let found = await searchPeople(query, 10, ctx);
|
|
360
531
|
// People search can miss a full name it would find by one of its words (seen with
|
|
361
532
|
// "KIM MIN JUN" for "김민준(KIM MIN JUN)"): then search each word, keeping exact names.
|
|
362
533
|
const words = query.split(/\s+/).filter((w) => w.length > 1);
|
|
363
534
|
if (!found.length && words.length > 1) {
|
|
364
535
|
for (const w of words) {
|
|
365
|
-
found = (await searchPeople(w, 25)).filter((p) => teams.sameName(p.name, query));
|
|
536
|
+
found = (await searchPeople(w, 25, ctx)).filter((p) => teams.sameName(p.name, query));
|
|
366
537
|
if (found.length) break;
|
|
367
538
|
}
|
|
368
539
|
}
|
|
@@ -412,20 +583,22 @@ async function sharepointTab(origin) {
|
|
|
412
583
|
}
|
|
413
584
|
|
|
414
585
|
/** A GET on SharePoint with the browser's own SharePoint session. */
|
|
415
|
-
async function sharepointGet(url) {
|
|
586
|
+
async function sharepointGet(url, ctx = null) {
|
|
416
587
|
if (!SHAREPOINT_URL.test(url)) throw new Error("Not a SharePoint URL");
|
|
417
|
-
|
|
588
|
+
ctx?.check();
|
|
589
|
+
const origin = new URL(url).origin;
|
|
590
|
+
return sendTo(gateKey("sharepoint", origin), await guarded(sharepointTab(origin), ctx), url, "GET", {}, null, ctx, true);
|
|
418
591
|
}
|
|
419
592
|
|
|
420
593
|
const transcripts = new Map(); // transcript URL -> parsed transcript; a finished transcript doesn't change
|
|
421
594
|
const TRANSCRIPTS_MAX = 50;
|
|
422
595
|
|
|
423
596
|
/** A call's transcript (Stream transcript.json) from the organizer's OneDrive. */
|
|
424
|
-
async function transcriptOf(info, session) {
|
|
597
|
+
async function transcriptOf(info, session, ctx = null) {
|
|
425
598
|
const cached = transcripts.get(session.transcriptUrl);
|
|
426
599
|
if (cached) return cached;
|
|
427
600
|
const url = session.transcriptUrl + (session.transcriptUrl.includes("?") ? "&" : "?") + "format=json";
|
|
428
|
-
const res = await sharepointGet(url);
|
|
601
|
+
const res = await sharepointGet(url, ctx);
|
|
429
602
|
if (res.status === 403 || res.status === 404) {
|
|
430
603
|
throw new Error(`The transcript of "${info.title}" (${session.start}) is no longer available to you (HTTP ${res.status}): the organizer may have deleted it or not shared it.`);
|
|
431
604
|
}
|
|
@@ -437,9 +610,9 @@ async function transcriptOf(info, session) {
|
|
|
437
610
|
}
|
|
438
611
|
|
|
439
612
|
/** A meeting chat by id, title or subject. */
|
|
440
|
-
async function resolveMeeting(meeting) {
|
|
441
|
-
const conv = await resolve(meeting);
|
|
442
|
-
const info = (await teamsAndChats()).meetings.find((m) => m.id === conv.id);
|
|
613
|
+
async function resolveMeeting(meeting, ctx = null) {
|
|
614
|
+
const conv = await resolve(meeting, ctx);
|
|
615
|
+
const info = (await teamsAndChats(false, ctx)).meetings.find((m) => m.id === conv.id);
|
|
443
616
|
if (!info) throw new Error(`"${conv.title ?? conv.id}" is not a meeting chat. list_meetings lists them.`);
|
|
444
617
|
return info;
|
|
445
618
|
}
|
|
@@ -454,11 +627,11 @@ const sessionsKey = (cap, id) => (cap ? `${cap.base}\n${id}` : null);
|
|
|
454
627
|
* The calls held in a meeting chat (see teams.meetingSessions). Cached for a minute, so
|
|
455
628
|
* read_transcript's continueFrom pages and get_meeting don't read the whole chat again.
|
|
456
629
|
*/
|
|
457
|
-
async function sessionsOf(info) {
|
|
630
|
+
async function sessionsOf(info, ctx = null) {
|
|
458
631
|
const key = sessionsKey(await getCapture("chatsvc"), info.id);
|
|
459
632
|
const hit = key && meetingSessionsCache.get(key);
|
|
460
633
|
if (hit && Date.now() - hit.at < SESSIONS_CACHE_MS) return hit.sessions;
|
|
461
|
-
const sessions = await readSessions(info);
|
|
634
|
+
const sessions = await readSessions(info, ctx);
|
|
462
635
|
// Filed under the base the chat was read from (a 401 on the way may have changed it).
|
|
463
636
|
const keyNow = sessionsKey(await getCapture("chatsvc"), info.id);
|
|
464
637
|
if (keyNow) {
|
|
@@ -469,10 +642,10 @@ async function sessionsOf(info) {
|
|
|
469
642
|
return sessions;
|
|
470
643
|
}
|
|
471
644
|
|
|
472
|
-
async function readSessions(info) {
|
|
645
|
+
async function readSessions(info, ctx = null) {
|
|
473
646
|
let raw;
|
|
474
647
|
try {
|
|
475
|
-
raw = await fetchMessages(info.id, { limit: 2000, includeSystem: true });
|
|
648
|
+
raw = await fetchMessages(info.id, { limit: 2000, includeSystem: true, ctx });
|
|
476
649
|
} catch (err) {
|
|
477
650
|
if (/HTTP 403/.test(err.message) && info.external) {
|
|
478
651
|
throw new Error(`"${info.title}" was organized by another organization, and Teams no longer lets you read its meeting chat.`);
|
|
@@ -504,7 +677,7 @@ const messagesTarget = (id, threadId) => (threadId ? `${id};messageid=${threadId
|
|
|
504
677
|
* page, or at `from` (a link from a cursor, see teams.readMessagesCursor), and takes only
|
|
505
678
|
* messages older than `olderThan` ({ time, id }, see teams.compareMessageKeys) if given.
|
|
506
679
|
*/
|
|
507
|
-
async function fetchMessagePages(id, { limit, olderThan, includeSystem, threadId, from }) {
|
|
680
|
+
async function fetchMessagePages(id, { limit, olderThan, includeSystem, threadId, from, ctx = null }) {
|
|
508
681
|
const target = messagesTarget(id, threadId);
|
|
509
682
|
const first = from ?? `/v1/users/ME/conversations/${encodeURIComponent(target)}/messages?view=${encodeURIComponent(MESSAGES_VIEW)}&pageSize=${Math.min(200, Math.max(20, limit * 2))}&startTime=1`;
|
|
510
683
|
let path = first;
|
|
@@ -514,7 +687,7 @@ async function fetchMessagePages(id, { limit, olderThan, includeSystem, threadId
|
|
|
514
687
|
let pages = 0;
|
|
515
688
|
while (path) {
|
|
516
689
|
if (pages === MAX_PAGES) return { raw, linkOf, first, next: path, capped: true, pages };
|
|
517
|
-
const json = await api("chatsvc", path);
|
|
690
|
+
const json = await api("chatsvc", path, { ctx });
|
|
518
691
|
pages++;
|
|
519
692
|
const msgs = json?.messages ?? [];
|
|
520
693
|
for (const m of msgs) {
|
|
@@ -549,31 +722,33 @@ export const handlers = {
|
|
|
549
722
|
};
|
|
550
723
|
},
|
|
551
724
|
|
|
552
|
-
async listTeams() {
|
|
553
|
-
return { teams: (await teamsAndChats(true)).teams };
|
|
725
|
+
async listTeams(_, ctx) {
|
|
726
|
+
return { teams: (await teamsAndChats(true, ctx)).teams };
|
|
554
727
|
},
|
|
555
728
|
|
|
556
|
-
async listChats({ query, limit = 30, unreadOnly = false }) {
|
|
557
|
-
|
|
729
|
+
async listChats({ query, limit = 30, unreadOnly = false }, ctx) {
|
|
730
|
+
const data = await teamsAndChats(true, ctx);
|
|
731
|
+
let chats = data.chats;
|
|
558
732
|
if (unreadOnly) chats = chats.filter((c) => c.unread);
|
|
559
733
|
if (query) chats = chats.filter((c) => teams.matchesWords(c.title, query) || c.memberNames?.some((n) => teams.matchesWords(n, query)));
|
|
560
734
|
chats.sort((a, b) => (b.lastMessage?.time ?? "").localeCompare(a.lastMessage?.time ?? ""));
|
|
561
735
|
return {
|
|
562
736
|
total: chats.length,
|
|
563
737
|
chats: chats.slice(0, limit).map(({ members, memberNames, lastFrom, ...c }) => ({ ...c, memberCount: members.length || undefined })),
|
|
738
|
+
...(data.namesMissing.chats ? { note: namesNote("Chat names", data.namesMissing.chats) } : {}),
|
|
564
739
|
};
|
|
565
740
|
},
|
|
566
741
|
|
|
567
|
-
async readMessages({ conversation, limit = 30, before, cursor, includeSystem = false, threadId, maxLength }) {
|
|
742
|
+
async readMessages({ conversation, limit = 30, before, cursor, includeSystem = false, threadId, maxLength }, ctx) {
|
|
568
743
|
if (cursor && before) throw new Error("Give either cursor or before, not both (cursor already knows where to go on).");
|
|
569
|
-
const conv = await resolve(conversation);
|
|
744
|
+
const conv = await resolve(conversation, ctx);
|
|
570
745
|
// A cursor resumes at the page that held the oldest message returned last time, and
|
|
571
746
|
// only takes what is older than that message (by time, then id).
|
|
572
747
|
const from = cursor ? teams.readMessagesCursor(cursor, messagesTarget(conv.id, threadId)) : null;
|
|
573
748
|
if (from) before = from.before ?? undefined;
|
|
574
749
|
const mark = from ? { time: from.before, id: from.id } : { time: before ?? null, id: null };
|
|
575
750
|
const olderThan = mark.time != null || mark.id != null ? mark : null;
|
|
576
|
-
const { raw, linkOf, first, next, capped, pages } = await fetchMessagePages(conv.id, { limit, olderThan, includeSystem, threadId, from: from?.path });
|
|
751
|
+
const { raw, linkOf, first, next, capped, pages } = await fetchMessagePages(conv.id, { limit, olderThan, includeSystem, threadId, from: from?.path, ctx });
|
|
577
752
|
const { messages, more, oldest } = teams.newestEntries(raw, { limit, includeSystem, channel: conv.kind === "channel" && !threadId });
|
|
578
753
|
let olderCursor = null;
|
|
579
754
|
if (oldest && (more || next)) olderCursor = teams.messagesCursor(linkOf.get(oldest.id) ?? first, oldest.time, oldest.id);
|
|
@@ -594,7 +769,7 @@ export const handlers = {
|
|
|
594
769
|
};
|
|
595
770
|
},
|
|
596
771
|
|
|
597
|
-
async searchMessages({ query, limit = 25, offset = 0 }) {
|
|
772
|
+
async searchMessages({ query, limit = 25, offset = 0 }, ctx) {
|
|
598
773
|
const body = {
|
|
599
774
|
entityRequests: [
|
|
600
775
|
{
|
|
@@ -612,40 +787,44 @@ export const handlers = {
|
|
|
612
787
|
logicalId: crypto.randomUUID(),
|
|
613
788
|
scenario: { Dimensions: [{ DimensionName: "QueryType", DimensionValue: "All" }, { DimensionName: "FormFactor", DimensionValue: "general.web.reactSearch" }], Name: "powerbar" },
|
|
614
789
|
};
|
|
615
|
-
const r = teams.parseSearch(await api("substrate", "/searchservice/api/v2/query", { method: "POST", body }));
|
|
790
|
+
const r = teams.parseSearch(await api("substrate", "/searchservice/api/v2/query", { method: "POST", body, ctx, repeatable: true }));
|
|
616
791
|
return { query, ...r, results: r.results.slice(0, limit) };
|
|
617
792
|
},
|
|
618
793
|
|
|
619
|
-
async findPeople({ query, limit = 10 }) {
|
|
620
|
-
const people = await searchPeople(query, limit);
|
|
794
|
+
async findPeople({ query, limit = 10 }, ctx) {
|
|
795
|
+
const people = await searchPeople(query, limit, ctx);
|
|
621
796
|
return { count: people.length, people };
|
|
622
797
|
},
|
|
623
798
|
|
|
624
|
-
async listMeetings({ query, limit = 20, transcriptOnly = false }) {
|
|
625
|
-
|
|
799
|
+
async listMeetings({ query, limit = 20, transcriptOnly = false }, ctx) {
|
|
800
|
+
const data = await teamsAndChats(true, ctx);
|
|
801
|
+
let meetings = data.meetings;
|
|
626
802
|
if (query) meetings = meetings.filter((m) => teams.matchesWords(m.title, query) || teams.matchesWords(m.organizerName, query));
|
|
627
803
|
if (transcriptOnly) meetings = meetings.filter((m) => m.hasTranscript);
|
|
628
804
|
meetings = [...meetings].sort((a, b) => (b.lastActivity ?? "").localeCompare(a.lastActivity ?? ""));
|
|
629
805
|
return {
|
|
630
806
|
total: meetings.length,
|
|
631
|
-
meetings: meetings.slice(0, limit).map(({ organizer, organizerName, joinUrl, lastJoinAt, ...m }) => ({ ...m, organizer: organizerName ?? organizer })),
|
|
807
|
+
meetings: meetings.slice(0, limit).map(({ organizer, organizerName, organizerNameMissing, joinUrl, lastJoinAt, ...m }) => ({ ...m, organizer: organizerName ?? organizer })),
|
|
808
|
+
...(data.namesMissing.organizers ? { note: namesNote("Organizer names", data.namesMissing.organizers) } : {}),
|
|
632
809
|
};
|
|
633
810
|
},
|
|
634
811
|
|
|
635
|
-
async getMeeting({ meeting, limit = 10 }) {
|
|
636
|
-
const info = await resolveMeeting(meeting);
|
|
637
|
-
const sessions = await sessionsOf(info);
|
|
638
|
-
const { organizer, organizerName, lastJoinAt, ...rest } = info;
|
|
812
|
+
async getMeeting({ meeting, limit = 10 }, ctx) {
|
|
813
|
+
const info = await resolveMeeting(meeting, ctx);
|
|
814
|
+
const sessions = await sessionsOf(info, ctx);
|
|
815
|
+
const { organizer, organizerName, organizerNameMissing, lastJoinAt, ...rest } = info;
|
|
639
816
|
return {
|
|
640
817
|
meeting: { ...rest, organizer: organizerName ?? organizer },
|
|
641
818
|
sessionCount: sessions.length,
|
|
642
819
|
sessions: sessions.slice(-limit).reverse().map(({ transcriptUrl, ...s }) => s),
|
|
820
|
+
// Only when its own lookup failed; the list it came from isn't kept long then (teamsAndChats).
|
|
821
|
+
...(organizerNameMissing ? { note: namesNote("Organizer name", organizerNameMissing) } : {}),
|
|
643
822
|
};
|
|
644
823
|
},
|
|
645
824
|
|
|
646
|
-
async readTranscript({ meeting, session, from, maxLength = 40_000 }) {
|
|
647
|
-
const info = await resolveMeeting(meeting);
|
|
648
|
-
const all = await sessionsOf(info);
|
|
825
|
+
async readTranscript({ meeting, session, from, maxLength = 40_000 }, ctx) {
|
|
826
|
+
const info = await resolveMeeting(meeting, ctx);
|
|
827
|
+
const all = await sessionsOf(info, ctx);
|
|
649
828
|
const withTranscript = all.filter((s) => s.transcriptUrl);
|
|
650
829
|
if (!withTranscript.length) {
|
|
651
830
|
throw new Error(`"${info.title}" has no transcript${all.length ? ` in its ${all.length} recorded call(s)` : ""}. Teams keeps one only when transcription was turned on in the meeting.`);
|
|
@@ -659,7 +838,7 @@ export const handlers = {
|
|
|
659
838
|
}
|
|
660
839
|
s = matches.at(-1);
|
|
661
840
|
}
|
|
662
|
-
const json = await transcriptOf(info, s);
|
|
841
|
+
const json = await transcriptOf(info, s, ctx);
|
|
663
842
|
const fromSeconds = from ? (String(from).split(":").reduce((a, x) => a * 60 + Number(x), 0) || 0) : 0;
|
|
664
843
|
const lines = teams.transcriptLines(json, { fromSeconds });
|
|
665
844
|
let text = "";
|
|
@@ -681,8 +860,8 @@ export const handlers = {
|
|
|
681
860
|
};
|
|
682
861
|
},
|
|
683
862
|
|
|
684
|
-
async searchTranscripts({ query, meeting, limit = 30, meetings: maxMeetings = 10 }) {
|
|
685
|
-
let list = meeting ? [await resolveMeeting(meeting)] : (await teamsAndChats(true)).meetings.filter((m) => m.hasTranscript);
|
|
863
|
+
async searchTranscripts({ query, meeting, limit = 30, meetings: maxMeetings = 10 }, ctx) {
|
|
864
|
+
let list = meeting ? [await resolveMeeting(meeting, ctx)] : (await teamsAndChats(true, ctx)).meetings.filter((m) => m.hasTranscript);
|
|
686
865
|
list = [...list].sort((a, b) => (b.lastActivity ?? "").localeCompare(a.lastActivity ?? "")).slice(0, maxMeetings);
|
|
687
866
|
const results = [];
|
|
688
867
|
const skipped = [];
|
|
@@ -691,19 +870,22 @@ export const handlers = {
|
|
|
691
870
|
let searched = 0;
|
|
692
871
|
for (const info of list) {
|
|
693
872
|
if (shown >= limit) break; // enough matches; older meetings aren't read
|
|
873
|
+
ctx?.check(); // withdrawn: no more meetings are read
|
|
694
874
|
searched++;
|
|
695
875
|
let sessions;
|
|
696
876
|
try {
|
|
697
|
-
sessions = (await sessionsOf(info)).filter((s) => s.transcriptUrl);
|
|
877
|
+
sessions = (await sessionsOf(info, ctx)).filter((s) => s.transcriptUrl);
|
|
698
878
|
} catch (err) {
|
|
879
|
+
ctx?.check();
|
|
699
880
|
skipped.push({ meeting: info.title, reason: err.message });
|
|
700
881
|
continue;
|
|
701
882
|
}
|
|
702
883
|
for (const s of sessions.reverse()) {
|
|
703
884
|
let json;
|
|
704
885
|
try {
|
|
705
|
-
json = await transcriptOf(info, s);
|
|
886
|
+
json = await transcriptOf(info, s, ctx);
|
|
706
887
|
} catch (err) {
|
|
888
|
+
ctx?.check();
|
|
707
889
|
skipped.push({ meeting: info.title, session: s.start, reason: err.message });
|
|
708
890
|
continue;
|
|
709
891
|
}
|
|
@@ -728,13 +910,13 @@ export const handlers = {
|
|
|
728
910
|
},
|
|
729
911
|
|
|
730
912
|
/** Read-only diagnostics for development: a GET on a service, never its headers. */
|
|
731
|
-
async probe({ service, path, url }) {
|
|
913
|
+
async probe({ service, path, url }, ctx) {
|
|
732
914
|
if (url) {
|
|
733
|
-
const res = await sharepointGet(url);
|
|
915
|
+
const res = await sharepointGet(url, ctx);
|
|
734
916
|
return { status: res.status, text: res.text.slice(0, 20_000_000) };
|
|
735
917
|
}
|
|
736
918
|
if (!["chatsvc", "csa", "mt", "substrate"].includes(service)) throw new Error("Unknown service");
|
|
737
|
-
const res = await api(service, path, { raw: true });
|
|
919
|
+
const res = await api(service, path, { raw: true, ctx });
|
|
738
920
|
return { status: res.status, text: res.text.slice(0, 20_000_000) };
|
|
739
921
|
},
|
|
740
922
|
|
|
@@ -764,20 +946,20 @@ export const handlers = {
|
|
|
764
946
|
if (threadId || subject) throw new Error("reply_to and subject are for channels; a new chat takes people and an optional title.");
|
|
765
947
|
const me = await myMri();
|
|
766
948
|
if (!me) throw new Error("Teams hasn't shown who is signed in yet. Reload the Teams tab and try again.");
|
|
767
|
-
const people = await resolvePeople(to, me);
|
|
949
|
+
const people = await resolvePeople(to, me, ctx);
|
|
768
950
|
if (title && people.length < 2) throw new Error("A title is only for group chats (two or more people besides you).");
|
|
769
|
-
const existing = teams.findExistingChat((await teamsAndChats(true)).chats, people.map((p) => p.mri), me, title);
|
|
951
|
+
const existing = teams.findExistingChat((await teamsAndChats(true, ctx)).chats, people.map((p) => p.mri), me, title);
|
|
770
952
|
const request = existing
|
|
771
953
|
? { conversation: { id: existing.id, kind: existing.kind, title: existing.title }, people, text: String(text), existingChat: true }
|
|
772
954
|
: { conversation: null, newChat: { members: people.map((p) => p.mri), title: title ?? null }, people, text: String(text) };
|
|
773
955
|
return askUserToConfirm(request, ctx);
|
|
774
956
|
}
|
|
775
|
-
const conv = await resolve(conversation);
|
|
957
|
+
const conv = await resolve(conversation, ctx);
|
|
776
958
|
if (threadId && conv.kind !== "channel") throw new Error("reply_to is only for channel posts; in a chat, just send the message.");
|
|
777
959
|
if (subject && (conv.kind !== "channel" || threadId)) throw new Error("A subject is only for a new channel post.");
|
|
778
960
|
let replyingTo = null;
|
|
779
961
|
if (threadId) {
|
|
780
|
-
const root = (await fetchMessages(conv.id, { limit: 1, threadId, includeSystem: false })).find((m) => String(m.id) === String(threadId));
|
|
962
|
+
const root = (await fetchMessages(conv.id, { limit: 1, threadId, includeSystem: false, ctx })).find((m) => String(m.id) === String(threadId));
|
|
781
963
|
replyingTo = root ? teams.parseMessage(root) : { id: threadId };
|
|
782
964
|
}
|
|
783
965
|
const request = { conversation: conv, text: String(text), threadId: threadId ?? null, subject: subject ?? null, replyingTo };
|
|
@@ -810,7 +992,14 @@ async function createChat({ members, title }) {
|
|
|
810
992
|
|
|
811
993
|
async function sendMessage({ conversation, newChat, people, text, threadId, subject }) {
|
|
812
994
|
if (newChat) {
|
|
813
|
-
|
|
995
|
+
let id;
|
|
996
|
+
try {
|
|
997
|
+
id = await createChat(newChat);
|
|
998
|
+
} catch (err) {
|
|
999
|
+
// Without an answer the chat may exist, but the message was never sent.
|
|
1000
|
+
if (err.outcomeUnknown) err.chatMayExist = true;
|
|
1001
|
+
throw err;
|
|
1002
|
+
}
|
|
814
1003
|
const title = newChat.title ?? people.map((p) => p.name).join(", ");
|
|
815
1004
|
const r = await sendMessage({ conversation: { id, title }, text });
|
|
816
1005
|
return { ...r, newChat: true, conversationId: id };
|
|
@@ -919,7 +1108,14 @@ function onConfirmMessage(msg, sender, sendResponse) {
|
|
|
919
1108
|
p.finish(await sendMessage(p.request), true);
|
|
920
1109
|
sendResponse({ ok: true });
|
|
921
1110
|
} catch (err) {
|
|
922
|
-
|
|
1111
|
+
// Without an answer the post may have gone through: saying it failed would invite a second one.
|
|
1112
|
+
const cause = /[.!?]$/.test(err.message) ? err.message : `${err.message}.`;
|
|
1113
|
+
const reason = err.chatMayExist
|
|
1114
|
+
? `Creating the chat did not finish: ${cause} The chat may have been created, without the message: check with list_chats before trying again.`
|
|
1115
|
+
: err.outcomeUnknown
|
|
1116
|
+
? `Sending did not finish: ${cause} The message may have been posted: check with read_messages before sending again.`
|
|
1117
|
+
: `Sending failed: ${err.message}`;
|
|
1118
|
+
p.finish({ sent: false, reason }, true);
|
|
923
1119
|
sendResponse({ ok: false, error: err.message });
|
|
924
1120
|
}
|
|
925
1121
|
})();
|
|
@@ -953,6 +1149,37 @@ const RETRY_MAX_MS = 30_000;
|
|
|
953
1149
|
let retryMs = RETRY_MIN_MS;
|
|
954
1150
|
let retryTimer = null;
|
|
955
1151
|
|
|
1152
|
+
/**
|
|
1153
|
+
* The hub sends {type:"ping"} every keep-alive period (its welcome's heartbeatMs; 20 s in every
|
|
1154
|
+
* version). When none has come for about 2 periods (this long without heartbeatMs), the hub is
|
|
1155
|
+
* frozen or hung: drop its connection and connect again (or to whoever took the port over).
|
|
1156
|
+
*/
|
|
1157
|
+
export const HEARTBEAT_TIMEOUT_MS = 45_000;
|
|
1158
|
+
/** The watchdog never waits less: a busy machine runs timers late, and that isn't a frozen hub. */
|
|
1159
|
+
const MIN_HEARTBEAT_TIMEOUT_MS = 2_000;
|
|
1160
|
+
let watchdog = null; // { socket, ms, timer } of the current connection
|
|
1161
|
+
|
|
1162
|
+
/** (Re)starts the watchdog of `socket`: no ping within `ms` drops it and connects again. */
|
|
1163
|
+
function watchHeartbeat(socket, ms) {
|
|
1164
|
+
clearTimeout(watchdog?.timer);
|
|
1165
|
+
const timer = setTimeout(() => {
|
|
1166
|
+
if (watchdog?.timer !== timer) return;
|
|
1167
|
+
watchdog = null;
|
|
1168
|
+
if (ws !== socket) return;
|
|
1169
|
+
// Its close may never complete: nobody can be told the outcome of what came on it, now.
|
|
1170
|
+
withdrawAll(socket);
|
|
1171
|
+
reconnectNow();
|
|
1172
|
+
connect();
|
|
1173
|
+
}, ms);
|
|
1174
|
+
watchdog = { socket, ms, timer };
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function stopWatchdog(socket) {
|
|
1178
|
+
if (watchdog?.socket !== socket) return;
|
|
1179
|
+
clearTimeout(watchdog.timer);
|
|
1180
|
+
watchdog = null;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
956
1183
|
/** Drops the current connection (even one still closing) and connects again. */
|
|
957
1184
|
function reconnectNow() {
|
|
958
1185
|
const old = ws;
|
|
@@ -1007,11 +1234,15 @@ async function connect() {
|
|
|
1007
1234
|
let challenged = false;
|
|
1008
1235
|
let authed = false;
|
|
1009
1236
|
let opened = false;
|
|
1237
|
+
// The welcome's proof is checked asynchronously, and the server may send requests right behind
|
|
1238
|
+
// it: they wait for the check (in the order they came) instead of being dropped.
|
|
1239
|
+
let checking = null;
|
|
1010
1240
|
socket.onopen = () => (opened = true);
|
|
1011
1241
|
const refuse = (error) => {
|
|
1242
|
+
socket.close();
|
|
1243
|
+
if (ws !== socket) return; // replaced meanwhile: the newer connection's state stays
|
|
1012
1244
|
state = "unauthorized";
|
|
1013
1245
|
lastError = error;
|
|
1014
|
-
socket.close();
|
|
1015
1246
|
};
|
|
1016
1247
|
socket.onmessage = async (ev) => {
|
|
1017
1248
|
let msg;
|
|
@@ -1021,6 +1252,9 @@ async function connect() {
|
|
|
1021
1252
|
return;
|
|
1022
1253
|
}
|
|
1023
1254
|
if (!msg || typeof msg !== "object") return;
|
|
1255
|
+
if (checking) await checking;
|
|
1256
|
+
// Closed or replaced meanwhile: nobody could be told the outcome (its requests were withdrawn).
|
|
1257
|
+
if (ws !== socket || socket.readyState !== WebSocket.OPEN) return;
|
|
1024
1258
|
if (!authed) {
|
|
1025
1259
|
if (msg.type === "challenge" && !challenged && typeof msg.nonce === "string") {
|
|
1026
1260
|
challenged = true;
|
|
@@ -1028,23 +1262,33 @@ async function connect() {
|
|
|
1028
1262
|
if (!secret) return refuse("This copy of the extension has no pairing file. Run `npx mcp-teams setup` and load the extension from the folder it shows.");
|
|
1029
1263
|
const proof = await teams.pairingProof(secret, "client", msg.nonce);
|
|
1030
1264
|
socket.send(JSON.stringify({ type: "hello", proof, nonce, version: VERSION, browser: browserName(), instance }));
|
|
1031
|
-
} else if (msg.type === "welcome") {
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1265
|
+
} else if (msg.type === "welcome" && !checking) {
|
|
1266
|
+
const notProven = () => refuse(`The program on port ${port || DEFAULT_PORT} could not prove it is your mcp-teams server, so the extension doesn't answer it.`);
|
|
1267
|
+
checking = (async () => {
|
|
1268
|
+
if (!secret || msg.proof !== (await teams.pairingProof(secret, "server", nonce))) return notProven();
|
|
1269
|
+
if (ws !== socket || socket.readyState !== WebSocket.OPEN) return; // a newer connection's state stays
|
|
1270
|
+
authed = true;
|
|
1271
|
+
state = "connected";
|
|
1272
|
+
lastError = null;
|
|
1273
|
+
retryMs = RETRY_MIN_MS;
|
|
1274
|
+
const heartbeatMs = Number(msg.heartbeatMs);
|
|
1275
|
+
watchHeartbeat(socket, heartbeatMs > 0 ? Math.max(MIN_HEARTBEAT_TIMEOUT_MS, Math.round(2.25 * heartbeatMs)) : HEARTBEAT_TIMEOUT_MS);
|
|
1276
|
+
})().catch(notProven);
|
|
1277
|
+
await checking;
|
|
1039
1278
|
}
|
|
1040
1279
|
return;
|
|
1041
1280
|
}
|
|
1281
|
+
if (msg.type === "ping") {
|
|
1282
|
+
if (watchdog?.socket === socket) watchHeartbeat(socket, watchdog.ms);
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1042
1285
|
await dispatch(msg, (reply) => socket.readyState === WebSocket.OPEN && socket.send(JSON.stringify(reply)), socket);
|
|
1043
1286
|
};
|
|
1044
1287
|
socket.onclose = (ev) => {
|
|
1045
1288
|
// Nobody can be told the outcome of what came on this connection: withdraw it
|
|
1046
1289
|
// (an open confirmation window closes; nothing is sent).
|
|
1047
1290
|
withdrawAll(socket);
|
|
1291
|
+
stopWatchdog(socket);
|
|
1048
1292
|
if (ws && ws !== socket) return; // replaced by a newer connection (reconnectNow)
|
|
1049
1293
|
ws = null;
|
|
1050
1294
|
// When a server session ends, another one takes over the port within a second:
|
|
@@ -1072,25 +1316,79 @@ async function connect() {
|
|
|
1072
1316
|
/** Requests being run, by id, so they can be withdrawn (a cancel from the server, or their connection closed). */
|
|
1073
1317
|
const running = new Map();
|
|
1074
1318
|
|
|
1075
|
-
/**
|
|
1076
|
-
|
|
1319
|
+
/**
|
|
1320
|
+
* What a handler gets besides its params: whether the request was withdrawn (by the server,
|
|
1321
|
+
* its connection closing, or its deadline passing), onCancel(f) (returns a stop function),
|
|
1322
|
+
* check() (throws once withdrawn) and its absolute `deadline` (null without timeoutMs).
|
|
1323
|
+
*/
|
|
1324
|
+
function requestContext(conn, timeoutMs, sentDeadline) {
|
|
1077
1325
|
const listeners = new Set();
|
|
1326
|
+
let timer = null;
|
|
1327
|
+
// The server's own deadline when it sends one (newer servers): it stopped waiting then, and the
|
|
1328
|
+
// request may have taken a moment to arrive.
|
|
1329
|
+
const deadline = timeoutMs > 0 ? Math.min(Date.now() + timeoutMs, sentDeadline > 0 ? sentDeadline : Infinity) : null;
|
|
1330
|
+
const limitReached = () => `The request's time limit (${secondsOf(timeoutMs)} s) passed before Teams answered.`;
|
|
1078
1331
|
const ctx = {
|
|
1079
1332
|
conn,
|
|
1080
1333
|
cancelled: false,
|
|
1081
|
-
|
|
1334
|
+
reason: null,
|
|
1335
|
+
deadline,
|
|
1336
|
+
cancel(reason = "The request was withdrawn.") {
|
|
1082
1337
|
if (ctx.cancelled) return;
|
|
1083
1338
|
ctx.cancelled = true;
|
|
1339
|
+
ctx.reason = new Error(reason);
|
|
1340
|
+
clearTimeout(timer);
|
|
1084
1341
|
for (const f of [...listeners]) f();
|
|
1085
1342
|
},
|
|
1086
1343
|
onCancel(f) {
|
|
1087
1344
|
listeners.add(f);
|
|
1088
1345
|
return () => listeners.delete(f);
|
|
1089
1346
|
},
|
|
1347
|
+
check() {
|
|
1348
|
+
// The deadline counts even before its timer fired (e.g. a server deadline that passed on the way).
|
|
1349
|
+
if (!ctx.cancelled && deadline !== null && Date.now() >= deadline) ctx.cancel(limitReached());
|
|
1350
|
+
if (ctx.cancelled) throw ctx.reason;
|
|
1351
|
+
},
|
|
1352
|
+
/** The request ended: its deadline no longer matters. */
|
|
1353
|
+
end() {
|
|
1354
|
+
clearTimeout(timer);
|
|
1355
|
+
},
|
|
1090
1356
|
};
|
|
1357
|
+
if (deadline) timer = setTimeout(() => ctx.cancel(limitReached()), Math.max(0, deadline - Date.now()));
|
|
1091
1358
|
return ctx;
|
|
1092
1359
|
}
|
|
1093
1360
|
|
|
1361
|
+
/** `promise`, unless the request (ctx) is withdrawn first: then its reason. The promise itself goes on (others may share it). */
|
|
1362
|
+
function guarded(promise, ctx) {
|
|
1363
|
+
if (!ctx) return promise;
|
|
1364
|
+
if (ctx.cancelled) return Promise.reject(ctx.reason);
|
|
1365
|
+
return new Promise((resolve, reject) => {
|
|
1366
|
+
const stop = ctx.onCancel(() => reject(ctx.reason));
|
|
1367
|
+
promise.then(resolve, reject).finally(stop);
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* The hub's turn a request holds (newer hubs say so with `turn: true`): giveBack() lets other
|
|
1373
|
+
* calls have it ({type:"gate-wait"}), takeAgain() asks for one ({type:"gate-open"}) and resolves
|
|
1374
|
+
* when the hub's {type:"turn"} comes (given()).
|
|
1375
|
+
*/
|
|
1376
|
+
function hubTurn(id, send) {
|
|
1377
|
+
let resume = null;
|
|
1378
|
+
return {
|
|
1379
|
+
giveBack: () => send({ type: "gate-wait", id }),
|
|
1380
|
+
takeAgain: () =>
|
|
1381
|
+
new Promise((resolve) => {
|
|
1382
|
+
resume = resolve;
|
|
1383
|
+
send({ type: "gate-open", id });
|
|
1384
|
+
}),
|
|
1385
|
+
given() {
|
|
1386
|
+
resume?.();
|
|
1387
|
+
resume = null;
|
|
1388
|
+
},
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1094
1392
|
/** Withdraws the requests that came on `conn`: their answers can no longer arrive. */
|
|
1095
1393
|
function withdrawAll(conn) {
|
|
1096
1394
|
for (const ctx of running.values()) if (ctx.conn === conn) ctx.cancel();
|
|
@@ -1102,13 +1400,16 @@ function withdrawAll(conn) {
|
|
|
1102
1400
|
* an unknown one is ignored, as is any other message.
|
|
1103
1401
|
*/
|
|
1104
1402
|
export async function dispatch(msg, send, conn = null) {
|
|
1105
|
-
if (msg.type === "cancel") {
|
|
1403
|
+
if (msg.type === "cancel" || msg.type === "turn") {
|
|
1106
1404
|
const ctx = running.get(msg.id);
|
|
1107
|
-
if (ctx?.conn
|
|
1405
|
+
if (ctx?.conn !== conn) return;
|
|
1406
|
+
if (msg.type === "cancel") ctx.cancel();
|
|
1407
|
+
else ctx.turn?.given();
|
|
1108
1408
|
return;
|
|
1109
1409
|
}
|
|
1110
1410
|
if (msg.type !== "request") return;
|
|
1111
|
-
const ctx = requestContext(conn);
|
|
1411
|
+
const ctx = requestContext(conn, Number(msg.timeoutMs), Number(msg.deadline));
|
|
1412
|
+
if (msg.turn === true) ctx.turn = hubTurn(msg.id, send);
|
|
1112
1413
|
running.set(msg.id, ctx);
|
|
1113
1414
|
const handler = Object.hasOwn(handlers, msg.method) ? handlers[msg.method] : null;
|
|
1114
1415
|
try {
|
|
@@ -1118,6 +1419,7 @@ export async function dispatch(msg, send, conn = null) {
|
|
|
1118
1419
|
} catch (err) {
|
|
1119
1420
|
send({ type: "response", id: msg.id, ok: false, error: err?.message ?? String(err) });
|
|
1120
1421
|
} finally {
|
|
1422
|
+
ctx.end();
|
|
1121
1423
|
if (running.get(msg.id) === ctx) running.delete(msg.id);
|
|
1122
1424
|
}
|
|
1123
1425
|
}
|