mcp-teams 0.2.12 → 0.2.14
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 +334 -29
- package/dist/bridge.js.map +1 -1
- package/dist/fairqueue.js +83 -0
- package/dist/fairqueue.js.map +1 -0
- package/dist/tools.js +48 -23
- package/dist/tools.js.map +1 -1
- package/extension/background.js +494 -127
- package/extension/manifest.json +1 -1
- package/extension/teams.js +124 -10
- 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,52 +196,156 @@ 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.
|
|
296
|
+
*/
|
|
297
|
+
async function passGate(key, ctx, wait) {
|
|
298
|
+
for (let ms = gatedFor(key); ms > 0; ms = gatedFor(key)) {
|
|
299
|
+
const deadline = Math.min(ctx?.deadline ?? Infinity, Date.now() + REQUEST_TIMEOUT_MS);
|
|
300
|
+
if (!wait || Date.now() + ms + GATE_MARGIN_MS > deadline) throw busy(Date.now() + ms);
|
|
301
|
+
await guarded(sleep(ms), ctx);
|
|
302
|
+
}
|
|
303
|
+
ctx?.check();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* runInTab through the gate `key`. A 429 closes the gate for its Retry-After. Teams refused
|
|
308
|
+
* that request without running it, so a read (a GET, or a POST that only reads: `repeatable`)
|
|
309
|
+
* goes once more after the wait (when `wait` and it fits the deadline). Anything else, such as
|
|
310
|
+
* a post, is never sent again: the caller hears that Teams is busy.
|
|
311
|
+
*/
|
|
312
|
+
async function sendTo(key, tab, url, method, headers, body, ctx, wait, repeatable = method === "GET") {
|
|
313
|
+
for (let attempt = 0; ; attempt++) {
|
|
314
|
+
await passGate(key, ctx, wait);
|
|
315
|
+
const res = await runInTab(tab, url, method, headers, body, ctx);
|
|
316
|
+
if (res.status !== 429) return res;
|
|
317
|
+
const until = Math.max(gates.get(key) ?? 0, Date.now() + teams.retryAfterMs(res.retryAfter));
|
|
318
|
+
gates.set(key, until);
|
|
319
|
+
if (!repeatable || attempt > 0) throw busy(until);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
222
323
|
/**
|
|
223
324
|
* 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).
|
|
325
|
+
* full URL on it). Returns the parsed JSON (null for an empty answer). While Teams throttles
|
|
326
|
+
* the service, the call waits that out if it fits the deadline; `wait: false` (for lookups
|
|
327
|
+
* that can be done without) fails at once as busy instead. `repeatable`: a POST that only
|
|
328
|
+
* reads (a search, a profile lookup), sent again after a 429 like a GET.
|
|
225
329
|
*/
|
|
226
|
-
async function api(service, path, { method = "GET", body, raw = false, withLocation = false } = {}) {
|
|
330
|
+
async function api(service, path, { method = "GET", body, raw = false, withLocation = false, ctx = null, wait = true, repeatable = method === "GET" } = {}) {
|
|
331
|
+
ctx?.check();
|
|
227
332
|
let cap = await getCapture(service);
|
|
228
|
-
|
|
333
|
+
// Opening a tab or waiting for a session can take a minute; a withdrawn request stops waiting.
|
|
334
|
+
let tab = await guarded(findTeamsTab(cap ? new URL(cap.base).origin : null), ctx);
|
|
229
335
|
if (!cap) {
|
|
230
|
-
cap = await refreshSession(service, tab, { quickWaitMs: service === "substrate" ? 2_000 : 4_000 });
|
|
336
|
+
cap = await guarded(refreshSession(service, tab, { quickWaitMs: service === "substrate" ? 2_000 : 4_000 }), ctx);
|
|
231
337
|
if (!cap) throw new Error(SERVICE_HELP[service]);
|
|
232
338
|
}
|
|
233
339
|
const urlFor = (c) => teams.serviceUrl(path, c.base);
|
|
234
|
-
let res = await
|
|
340
|
+
let res = await sendTo(gateKey(service, cap.base), tab, urlFor(cap), method, cap.headers, body, ctx, wait, repeatable);
|
|
235
341
|
if (res.status === 401) {
|
|
236
342
|
// Rejected before running (expired token), so trying once more is safe. A 403 is a
|
|
237
343
|
// real answer (e.g. no longer a member of that chat).
|
|
238
|
-
|
|
344
|
+
meetingSessionsCache.clear(); // the session (maybe the account) is changing
|
|
345
|
+
cap = await guarded(refreshSession(service, tab, { quickWaitMs: 0 }), ctx);
|
|
239
346
|
if (!cap) throw new Error(SERVICE_HELP[service]);
|
|
240
|
-
tab = await findTeamsTab(new URL(cap.base).origin);
|
|
241
|
-
res = await
|
|
347
|
+
tab = await guarded(findTeamsTab(new URL(cap.base).origin), ctx);
|
|
348
|
+
res = await sendTo(gateKey(service, cap.base), tab, urlFor(cap), method, cap.headers, body, ctx, wait, repeatable);
|
|
242
349
|
}
|
|
243
350
|
if (raw) return res;
|
|
244
351
|
if (res.status >= 400) {
|
|
@@ -271,54 +378,106 @@ async function myMri() {
|
|
|
271
378
|
}
|
|
272
379
|
|
|
273
380
|
const LIST_CACHE_MS = 60_000;
|
|
274
|
-
let listCache = null; // {
|
|
381
|
+
let listCache = null; // { until, data: { teams, chats, meetings, namesMissing } }
|
|
275
382
|
|
|
276
|
-
/**
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
383
|
+
/**
|
|
384
|
+
* Teams, channels and chats (csa), with names for untitled chats. Cached for a minute, but
|
|
385
|
+
* when names are missing (`namesMissing`: { chats, organizers }, see lookupNames) only until
|
|
386
|
+
* Teams' throttle is over, or not at all after another failure, so they are looked up again.
|
|
387
|
+
* A meeting whose organizer's name is missing that way has `organizerNameMissing`.
|
|
388
|
+
*/
|
|
389
|
+
async function teamsAndChats(fresh = false, ctx = null) {
|
|
390
|
+
if (!fresh && listCache && Date.now() < listCache.until) return listCache.data;
|
|
391
|
+
const json = await api("csa", "/api/v1/teams/users/me?isPrefetch=false&enableMembershipSummary=true&enableRC2Fetch=false", { ctx });
|
|
280
392
|
const data = { teams: teams.parseTeams(json), chats: teams.parseCsaChats(json), meetings: teams.parseCsaMeetings(json) };
|
|
281
|
-
await nameChats(data.chats);
|
|
282
|
-
await lookupNames(data.meetings.map((m) => m.organizer));
|
|
283
|
-
for (const m of data.meetings)
|
|
284
|
-
|
|
393
|
+
const chats = await nameChats(data.chats, ctx);
|
|
394
|
+
const organizers = await lookupNames(data.meetings.map((m) => m.organizer), ctx);
|
|
395
|
+
for (const m of data.meetings) {
|
|
396
|
+
m.organizerName = names.get(m.organizer) ?? null;
|
|
397
|
+
if (!m.organizerName && organizers?.mris.has(m.organizer)) m.organizerNameMissing = organizers;
|
|
398
|
+
}
|
|
399
|
+
data.namesMissing = { chats, organizers };
|
|
400
|
+
const missing = [chats, organizers].filter(Boolean);
|
|
401
|
+
const until = missing.some((x) => !x.throttled) ? 0 : missing.length ? Math.max(...missing.map((x) => x.until)) : Date.now() + LIST_CACHE_MS;
|
|
402
|
+
listCache = { until, data };
|
|
285
403
|
return data;
|
|
286
404
|
}
|
|
287
405
|
|
|
406
|
+
/** A result's note when names are missing (`missing`, see lookupNames): throttled, or why. */
|
|
407
|
+
function namesNote(what, missing) {
|
|
408
|
+
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}`;
|
|
409
|
+
const wait = missing.until - Date.now();
|
|
410
|
+
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."}`;
|
|
411
|
+
}
|
|
412
|
+
|
|
288
413
|
const names = new Map(); // mri -> display name; bounded, starts over when full
|
|
289
414
|
const NAMES_MAX = 5000;
|
|
290
415
|
|
|
291
|
-
/**
|
|
292
|
-
|
|
416
|
+
/**
|
|
417
|
+
* Looks up display names for MRIs not seen yet (mt short profiles, 100 at a time). Returns
|
|
418
|
+
* null when it could ask for all of them, else why some were left out (the caller says so):
|
|
419
|
+
* { throttled: true, until } while Teams throttles mt, or { error } for another failure, with
|
|
420
|
+
* `mris`: those it couldn't ask for (the failed batches, and the ones a throttle left out).
|
|
421
|
+
* Names are a nicety, so it doesn't wait out a throttle unless `wait`.
|
|
422
|
+
*/
|
|
423
|
+
async function lookupNames(mris, ctx = null, { wait = false } = {}) {
|
|
293
424
|
const unknown = [...new Set(mris)].filter((m) => m && !names.has(m) && /^8:/.test(m));
|
|
425
|
+
let missing = null;
|
|
426
|
+
const notAsked = new Set();
|
|
294
427
|
for (let i = 0; i < unknown.length; i += 100) {
|
|
428
|
+
if (missing?.throttled) {
|
|
429
|
+
for (const m of unknown.slice(i)) notAsked.add(m);
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
// Names that can't be looked up are left out, but a withdrawn request ends here (and isn't cached).
|
|
433
|
+
ctx?.check();
|
|
295
434
|
const batch = unknown.slice(i, i + 100);
|
|
296
435
|
const json = await api("mt", "/beta/users/fetchShortProfile?isMailAddress=false&enableGuest=true&includeIBBarredUsers=true&skypeTeamsInfo=true", {
|
|
297
436
|
method: "POST",
|
|
298
437
|
body: batch,
|
|
299
|
-
|
|
438
|
+
ctx,
|
|
439
|
+
wait,
|
|
440
|
+
repeatable: true,
|
|
441
|
+
}).catch((err) => {
|
|
442
|
+
ctx?.check(); // a withdrawn request ends here (and isn't cached)
|
|
443
|
+
missing = err?.throttled ? { throttled: true, until: err.until } : (missing ?? { error: err?.message ?? String(err) });
|
|
444
|
+
for (const m of batch) notAsked.add(m);
|
|
445
|
+
return null;
|
|
446
|
+
});
|
|
300
447
|
if (names.size > NAMES_MAX) names.clear();
|
|
301
448
|
for (const p of teams.parseProfiles(json)) if (p.mri && p.name) names.set(p.mri, p.name);
|
|
302
449
|
}
|
|
450
|
+
ctx?.check();
|
|
451
|
+
return missing && { ...missing, mris: notAsked };
|
|
303
452
|
}
|
|
304
453
|
|
|
305
|
-
|
|
454
|
+
/** Names untitled chats by their members; returns why names are missing, if they are (see lookupNames). */
|
|
455
|
+
async function nameChats(chats, ctx = null) {
|
|
306
456
|
const me = await myMri();
|
|
307
|
-
await lookupNames(chats.filter((c) => !c.title).flatMap((c) => c.members));
|
|
457
|
+
const missing = await lookupNames(chats.filter((c) => !c.title).flatMap((c) => c.members), ctx);
|
|
308
458
|
for (const c of chats) {
|
|
309
459
|
c.memberNames = c.members.filter((m) => m !== me).map((m) => names.get(m)).filter(Boolean);
|
|
310
460
|
c.title = teams.chatTitle(c, names, me);
|
|
311
461
|
}
|
|
462
|
+
return missing;
|
|
312
463
|
}
|
|
313
464
|
|
|
314
|
-
async function resolve(conversation) {
|
|
465
|
+
async function resolve(conversation, ctx = null) {
|
|
315
466
|
const q = String(conversation ?? "").trim();
|
|
316
467
|
if (!q) throw new Error("Say which chat or channel: an id, 'Team/Channel', a chat title or a person's name.");
|
|
317
468
|
if (/^(19|48):/.test(q)) {
|
|
318
|
-
const data = await teamsAndChats().catch(() => ({ teams: [], chats: [] }));
|
|
469
|
+
const data = await teamsAndChats(false, ctx).catch(() => ({ teams: [], chats: [] }));
|
|
470
|
+
ctx?.check();
|
|
319
471
|
return describe(q.split(";messageid=")[0], data);
|
|
320
472
|
}
|
|
321
|
-
|
|
473
|
+
const data = await teamsAndChats(false, ctx);
|
|
474
|
+
try {
|
|
475
|
+
return teams.resolveConversation(q, data);
|
|
476
|
+
} catch (err) {
|
|
477
|
+
// Untitled chats are found by their members' names: without them a match can be missed.
|
|
478
|
+
if (data.namesMissing?.chats) err.message += `\n${namesNote("Chat names", data.namesMissing.chats)}`;
|
|
479
|
+
throw err;
|
|
480
|
+
}
|
|
322
481
|
}
|
|
323
482
|
|
|
324
483
|
/** What we know about a conversation id from the lists. */
|
|
@@ -334,7 +493,7 @@ function describe(id, { teams: ts, chats }) {
|
|
|
334
493
|
}
|
|
335
494
|
|
|
336
495
|
/** People search as in Teams' people picker (substrate suggestions). */
|
|
337
|
-
async function searchPeople(query, limit) {
|
|
496
|
+
async function searchPeople(query, limit, ctx = null) {
|
|
338
497
|
const body = {
|
|
339
498
|
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"] }],
|
|
340
499
|
cvid: crypto.randomUUID(),
|
|
@@ -342,26 +501,28 @@ async function searchPeople(query, limit) {
|
|
|
342
501
|
AppName: "Microsoft Teams",
|
|
343
502
|
Scenario: { Name: "peoplepicker.newChat" },
|
|
344
503
|
};
|
|
345
|
-
return teams.parseSuggestions(await api("substrate", "/search/api/v1/suggestions?scenario=peoplepicker.newChat", { method: "POST", body }));
|
|
504
|
+
return teams.parseSuggestions(await api("substrate", "/search/api/v1/suggestions?scenario=peoplepicker.newChat", { method: "POST", body, ctx, repeatable: true }));
|
|
346
505
|
}
|
|
347
506
|
|
|
348
507
|
/** Each name or address as the one person it means (see teams.pickPerson). */
|
|
349
|
-
async function resolvePeople(queries, me) {
|
|
508
|
+
async function resolvePeople(queries, me, ctx = null) {
|
|
350
509
|
const people = [];
|
|
351
510
|
for (const q of queries) {
|
|
352
511
|
const query = String(q).trim();
|
|
353
512
|
if (/^8:orgid:/i.test(query)) {
|
|
354
|
-
|
|
513
|
+
// The window must show who it is: wait out a throttle if the deadline allows, else fail as busy.
|
|
514
|
+
const missing = await lookupNames([query], ctx, { wait: true });
|
|
515
|
+
if (missing?.throttled) throw busy(missing.until);
|
|
355
516
|
people.push({ mri: query, name: names.get(query) ?? query, email: null });
|
|
356
517
|
continue;
|
|
357
518
|
}
|
|
358
|
-
let found = await searchPeople(query, 10);
|
|
519
|
+
let found = await searchPeople(query, 10, ctx);
|
|
359
520
|
// People search can miss a full name it would find by one of its words (seen with
|
|
360
521
|
// "KIM MIN JUN" for "김민준(KIM MIN JUN)"): then search each word, keeping exact names.
|
|
361
522
|
const words = query.split(/\s+/).filter((w) => w.length > 1);
|
|
362
523
|
if (!found.length && words.length > 1) {
|
|
363
524
|
for (const w of words) {
|
|
364
|
-
found = (await searchPeople(w, 25)).filter((p) => teams.sameName(p.name, query));
|
|
525
|
+
found = (await searchPeople(w, 25, ctx)).filter((p) => teams.sameName(p.name, query));
|
|
365
526
|
if (found.length) break;
|
|
366
527
|
}
|
|
367
528
|
}
|
|
@@ -411,20 +572,22 @@ async function sharepointTab(origin) {
|
|
|
411
572
|
}
|
|
412
573
|
|
|
413
574
|
/** A GET on SharePoint with the browser's own SharePoint session. */
|
|
414
|
-
async function sharepointGet(url) {
|
|
575
|
+
async function sharepointGet(url, ctx = null) {
|
|
415
576
|
if (!SHAREPOINT_URL.test(url)) throw new Error("Not a SharePoint URL");
|
|
416
|
-
|
|
577
|
+
ctx?.check();
|
|
578
|
+
const origin = new URL(url).origin;
|
|
579
|
+
return sendTo(gateKey("sharepoint", origin), await guarded(sharepointTab(origin), ctx), url, "GET", {}, null, ctx, true);
|
|
417
580
|
}
|
|
418
581
|
|
|
419
582
|
const transcripts = new Map(); // transcript URL -> parsed transcript; a finished transcript doesn't change
|
|
420
583
|
const TRANSCRIPTS_MAX = 50;
|
|
421
584
|
|
|
422
585
|
/** A call's transcript (Stream transcript.json) from the organizer's OneDrive. */
|
|
423
|
-
async function transcriptOf(info, session) {
|
|
586
|
+
async function transcriptOf(info, session, ctx = null) {
|
|
424
587
|
const cached = transcripts.get(session.transcriptUrl);
|
|
425
588
|
if (cached) return cached;
|
|
426
589
|
const url = session.transcriptUrl + (session.transcriptUrl.includes("?") ? "&" : "?") + "format=json";
|
|
427
|
-
const res = await sharepointGet(url);
|
|
590
|
+
const res = await sharepointGet(url, ctx);
|
|
428
591
|
if (res.status === 403 || res.status === 404) {
|
|
429
592
|
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.`);
|
|
430
593
|
}
|
|
@@ -436,18 +599,42 @@ async function transcriptOf(info, session) {
|
|
|
436
599
|
}
|
|
437
600
|
|
|
438
601
|
/** A meeting chat by id, title or subject. */
|
|
439
|
-
async function resolveMeeting(meeting) {
|
|
440
|
-
const conv = await resolve(meeting);
|
|
441
|
-
const info = (await teamsAndChats()).meetings.find((m) => m.id === conv.id);
|
|
602
|
+
async function resolveMeeting(meeting, ctx = null) {
|
|
603
|
+
const conv = await resolve(meeting, ctx);
|
|
604
|
+
const info = (await teamsAndChats(false, ctx)).meetings.find((m) => m.id === conv.id);
|
|
442
605
|
if (!info) throw new Error(`"${conv.title ?? conv.id}" is not a meeting chat. list_meetings lists them.`);
|
|
443
606
|
return info;
|
|
444
607
|
}
|
|
445
608
|
|
|
446
|
-
|
|
447
|
-
|
|
609
|
+
const SESSIONS_CACHE_MS = 60_000;
|
|
610
|
+
const SESSIONS_CACHE_MAX = 50;
|
|
611
|
+
/** "<chatsvc base>\n<meeting id>" -> { at, sessions }: one tenant's meetings never answer another's. */
|
|
612
|
+
const meetingSessionsCache = new Map();
|
|
613
|
+
const sessionsKey = (cap, id) => (cap ? `${cap.base}\n${id}` : null);
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* The calls held in a meeting chat (see teams.meetingSessions). Cached for a minute, so
|
|
617
|
+
* read_transcript's continueFrom pages and get_meeting don't read the whole chat again.
|
|
618
|
+
*/
|
|
619
|
+
async function sessionsOf(info, ctx = null) {
|
|
620
|
+
const key = sessionsKey(await getCapture("chatsvc"), info.id);
|
|
621
|
+
const hit = key && meetingSessionsCache.get(key);
|
|
622
|
+
if (hit && Date.now() - hit.at < SESSIONS_CACHE_MS) return hit.sessions;
|
|
623
|
+
const sessions = await readSessions(info, ctx);
|
|
624
|
+
// Filed under the base the chat was read from (a 401 on the way may have changed it).
|
|
625
|
+
const keyNow = sessionsKey(await getCapture("chatsvc"), info.id);
|
|
626
|
+
if (keyNow) {
|
|
627
|
+
meetingSessionsCache.delete(keyNow);
|
|
628
|
+
if (meetingSessionsCache.size >= SESSIONS_CACHE_MAX) meetingSessionsCache.delete(meetingSessionsCache.keys().next().value);
|
|
629
|
+
meetingSessionsCache.set(keyNow, { at: Date.now(), sessions });
|
|
630
|
+
}
|
|
631
|
+
return sessions;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function readSessions(info, ctx = null) {
|
|
448
635
|
let raw;
|
|
449
636
|
try {
|
|
450
|
-
raw = await fetchMessages(info.id, { limit: 2000, includeSystem: true });
|
|
637
|
+
raw = await fetchMessages(info.id, { limit: 2000, includeSystem: true, ctx });
|
|
451
638
|
} catch (err) {
|
|
452
639
|
if (/HTTP 403/.test(err.message) && info.external) {
|
|
453
640
|
throw new Error(`"${info.title}" was organized by another organization, and Teams no longer lets you read its meeting chat.`);
|
|
@@ -476,25 +663,24 @@ const messagesTarget = (id, threadId) => (threadId ? `${id};messageid=${threadId
|
|
|
476
663
|
* fetchMessages with where each message came from: `linkOf` (message id → the link of its
|
|
477
664
|
* page), `first` (the first page's link), `next` (the backward link not followed yet, if
|
|
478
665
|
* any) and `capped` (stopped after MAX_PAGES pages short of `limit`). Starts at the newest
|
|
479
|
-
* page, or at `from` (a link from a cursor, see teams.readMessagesCursor)
|
|
666
|
+
* page, or at `from` (a link from a cursor, see teams.readMessagesCursor), and takes only
|
|
667
|
+
* messages older than `olderThan` ({ time, id }, see teams.compareMessageKeys) if given.
|
|
480
668
|
*/
|
|
481
|
-
async function fetchMessagePages(id, { limit,
|
|
669
|
+
async function fetchMessagePages(id, { limit, olderThan, includeSystem, threadId, from, ctx = null }) {
|
|
482
670
|
const target = messagesTarget(id, threadId);
|
|
483
671
|
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`;
|
|
484
672
|
let path = first;
|
|
485
|
-
const beforeMs = before ? Date.parse(before) : null;
|
|
486
673
|
const raw = [];
|
|
487
674
|
const linkOf = new Map();
|
|
488
675
|
let counted = 0;
|
|
489
676
|
let pages = 0;
|
|
490
677
|
while (path) {
|
|
491
678
|
if (pages === MAX_PAGES) return { raw, linkOf, first, next: path, capped: true, pages };
|
|
492
|
-
const json = await api("chatsvc", path);
|
|
679
|
+
const json = await api("chatsvc", path, { ctx });
|
|
493
680
|
pages++;
|
|
494
681
|
const msgs = json?.messages ?? [];
|
|
495
682
|
for (const m of msgs) {
|
|
496
|
-
|
|
497
|
-
if (beforeMs && t >= beforeMs) continue;
|
|
683
|
+
if (olderThan && teams.compareMessageKeys(teams.messageKey(m), olderThan) >= 0) continue;
|
|
498
684
|
raw.push(m);
|
|
499
685
|
linkOf.set(String(m.id ?? m.clientmessageid ?? ""), path);
|
|
500
686
|
if (includeSystem || !/^(ThreadActivity\/|Event\/|Control\/)/i.test(m.messagetype ?? "")) counted++;
|
|
@@ -525,41 +711,42 @@ export const handlers = {
|
|
|
525
711
|
};
|
|
526
712
|
},
|
|
527
713
|
|
|
528
|
-
async listTeams() {
|
|
529
|
-
return { teams: (await teamsAndChats(true)).teams };
|
|
714
|
+
async listTeams(_, ctx) {
|
|
715
|
+
return { teams: (await teamsAndChats(true, ctx)).teams };
|
|
530
716
|
},
|
|
531
717
|
|
|
532
|
-
async listChats({ query, limit = 30, unreadOnly = false }) {
|
|
533
|
-
|
|
718
|
+
async listChats({ query, limit = 30, unreadOnly = false }, ctx) {
|
|
719
|
+
const data = await teamsAndChats(true, ctx);
|
|
720
|
+
let chats = data.chats;
|
|
534
721
|
if (unreadOnly) chats = chats.filter((c) => c.unread);
|
|
535
722
|
if (query) chats = chats.filter((c) => teams.matchesWords(c.title, query) || c.memberNames?.some((n) => teams.matchesWords(n, query)));
|
|
536
723
|
chats.sort((a, b) => (b.lastMessage?.time ?? "").localeCompare(a.lastMessage?.time ?? ""));
|
|
537
724
|
return {
|
|
538
725
|
total: chats.length,
|
|
539
726
|
chats: chats.slice(0, limit).map(({ members, memberNames, lastFrom, ...c }) => ({ ...c, memberCount: members.length || undefined })),
|
|
727
|
+
...(data.namesMissing.chats ? { note: namesNote("Chat names", data.namesMissing.chats) } : {}),
|
|
540
728
|
};
|
|
541
729
|
},
|
|
542
730
|
|
|
543
|
-
async readMessages({ conversation, limit = 30, before, cursor, includeSystem = false, threadId }) {
|
|
731
|
+
async readMessages({ conversation, limit = 30, before, cursor, includeSystem = false, threadId, maxLength }, ctx) {
|
|
544
732
|
if (cursor && before) throw new Error("Give either cursor or before, not both (cursor already knows where to go on).");
|
|
545
|
-
const conv = await resolve(conversation);
|
|
733
|
+
const conv = await resolve(conversation, ctx);
|
|
546
734
|
// A cursor resumes at the page that held the oldest message returned last time, and
|
|
547
|
-
// only takes what is older than that message.
|
|
735
|
+
// only takes what is older than that message (by time, then id).
|
|
548
736
|
const from = cursor ? teams.readMessagesCursor(cursor, messagesTarget(conv.id, threadId)) : null;
|
|
549
737
|
if (from) before = from.before ?? undefined;
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
const more =
|
|
554
|
-
messages = messages.slice(-limit);
|
|
555
|
-
const oldest = messages[0];
|
|
738
|
+
const mark = from ? { time: from.before, id: from.id } : { time: before ?? null, id: null };
|
|
739
|
+
const olderThan = mark.time != null || mark.id != null ? mark : null;
|
|
740
|
+
const { raw, linkOf, first, next, capped, pages } = await fetchMessagePages(conv.id, { limit, olderThan, includeSystem, threadId, from: from?.path, ctx });
|
|
741
|
+
const { messages, more, oldest } = teams.newestEntries(raw, { limit, includeSystem, channel: conv.kind === "channel" && !threadId });
|
|
556
742
|
let olderCursor = null;
|
|
557
|
-
if (oldest && (more || next)) olderCursor = teams.messagesCursor(linkOf.get(oldest.id) ?? first, oldest.time);
|
|
558
|
-
else if (!oldest && next) olderCursor = teams.messagesCursor(next,
|
|
743
|
+
if (oldest && (more || next)) olderCursor = teams.messagesCursor(linkOf.get(oldest.id) ?? first, oldest.time, oldest.id);
|
|
744
|
+
else if (!oldest && next) olderCursor = teams.messagesCursor(next, olderThan?.time ?? null, olderThan?.id ?? null);
|
|
559
745
|
return {
|
|
560
746
|
conversation: conv,
|
|
561
747
|
count: messages.length,
|
|
562
|
-
|
|
748
|
+
// Cut here, so long texts don't cross the bridge only to be cut by the server.
|
|
749
|
+
messages: teams.capText(messages, maxLength),
|
|
563
750
|
...(more || raw.length ? { olderBefore: oldest?.time ?? null } : {}),
|
|
564
751
|
...(olderCursor ? { olderCursor } : {}),
|
|
565
752
|
...(capped && messages.length < limit
|
|
@@ -571,7 +758,7 @@ export const handlers = {
|
|
|
571
758
|
};
|
|
572
759
|
},
|
|
573
760
|
|
|
574
|
-
async searchMessages({ query, limit = 25, offset = 0 }) {
|
|
761
|
+
async searchMessages({ query, limit = 25, offset = 0 }, ctx) {
|
|
575
762
|
const body = {
|
|
576
763
|
entityRequests: [
|
|
577
764
|
{
|
|
@@ -589,40 +776,44 @@ export const handlers = {
|
|
|
589
776
|
logicalId: crypto.randomUUID(),
|
|
590
777
|
scenario: { Dimensions: [{ DimensionName: "QueryType", DimensionValue: "All" }, { DimensionName: "FormFactor", DimensionValue: "general.web.reactSearch" }], Name: "powerbar" },
|
|
591
778
|
};
|
|
592
|
-
const r = teams.parseSearch(await api("substrate", "/searchservice/api/v2/query", { method: "POST", body }));
|
|
779
|
+
const r = teams.parseSearch(await api("substrate", "/searchservice/api/v2/query", { method: "POST", body, ctx, repeatable: true }));
|
|
593
780
|
return { query, ...r, results: r.results.slice(0, limit) };
|
|
594
781
|
},
|
|
595
782
|
|
|
596
|
-
async findPeople({ query, limit = 10 }) {
|
|
597
|
-
const people = await searchPeople(query, limit);
|
|
783
|
+
async findPeople({ query, limit = 10 }, ctx) {
|
|
784
|
+
const people = await searchPeople(query, limit, ctx);
|
|
598
785
|
return { count: people.length, people };
|
|
599
786
|
},
|
|
600
787
|
|
|
601
|
-
async listMeetings({ query, limit = 20, transcriptOnly = false }) {
|
|
602
|
-
|
|
788
|
+
async listMeetings({ query, limit = 20, transcriptOnly = false }, ctx) {
|
|
789
|
+
const data = await teamsAndChats(true, ctx);
|
|
790
|
+
let meetings = data.meetings;
|
|
603
791
|
if (query) meetings = meetings.filter((m) => teams.matchesWords(m.title, query) || teams.matchesWords(m.organizerName, query));
|
|
604
792
|
if (transcriptOnly) meetings = meetings.filter((m) => m.hasTranscript);
|
|
605
793
|
meetings = [...meetings].sort((a, b) => (b.lastActivity ?? "").localeCompare(a.lastActivity ?? ""));
|
|
606
794
|
return {
|
|
607
795
|
total: meetings.length,
|
|
608
|
-
meetings: meetings.slice(0, limit).map(({ organizer, organizerName, joinUrl, lastJoinAt, ...m }) => ({ ...m, organizer: organizerName ?? organizer })),
|
|
796
|
+
meetings: meetings.slice(0, limit).map(({ organizer, organizerName, organizerNameMissing, joinUrl, lastJoinAt, ...m }) => ({ ...m, organizer: organizerName ?? organizer })),
|
|
797
|
+
...(data.namesMissing.organizers ? { note: namesNote("Organizer names", data.namesMissing.organizers) } : {}),
|
|
609
798
|
};
|
|
610
799
|
},
|
|
611
800
|
|
|
612
|
-
async getMeeting({ meeting, limit = 10 }) {
|
|
613
|
-
const info = await resolveMeeting(meeting);
|
|
614
|
-
const sessions = await sessionsOf(info);
|
|
615
|
-
const { organizer, organizerName, lastJoinAt, ...rest } = info;
|
|
801
|
+
async getMeeting({ meeting, limit = 10 }, ctx) {
|
|
802
|
+
const info = await resolveMeeting(meeting, ctx);
|
|
803
|
+
const sessions = await sessionsOf(info, ctx);
|
|
804
|
+
const { organizer, organizerName, organizerNameMissing, lastJoinAt, ...rest } = info;
|
|
616
805
|
return {
|
|
617
806
|
meeting: { ...rest, organizer: organizerName ?? organizer },
|
|
618
807
|
sessionCount: sessions.length,
|
|
619
808
|
sessions: sessions.slice(-limit).reverse().map(({ transcriptUrl, ...s }) => s),
|
|
809
|
+
// Only when its own lookup failed; the list it came from isn't kept long then (teamsAndChats).
|
|
810
|
+
...(organizerNameMissing ? { note: namesNote("Organizer name", organizerNameMissing) } : {}),
|
|
620
811
|
};
|
|
621
812
|
},
|
|
622
813
|
|
|
623
|
-
async readTranscript({ meeting, session, from, maxLength = 40_000 }) {
|
|
624
|
-
const info = await resolveMeeting(meeting);
|
|
625
|
-
const all = await sessionsOf(info);
|
|
814
|
+
async readTranscript({ meeting, session, from, maxLength = 40_000 }, ctx) {
|
|
815
|
+
const info = await resolveMeeting(meeting, ctx);
|
|
816
|
+
const all = await sessionsOf(info, ctx);
|
|
626
817
|
const withTranscript = all.filter((s) => s.transcriptUrl);
|
|
627
818
|
if (!withTranscript.length) {
|
|
628
819
|
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.`);
|
|
@@ -636,7 +827,7 @@ export const handlers = {
|
|
|
636
827
|
}
|
|
637
828
|
s = matches.at(-1);
|
|
638
829
|
}
|
|
639
|
-
const json = await transcriptOf(info, s);
|
|
830
|
+
const json = await transcriptOf(info, s, ctx);
|
|
640
831
|
const fromSeconds = from ? (String(from).split(":").reduce((a, x) => a * 60 + Number(x), 0) || 0) : 0;
|
|
641
832
|
const lines = teams.transcriptLines(json, { fromSeconds });
|
|
642
833
|
let text = "";
|
|
@@ -658,26 +849,32 @@ export const handlers = {
|
|
|
658
849
|
};
|
|
659
850
|
},
|
|
660
851
|
|
|
661
|
-
async searchTranscripts({ query, meeting, limit = 30, meetings: maxMeetings = 10 }) {
|
|
662
|
-
let list = meeting ? [await resolveMeeting(meeting)] : (await teamsAndChats(true)).meetings.filter((m) => m.hasTranscript);
|
|
852
|
+
async searchTranscripts({ query, meeting, limit = 30, meetings: maxMeetings = 10 }, ctx) {
|
|
853
|
+
let list = meeting ? [await resolveMeeting(meeting, ctx)] : (await teamsAndChats(true, ctx)).meetings.filter((m) => m.hasTranscript);
|
|
663
854
|
list = [...list].sort((a, b) => (b.lastActivity ?? "").localeCompare(a.lastActivity ?? "")).slice(0, maxMeetings);
|
|
664
855
|
const results = [];
|
|
665
856
|
const skipped = [];
|
|
666
857
|
let total = 0;
|
|
667
858
|
let shown = 0;
|
|
859
|
+
let searched = 0;
|
|
668
860
|
for (const info of list) {
|
|
861
|
+
if (shown >= limit) break; // enough matches; older meetings aren't read
|
|
862
|
+
ctx?.check(); // withdrawn: no more meetings are read
|
|
863
|
+
searched++;
|
|
669
864
|
let sessions;
|
|
670
865
|
try {
|
|
671
|
-
sessions = (await sessionsOf(info)).filter((s) => s.transcriptUrl);
|
|
866
|
+
sessions = (await sessionsOf(info, ctx)).filter((s) => s.transcriptUrl);
|
|
672
867
|
} catch (err) {
|
|
868
|
+
ctx?.check();
|
|
673
869
|
skipped.push({ meeting: info.title, reason: err.message });
|
|
674
870
|
continue;
|
|
675
871
|
}
|
|
676
872
|
for (const s of sessions.reverse()) {
|
|
677
873
|
let json;
|
|
678
874
|
try {
|
|
679
|
-
json = await transcriptOf(info, s);
|
|
875
|
+
json = await transcriptOf(info, s, ctx);
|
|
680
876
|
} catch (err) {
|
|
877
|
+
ctx?.check();
|
|
681
878
|
skipped.push({ meeting: info.title, session: s.start, reason: err.message });
|
|
682
879
|
continue;
|
|
683
880
|
}
|
|
@@ -690,17 +887,25 @@ export const handlers = {
|
|
|
690
887
|
}
|
|
691
888
|
}
|
|
692
889
|
}
|
|
693
|
-
|
|
890
|
+
const notSearched = list.length - searched;
|
|
891
|
+
return {
|
|
892
|
+
query,
|
|
893
|
+
meetingsSearched: searched,
|
|
894
|
+
matches: total,
|
|
895
|
+
results,
|
|
896
|
+
...(skipped.length ? { skipped } : {}),
|
|
897
|
+
...(notSearched ? { notSearched, note: `Stopped at limit: ${notSearched} older meetings with a transcript weren't searched. Raise limit to search them.` } : {}),
|
|
898
|
+
};
|
|
694
899
|
},
|
|
695
900
|
|
|
696
901
|
/** Read-only diagnostics for development: a GET on a service, never its headers. */
|
|
697
|
-
async probe({ service, path, url }) {
|
|
902
|
+
async probe({ service, path, url }, ctx) {
|
|
698
903
|
if (url) {
|
|
699
|
-
const res = await sharepointGet(url);
|
|
904
|
+
const res = await sharepointGet(url, ctx);
|
|
700
905
|
return { status: res.status, text: res.text.slice(0, 20_000_000) };
|
|
701
906
|
}
|
|
702
907
|
if (!["chatsvc", "csa", "mt", "substrate"].includes(service)) throw new Error("Unknown service");
|
|
703
|
-
const res = await api(service, path, { raw: true });
|
|
908
|
+
const res = await api(service, path, { raw: true, ctx });
|
|
704
909
|
return { status: res.status, text: res.text.slice(0, 20_000_000) };
|
|
705
910
|
},
|
|
706
911
|
|
|
@@ -723,31 +928,31 @@ export const handlers = {
|
|
|
723
928
|
return { reloading: true, version: VERSION };
|
|
724
929
|
},
|
|
725
930
|
|
|
726
|
-
async requestSend({ conversation, to, title, text, threadId, subject }) {
|
|
931
|
+
async requestSend({ conversation, to, title, text, threadId, subject }, ctx) {
|
|
727
932
|
if (!String(text ?? "").trim()) throw new Error("The message is empty.");
|
|
728
933
|
if (to?.length) {
|
|
729
934
|
if (conversation) throw new Error("Give either a conversation or people (to), not both.");
|
|
730
935
|
if (threadId || subject) throw new Error("reply_to and subject are for channels; a new chat takes people and an optional title.");
|
|
731
936
|
const me = await myMri();
|
|
732
937
|
if (!me) throw new Error("Teams hasn't shown who is signed in yet. Reload the Teams tab and try again.");
|
|
733
|
-
const people = await resolvePeople(to, me);
|
|
938
|
+
const people = await resolvePeople(to, me, ctx);
|
|
734
939
|
if (title && people.length < 2) throw new Error("A title is only for group chats (two or more people besides you).");
|
|
735
|
-
const existing = teams.findExistingChat((await teamsAndChats(true)).chats, people.map((p) => p.mri), me, title);
|
|
940
|
+
const existing = teams.findExistingChat((await teamsAndChats(true, ctx)).chats, people.map((p) => p.mri), me, title);
|
|
736
941
|
const request = existing
|
|
737
942
|
? { conversation: { id: existing.id, kind: existing.kind, title: existing.title }, people, text: String(text), existingChat: true }
|
|
738
943
|
: { conversation: null, newChat: { members: people.map((p) => p.mri), title: title ?? null }, people, text: String(text) };
|
|
739
|
-
return askUserToConfirm(request);
|
|
944
|
+
return askUserToConfirm(request, ctx);
|
|
740
945
|
}
|
|
741
|
-
const conv = await resolve(conversation);
|
|
946
|
+
const conv = await resolve(conversation, ctx);
|
|
742
947
|
if (threadId && conv.kind !== "channel") throw new Error("reply_to is only for channel posts; in a chat, just send the message.");
|
|
743
948
|
if (subject && (conv.kind !== "channel" || threadId)) throw new Error("A subject is only for a new channel post.");
|
|
744
949
|
let replyingTo = null;
|
|
745
950
|
if (threadId) {
|
|
746
|
-
const root = (await fetchMessages(conv.id, { limit: 1, threadId, includeSystem: false })).find((m) => String(m.id) === String(threadId));
|
|
951
|
+
const root = (await fetchMessages(conv.id, { limit: 1, threadId, includeSystem: false, ctx })).find((m) => String(m.id) === String(threadId));
|
|
747
952
|
replyingTo = root ? teams.parseMessage(root) : { id: threadId };
|
|
748
953
|
}
|
|
749
954
|
const request = { conversation: conv, text: String(text), threadId: threadId ?? null, subject: subject ?? null, replyingTo };
|
|
750
|
-
return askUserToConfirm(request);
|
|
955
|
+
return askUserToConfirm(request, ctx);
|
|
751
956
|
},
|
|
752
957
|
};
|
|
753
958
|
|
|
@@ -776,7 +981,14 @@ async function createChat({ members, title }) {
|
|
|
776
981
|
|
|
777
982
|
async function sendMessage({ conversation, newChat, people, text, threadId, subject }) {
|
|
778
983
|
if (newChat) {
|
|
779
|
-
|
|
984
|
+
let id;
|
|
985
|
+
try {
|
|
986
|
+
id = await createChat(newChat);
|
|
987
|
+
} catch (err) {
|
|
988
|
+
// Without an answer the chat may exist, but the message was never sent.
|
|
989
|
+
if (err.outcomeUnknown) err.chatMayExist = true;
|
|
990
|
+
throw err;
|
|
991
|
+
}
|
|
780
992
|
const title = newChat.title ?? people.map((p) => p.name).join(", ");
|
|
781
993
|
const r = await sendMessage({ conversation: { id, title }, text });
|
|
782
994
|
return { ...r, newChat: true, conversationId: id };
|
|
@@ -812,7 +1024,25 @@ async function sendMessage({ conversation, newChat, people, text, threadId, subj
|
|
|
812
1024
|
*/
|
|
813
1025
|
const pendingSends = new Map();
|
|
814
1026
|
|
|
815
|
-
|
|
1027
|
+
/** How many confirmation windows wait for the user's decision. */
|
|
1028
|
+
export function pendingSendCount() {
|
|
1029
|
+
return pendingSends.size;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const WITHDRAWN = {
|
|
1033
|
+
sent: false,
|
|
1034
|
+
reason: "The request was withdrawn (cancelled, or the server stopped waiting), so the confirmation window was closed; nothing was sent.",
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Shows the confirmation window and resolves with the outcome. When the request is
|
|
1039
|
+
* withdrawn while the window is open (ctx: the server cancelled it, or the connection it
|
|
1040
|
+
* came on closed), the window closes and nothing is sent, so a late click can't post after
|
|
1041
|
+
* the AI was told the send failed. Once the user clicked Send, the post runs to the end
|
|
1042
|
+
* and its outcome is the answer.
|
|
1043
|
+
*/
|
|
1044
|
+
async function askUserToConfirm(request, ctx) {
|
|
1045
|
+
if (ctx?.cancelled) return WITHDRAWN;
|
|
816
1046
|
const id = crypto.randomUUID();
|
|
817
1047
|
const win = await chrome.windows.create({
|
|
818
1048
|
url: chrome.runtime.getURL(`confirm.html?id=${id}`),
|
|
@@ -822,16 +1052,22 @@ async function askUserToConfirm(request) {
|
|
|
822
1052
|
focused: true,
|
|
823
1053
|
});
|
|
824
1054
|
return new Promise((resolve) => {
|
|
825
|
-
|
|
1055
|
+
let stopWatching = () => {};
|
|
1056
|
+
// fromSend: the outcome of the post the user started; anything else is ignored then.
|
|
1057
|
+
const finish = (result, fromSend = false) => {
|
|
826
1058
|
const p = pendingSends.get(id);
|
|
827
|
-
if (!p) return;
|
|
1059
|
+
if (!p || (p.sending && !fromSend)) return;
|
|
828
1060
|
pendingSends.delete(id);
|
|
829
1061
|
clearTimeout(p.timer);
|
|
1062
|
+
stopWatching();
|
|
830
1063
|
chrome.windows.remove(p.windowId).catch(() => {});
|
|
831
1064
|
resolve(result);
|
|
832
1065
|
};
|
|
833
1066
|
const timer = setTimeout(() => finish({ sent: false, reason: "No decision within 5 minutes; nothing was sent." }), CONFIRM_TIMEOUT_MS);
|
|
834
|
-
pendingSends.set(id, { request, windowId: win.id, timer, finish });
|
|
1067
|
+
pendingSends.set(id, { request, windowId: win.id, timer, finish, sending: false });
|
|
1068
|
+
// Withdrawn while the window was opening, or later.
|
|
1069
|
+
if (ctx?.cancelled) finish(WITHDRAWN);
|
|
1070
|
+
else if (ctx) stopWatching = ctx.onCancel(() => finish(WITHDRAWN));
|
|
835
1071
|
});
|
|
836
1072
|
}
|
|
837
1073
|
|
|
@@ -855,11 +1091,20 @@ function onConfirmMessage(msg, sender, sendResponse) {
|
|
|
855
1091
|
p.finish({ sent: false, reason: "The user declined; nothing was sent." });
|
|
856
1092
|
return sendResponse({ ok: true });
|
|
857
1093
|
}
|
|
1094
|
+
// From here on the post runs to the end: a withdrawal no longer closes the window.
|
|
1095
|
+
p.sending = true;
|
|
858
1096
|
try {
|
|
859
|
-
p.finish(await sendMessage(p.request));
|
|
1097
|
+
p.finish(await sendMessage(p.request), true);
|
|
860
1098
|
sendResponse({ ok: true });
|
|
861
1099
|
} catch (err) {
|
|
862
|
-
|
|
1100
|
+
// Without an answer the post may have gone through: saying it failed would invite a second one.
|
|
1101
|
+
const cause = /[.!?]$/.test(err.message) ? err.message : `${err.message}.`;
|
|
1102
|
+
const reason = err.chatMayExist
|
|
1103
|
+
? `Creating the chat did not finish: ${cause} The chat may have been created, without the message: check with list_chats before trying again.`
|
|
1104
|
+
: err.outcomeUnknown
|
|
1105
|
+
? `Sending did not finish: ${cause} The message may have been posted: check with read_messages before sending again.`
|
|
1106
|
+
: `Sending failed: ${err.message}`;
|
|
1107
|
+
p.finish({ sent: false, reason }, true);
|
|
863
1108
|
sendResponse({ ok: false, error: err.message });
|
|
864
1109
|
}
|
|
865
1110
|
})();
|
|
@@ -893,6 +1138,37 @@ const RETRY_MAX_MS = 30_000;
|
|
|
893
1138
|
let retryMs = RETRY_MIN_MS;
|
|
894
1139
|
let retryTimer = null;
|
|
895
1140
|
|
|
1141
|
+
/**
|
|
1142
|
+
* The hub sends {type:"ping"} every keep-alive period (its welcome's heartbeatMs; 20 s in every
|
|
1143
|
+
* version). When none has come for about 2 periods (this long without heartbeatMs), the hub is
|
|
1144
|
+
* frozen or hung: drop its connection and connect again (or to whoever took the port over).
|
|
1145
|
+
*/
|
|
1146
|
+
export const HEARTBEAT_TIMEOUT_MS = 45_000;
|
|
1147
|
+
/** The watchdog never waits less: a busy machine runs timers late, and that isn't a frozen hub. */
|
|
1148
|
+
const MIN_HEARTBEAT_TIMEOUT_MS = 2_000;
|
|
1149
|
+
let watchdog = null; // { socket, ms, timer } of the current connection
|
|
1150
|
+
|
|
1151
|
+
/** (Re)starts the watchdog of `socket`: no ping within `ms` drops it and connects again. */
|
|
1152
|
+
function watchHeartbeat(socket, ms) {
|
|
1153
|
+
clearTimeout(watchdog?.timer);
|
|
1154
|
+
const timer = setTimeout(() => {
|
|
1155
|
+
if (watchdog?.timer !== timer) return;
|
|
1156
|
+
watchdog = null;
|
|
1157
|
+
if (ws !== socket) return;
|
|
1158
|
+
// Its close may never complete: nobody can be told the outcome of what came on it, now.
|
|
1159
|
+
withdrawAll(socket);
|
|
1160
|
+
reconnectNow();
|
|
1161
|
+
connect();
|
|
1162
|
+
}, ms);
|
|
1163
|
+
watchdog = { socket, ms, timer };
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function stopWatchdog(socket) {
|
|
1167
|
+
if (watchdog?.socket !== socket) return;
|
|
1168
|
+
clearTimeout(watchdog.timer);
|
|
1169
|
+
watchdog = null;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
896
1172
|
/** Drops the current connection (even one still closing) and connects again. */
|
|
897
1173
|
function reconnectNow() {
|
|
898
1174
|
const old = ws;
|
|
@@ -947,11 +1223,15 @@ async function connect() {
|
|
|
947
1223
|
let challenged = false;
|
|
948
1224
|
let authed = false;
|
|
949
1225
|
let opened = false;
|
|
1226
|
+
// The welcome's proof is checked asynchronously, and the server may send requests right behind
|
|
1227
|
+
// it: they wait for the check (in the order they came) instead of being dropped.
|
|
1228
|
+
let checking = null;
|
|
950
1229
|
socket.onopen = () => (opened = true);
|
|
951
1230
|
const refuse = (error) => {
|
|
1231
|
+
socket.close();
|
|
1232
|
+
if (ws !== socket) return; // replaced meanwhile: the newer connection's state stays
|
|
952
1233
|
state = "unauthorized";
|
|
953
1234
|
lastError = error;
|
|
954
|
-
socket.close();
|
|
955
1235
|
};
|
|
956
1236
|
socket.onmessage = async (ev) => {
|
|
957
1237
|
let msg;
|
|
@@ -961,6 +1241,9 @@ async function connect() {
|
|
|
961
1241
|
return;
|
|
962
1242
|
}
|
|
963
1243
|
if (!msg || typeof msg !== "object") return;
|
|
1244
|
+
if (checking) await checking;
|
|
1245
|
+
// Closed or replaced meanwhile: nobody could be told the outcome (its requests were withdrawn).
|
|
1246
|
+
if (ws !== socket || socket.readyState !== WebSocket.OPEN) return;
|
|
964
1247
|
if (!authed) {
|
|
965
1248
|
if (msg.type === "challenge" && !challenged && typeof msg.nonce === "string") {
|
|
966
1249
|
challenged = true;
|
|
@@ -968,20 +1251,33 @@ async function connect() {
|
|
|
968
1251
|
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.");
|
|
969
1252
|
const proof = await teams.pairingProof(secret, "client", msg.nonce);
|
|
970
1253
|
socket.send(JSON.stringify({ type: "hello", proof, nonce, version: VERSION, browser: browserName(), instance }));
|
|
971
|
-
} else if (msg.type === "welcome") {
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1254
|
+
} else if (msg.type === "welcome" && !checking) {
|
|
1255
|
+
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.`);
|
|
1256
|
+
checking = (async () => {
|
|
1257
|
+
if (!secret || msg.proof !== (await teams.pairingProof(secret, "server", nonce))) return notProven();
|
|
1258
|
+
if (ws !== socket || socket.readyState !== WebSocket.OPEN) return; // a newer connection's state stays
|
|
1259
|
+
authed = true;
|
|
1260
|
+
state = "connected";
|
|
1261
|
+
lastError = null;
|
|
1262
|
+
retryMs = RETRY_MIN_MS;
|
|
1263
|
+
const heartbeatMs = Number(msg.heartbeatMs);
|
|
1264
|
+
watchHeartbeat(socket, heartbeatMs > 0 ? Math.max(MIN_HEARTBEAT_TIMEOUT_MS, Math.round(2.25 * heartbeatMs)) : HEARTBEAT_TIMEOUT_MS);
|
|
1265
|
+
})().catch(notProven);
|
|
1266
|
+
await checking;
|
|
979
1267
|
}
|
|
980
1268
|
return;
|
|
981
1269
|
}
|
|
982
|
-
|
|
1270
|
+
if (msg.type === "ping") {
|
|
1271
|
+
if (watchdog?.socket === socket) watchHeartbeat(socket, watchdog.ms);
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
await dispatch(msg, (reply) => socket.readyState === WebSocket.OPEN && socket.send(JSON.stringify(reply)), socket);
|
|
983
1275
|
};
|
|
984
1276
|
socket.onclose = (ev) => {
|
|
1277
|
+
// Nobody can be told the outcome of what came on this connection: withdraw it
|
|
1278
|
+
// (an open confirmation window closes; nothing is sent).
|
|
1279
|
+
withdrawAll(socket);
|
|
1280
|
+
stopWatchdog(socket);
|
|
985
1281
|
if (ws && ws !== socket) return; // replaced by a newer connection (reconnectNow)
|
|
986
1282
|
ws = null;
|
|
987
1283
|
// When a server session ends, another one takes over the port within a second:
|
|
@@ -1006,19 +1302,90 @@ async function connect() {
|
|
|
1006
1302
|
};
|
|
1007
1303
|
}
|
|
1008
1304
|
|
|
1305
|
+
/** Requests being run, by id, so they can be withdrawn (a cancel from the server, or their connection closed). */
|
|
1306
|
+
const running = new Map();
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* What a handler gets besides its params: whether the request was withdrawn (by the server,
|
|
1310
|
+
* its connection closing, or its deadline passing), onCancel(f) (returns a stop function),
|
|
1311
|
+
* check() (throws once withdrawn) and its absolute `deadline` (null without timeoutMs).
|
|
1312
|
+
*/
|
|
1313
|
+
function requestContext(conn, timeoutMs, sentDeadline) {
|
|
1314
|
+
const listeners = new Set();
|
|
1315
|
+
let timer = null;
|
|
1316
|
+
// The server's own deadline when it sends one (newer servers): it stopped waiting then, and the
|
|
1317
|
+
// request may have taken a moment to arrive.
|
|
1318
|
+
const deadline = timeoutMs > 0 ? Math.min(Date.now() + timeoutMs, sentDeadline > 0 ? sentDeadline : Infinity) : null;
|
|
1319
|
+
const limitReached = () => `The request's time limit (${secondsOf(timeoutMs)} s) passed before Teams answered.`;
|
|
1320
|
+
const ctx = {
|
|
1321
|
+
conn,
|
|
1322
|
+
cancelled: false,
|
|
1323
|
+
reason: null,
|
|
1324
|
+
deadline,
|
|
1325
|
+
cancel(reason = "The request was withdrawn.") {
|
|
1326
|
+
if (ctx.cancelled) return;
|
|
1327
|
+
ctx.cancelled = true;
|
|
1328
|
+
ctx.reason = new Error(reason);
|
|
1329
|
+
clearTimeout(timer);
|
|
1330
|
+
for (const f of [...listeners]) f();
|
|
1331
|
+
},
|
|
1332
|
+
onCancel(f) {
|
|
1333
|
+
listeners.add(f);
|
|
1334
|
+
return () => listeners.delete(f);
|
|
1335
|
+
},
|
|
1336
|
+
check() {
|
|
1337
|
+
// The deadline counts even before its timer fired (e.g. a server deadline that passed on the way).
|
|
1338
|
+
if (!ctx.cancelled && deadline !== null && Date.now() >= deadline) ctx.cancel(limitReached());
|
|
1339
|
+
if (ctx.cancelled) throw ctx.reason;
|
|
1340
|
+
},
|
|
1341
|
+
/** The request ended: its deadline no longer matters. */
|
|
1342
|
+
end() {
|
|
1343
|
+
clearTimeout(timer);
|
|
1344
|
+
},
|
|
1345
|
+
};
|
|
1346
|
+
if (deadline) timer = setTimeout(() => ctx.cancel(limitReached()), Math.max(0, deadline - Date.now()));
|
|
1347
|
+
return ctx;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
/** `promise`, unless the request (ctx) is withdrawn first: then its reason. The promise itself goes on (others may share it). */
|
|
1351
|
+
function guarded(promise, ctx) {
|
|
1352
|
+
if (!ctx) return promise;
|
|
1353
|
+
if (ctx.cancelled) return Promise.reject(ctx.reason);
|
|
1354
|
+
return new Promise((resolve, reject) => {
|
|
1355
|
+
const stop = ctx.onCancel(() => reject(ctx.reason));
|
|
1356
|
+
promise.then(resolve, reject).finally(stop);
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/** Withdraws the requests that came on `conn`: their answers can no longer arrive. */
|
|
1361
|
+
function withdrawAll(conn) {
|
|
1362
|
+
for (const ctx of running.values()) if (ctx.conn === conn) ctx.cancel();
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1009
1365
|
/**
|
|
1010
|
-
* Runs one request from the server with its handler and sends back the response.
|
|
1011
|
-
*
|
|
1366
|
+
* Runs one request from the server with its handler and sends back the response.
|
|
1367
|
+
* {type:"cancel", id} withdraws a running request that came on the same connection (`conn`);
|
|
1368
|
+
* an unknown one is ignored, as is any other message.
|
|
1012
1369
|
*/
|
|
1013
|
-
export async function dispatch(msg, send) {
|
|
1370
|
+
export async function dispatch(msg, send, conn = null) {
|
|
1371
|
+
if (msg.type === "cancel") {
|
|
1372
|
+
const ctx = running.get(msg.id);
|
|
1373
|
+
if (ctx?.conn === conn) ctx.cancel();
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1014
1376
|
if (msg.type !== "request") return;
|
|
1377
|
+
const ctx = requestContext(conn, Number(msg.timeoutMs), Number(msg.deadline));
|
|
1378
|
+
running.set(msg.id, ctx);
|
|
1015
1379
|
const handler = Object.hasOwn(handlers, msg.method) ? handlers[msg.method] : null;
|
|
1016
1380
|
try {
|
|
1017
1381
|
if (!handler) throw new Error(`Unknown method ${msg.method}`);
|
|
1018
|
-
const result = await handler(msg.params ?? {});
|
|
1382
|
+
const result = await handler(msg.params ?? {}, ctx);
|
|
1019
1383
|
send({ type: "response", id: msg.id, ok: true, result });
|
|
1020
1384
|
} catch (err) {
|
|
1021
1385
|
send({ type: "response", id: msg.id, ok: false, error: err?.message ?? String(err) });
|
|
1386
|
+
} finally {
|
|
1387
|
+
ctx.end();
|
|
1388
|
+
if (running.get(msg.id) === ctx) running.delete(msg.id);
|
|
1022
1389
|
}
|
|
1023
1390
|
}
|
|
1024
1391
|
|