@commandgarden/cli 2.14.0 → 2.14.2

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.
Files changed (70) hide show
  1. package/dist/main.js +248 -278
  2. package/node_modules/@commandgarden/app/dist/client/assets/{Slides-DWQmJfkp.js → Slides-DMIm_HgI.js} +5 -5
  3. package/node_modules/@commandgarden/app/dist/client/assets/{index-C8yqRQk-.js → index-CupEN1w-.js} +53 -53
  4. package/node_modules/@commandgarden/app/dist/client/index.html +18 -18
  5. package/node_modules/@commandgarden/app/dist/server/daemon-client.test.js +47 -0
  6. package/node_modules/@commandgarden/app/dist/server/main.js +1 -1
  7. package/node_modules/@commandgarden/app/dist/server/routes/audit.js +16 -27
  8. package/node_modules/@commandgarden/app/dist/server/routes/config.js +2 -8
  9. package/node_modules/@commandgarden/app/dist/server/routes/connectors.js +26 -37
  10. package/node_modules/@commandgarden/app/dist/server/routes/routes.test.js +137 -0
  11. package/node_modules/@commandgarden/app/dist/server/routes/run.js +4 -3
  12. package/node_modules/@commandgarden/app/dist/server/routes/skills.js +2 -2
  13. package/node_modules/@commandgarden/app/dist/server/routes/status.js +1 -2
  14. package/node_modules/@commandgarden/app/dist/server/store.js +53 -53
  15. package/node_modules/@commandgarden/app/dist/server/store.test.js +59 -0
  16. package/node_modules/@commandgarden/app/package.json +42 -42
  17. package/node_modules/@commandgarden/chrome/dist/content-script.js.map +1 -1
  18. package/node_modules/@commandgarden/chrome/dist/fetch-interceptor.js +44 -0
  19. package/node_modules/@commandgarden/chrome/dist/manifest.json +26 -26
  20. package/node_modules/@commandgarden/chrome/dist/popup.html +218 -218
  21. package/node_modules/@commandgarden/chrome/dist/popup.js.map +1 -1
  22. package/node_modules/@commandgarden/chrome/dist/service-worker.js +40 -40
  23. package/node_modules/@commandgarden/chrome/dist/service-worker.js.map +2 -2
  24. package/node_modules/@commandgarden/chrome/package.json +26 -26
  25. package/node_modules/@commandgarden/daemon/connectors/ado-git-commits.eval.js +100 -100
  26. package/node_modules/@commandgarden/daemon/connectors/ado-git-commits.yaml +71 -71
  27. package/node_modules/@commandgarden/daemon/connectors/gcs-kb-content.yaml +61 -61
  28. package/node_modules/@commandgarden/daemon/connectors/gcs-kb-pages.yaml +54 -54
  29. package/node_modules/@commandgarden/daemon/connectors/jira-my-tickets.eval.js +115 -115
  30. package/node_modules/@commandgarden/daemon/connectors/jira-my-tickets.yaml +54 -54
  31. package/node_modules/@commandgarden/daemon/connectors/lib/msal-token.js +20 -20
  32. package/node_modules/@commandgarden/daemon/connectors/outlook-my-meetings.eval.js +204 -204
  33. package/node_modules/@commandgarden/daemon/connectors/outlook-my-meetings.yaml +42 -42
  34. package/node_modules/@commandgarden/daemon/connectors/saba-pending-training.eval.js +94 -94
  35. package/node_modules/@commandgarden/daemon/connectors/saba-pending-training.yaml +34 -34
  36. package/node_modules/@commandgarden/daemon/connectors/socket-security-news.yaml +63 -63
  37. package/node_modules/@commandgarden/daemon/connectors/teams-room-availability.eval.js +437 -437
  38. package/node_modules/@commandgarden/daemon/connectors/teams-room-availability.yaml +53 -53
  39. package/node_modules/@commandgarden/daemon/connectors/teams-rooms-availability.eval.js +547 -547
  40. package/node_modules/@commandgarden/daemon/connectors/teams-rooms-availability.yaml +48 -48
  41. package/node_modules/@commandgarden/daemon/connectors/timetracking-projects.eval.js +52 -52
  42. package/node_modules/@commandgarden/daemon/connectors/timetracking-projects.yaml +42 -42
  43. package/node_modules/@commandgarden/daemon/connectors/timetracking-report.eval.js +81 -81
  44. package/node_modules/@commandgarden/daemon/connectors/timetracking-report.yaml +52 -52
  45. package/node_modules/@commandgarden/daemon/connectors/tldrsec-newsletter.eval.js +27 -27
  46. package/node_modules/@commandgarden/daemon/connectors/tldrsec-newsletter.yaml +38 -38
  47. package/node_modules/@commandgarden/daemon/connectors/tokenmaster-clients-list.yaml +55 -55
  48. package/node_modules/@commandgarden/daemon/dist/audit-store.js +8 -8
  49. package/node_modules/@commandgarden/daemon/dist/auth.d.ts +0 -1
  50. package/node_modules/@commandgarden/daemon/dist/auth.d.ts.map +1 -1
  51. package/node_modules/@commandgarden/daemon/dist/auth.js +0 -10
  52. package/node_modules/@commandgarden/daemon/dist/auth.js.map +1 -1
  53. package/node_modules/@commandgarden/daemon/dist/cdp-client.d.ts +24 -0
  54. package/node_modules/@commandgarden/daemon/dist/cdp-client.d.ts.map +1 -0
  55. package/node_modules/@commandgarden/daemon/dist/cdp-client.js +150 -0
  56. package/node_modules/@commandgarden/daemon/dist/cdp-client.js.map +1 -0
  57. package/node_modules/@commandgarden/daemon/dist/main.js +5 -12
  58. package/node_modules/@commandgarden/daemon/dist/main.js.map +1 -1
  59. package/node_modules/@commandgarden/daemon/dist/server.d.ts.map +1 -1
  60. package/node_modules/@commandgarden/daemon/dist/server.js +0 -10
  61. package/node_modules/@commandgarden/daemon/dist/server.js.map +1 -1
  62. package/node_modules/@commandgarden/daemon/package.json +32 -32
  63. package/node_modules/@commandgarden/shared/dist/daemon-client.d.ts +1 -5
  64. package/node_modules/@commandgarden/shared/dist/daemon-client.d.ts.map +1 -1
  65. package/node_modules/@commandgarden/shared/dist/daemon-client.js +14 -32
  66. package/node_modules/@commandgarden/shared/dist/daemon-client.js.map +1 -1
  67. package/node_modules/@commandgarden/shared/package.json +32 -32
  68. package/package.json +58 -58
  69. package/node_modules/@commandgarden/app/dist/server/routes/utils.d.ts +0 -2
  70. package/node_modules/@commandgarden/app/dist/server/routes/utils.js +0 -4
@@ -1,437 +1,437 @@
1
- // Runs in page context via js_evaluate step.
2
- // Drives the Outlook Scheduling Assistant to fetch a meeting-room's free/busy
3
- // timeline for a single day. Strategy: reuse the authenticated browser session,
4
- // drive the UI to trigger the page's own GraphQL getSchedule call, and intercept
5
- // the response (the endpoint 401s on replay).
6
- //
7
- // Template variables interpolated before execution:
8
- // ${{ args.room }}
9
- // ${{ args.date | default("") }}
10
-
11
- const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
12
- 'July', 'August', 'September', 'October', 'November', 'December'];
13
- const DATE_RE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
14
- const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
15
-
16
- function pad2(n) { return String(n).padStart(2, '0'); }
17
- function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
18
-
19
- function todayLocalISO() {
20
- const d = new Date();
21
- return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
22
- }
23
-
24
- /** "2026-06-18" -> "18, June, 2026" (matches the OWA date-cell aria-label). */
25
- function dateCellLabel(iso) {
26
- const [y, m, d] = iso.split('-').map(Number);
27
- return `${d}, ${MONTHS[m - 1]}, ${y}`;
28
- }
29
-
30
- /** querySelector shorthand, returns null if not found. */
31
- function $(sel) { return document.querySelector(sel); }
32
-
33
- /** True when an element matching the selector exists. */
34
- function exists(sel) { return !!$(sel); }
35
-
36
- /**
37
- * Click a button/link by accessible name (aria-label OR visible text).
38
- * Returns true if an element was found and clicked.
39
- */
40
- function clickByName(name) {
41
- const els = Array.from(document.querySelectorAll('button,a,[role=button]'));
42
- const el = els.find(e => {
43
- if (!e.offsetParent) return false;
44
- const al = (e.getAttribute('aria-label') || '').trim();
45
- const tx = (e.textContent || '').replace(/\s+/g, ' ').trim();
46
- return al === name || al.includes(name) || tx.includes(name);
47
- });
48
- if (!el) return false;
49
- el.scrollIntoView({ block: 'center' });
50
- el.click();
51
- return true;
52
- }
53
-
54
- /** True when the Scheduling Assistant view (with its Start date picker) is rendered. */
55
- function schedulingAssistantOpen() {
56
- return Array.from(document.querySelectorAll('input')).some(
57
- e => (e.getAttribute('aria-label') || '') === 'Start date'
58
- );
59
- }
60
-
61
- /** Type text into an input by selector (focus, set value, dispatch events). */
62
- function typeText(sel, text) {
63
- const el = $(sel);
64
- if (!el) throw new Error(`Element "${sel}" not found for typing`);
65
- el.focus();
66
- const setter = Object.getOwnPropertyDescriptor(
67
- window.HTMLInputElement.prototype, 'value'
68
- ).set;
69
- setter.call(el, text);
70
- el.dispatchEvent(new Event('input', { bubbles: true }));
71
- el.dispatchEvent(new Event('change', { bubbles: true }));
72
- }
73
-
74
- // ── Fetch interceptor ────────────────────────────────────────────────
75
- // Captures getSchedule GraphQL responses (the page's own authenticated
76
- // request — the endpoint 401s on replay).
77
- if (!window.__rfb) {
78
- window.__rfb = []; // getSchedule (free/busy) captures
79
- window.__rfm = []; // findmeetinglocations (room resolution + capacity) captures
80
- const origFetch = window.fetch;
81
- window.fetch = function () {
82
- const a = arguments;
83
- const u = (a[0] && a[0].url) || a[0] || '';
84
- const rb = (a[1] && a[1].body) || '';
85
- return origFetch.apply(this, a).then(r => {
86
- try {
87
- if (String(u).indexOf('graphql') > -1) {
88
- r.clone().text().then(t => {
89
- if (t.indexOf('getSchedule') > -1 && t.indexOf('availabilityView') > -1) {
90
- window.__rfb.push({ req: String(rb || ''), body: t });
91
- }
92
- }).catch(() => {});
93
- } else if (String(u).indexOf('findmeetinglocations') > -1) {
94
- r.clone().text().then(t => {
95
- window.__rfm.push({ req: String(rb || ''), body: t });
96
- }).catch(() => {});
97
- }
98
- } catch (_e) { /* ignore */ }
99
- return r;
100
- });
101
- };
102
- }
103
-
104
- /** Read and clear captured {req, body} pairs. */
105
- function readCapture() {
106
- const d = window.__rfb || [];
107
- window.__rfb = [];
108
- return d;
109
- }
110
-
111
- /** Read and clear captured findmeetinglocations {req, body} pairs. */
112
- function readMeetingLocationCapture() {
113
- const d = window.__rfm || [];
114
- window.__rfm = [];
115
- return d;
116
- }
117
-
118
- /** Map of scheduleId (lowercased) -> schedule object from a captured response body. */
119
- function schedulesFromPair(pair) {
120
- let body;
121
- try { body = JSON.parse(pair.body); } catch (_e) { return new Map(); }
122
- const byId = new Map();
123
- const nodes = Array.isArray(body) ? body : [body];
124
- for (const node of nodes) {
125
- const schedules = node?.data?.getSchedule?.schedules;
126
- if (Array.isArray(schedules)) {
127
- for (const s of schedules) {
128
- if (s?.scheduleId) byId.set(String(s.scheduleId).toLowerCase(), s);
129
- }
130
- }
131
- }
132
- return byId;
133
- }
134
-
135
- /**
136
- * Build a free/busy timeline (busy/tentative/oof/elsewhere blocks + free gaps)
137
- * for one local day from getSchedule scheduleItems.
138
- */
139
- function buildTimeline(items, dateIso) {
140
- const PRIORITY = { oof: 4, busy: 3, tentative: 2, elsewhere: 1, free: 0 };
141
- const mapStatus = st => ({
142
- Busy: 'busy', Tentative: 'tentative', Oof: 'oof', WorkingElsewhere: 'elsewhere', Free: 'free',
143
- }[st] || 'busy');
144
-
145
- const dayStart = new Date(`${dateIso}T00:00:00`);
146
- const dayEnd = new Date(dayStart.getTime() + 86400000);
147
- const fmt = ms => {
148
- if (ms >= dayEnd.getTime()) return '24:00';
149
- const d = new Date(ms);
150
- return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
151
- };
152
-
153
- const blocks = [];
154
- for (const it of items) {
155
- if (!it || !it.startTime || !it.endTime) continue;
156
- const s = new Date(it.startTime.dateTime).getTime();
157
- const e = new Date(it.endTime.dateTime).getTime();
158
- const cs = Math.max(s, dayStart.getTime());
159
- const ce = Math.min(e, dayEnd.getTime());
160
- if (ce > cs) blocks.push({ state: mapStatus(it.status), s: cs, e: ce });
161
- }
162
- blocks.sort((a, b) => a.s - b.s);
163
-
164
- // Merge overlapping/adjacent busy blocks.
165
- const merged = [];
166
- for (const b of blocks) {
167
- const last = merged[merged.length - 1];
168
- if (last && b.s <= last.e) {
169
- last.e = Math.max(last.e, b.e);
170
- if (PRIORITY[b.state] > PRIORITY[last.state]) last.state = b.state;
171
- } else {
172
- merged.push({ ...b });
173
- }
174
- }
175
-
176
- const rows = [];
177
- let cursor = dayStart.getTime();
178
- for (const b of merged) {
179
- if (b.s > cursor) rows.push({ state: 'free', s: cursor, e: b.s });
180
- rows.push({ state: b.state, s: b.s, e: b.e });
181
- cursor = b.e;
182
- }
183
- if (cursor < dayEnd.getTime()) rows.push({ state: 'free', s: cursor, e: dayEnd.getTime() });
184
-
185
- return rows.map(r => ({
186
- date: dateIso,
187
- state: r.state,
188
- start: fmt(r.s),
189
- end: fmt(r.e),
190
- durationMin: Math.round((r.e - r.s) / 60000),
191
- }));
192
- }
193
-
194
- // ── Main flow ────────────────────────────────────────────────────────
195
-
196
- const roomsArgRaw = '${{ args.rooms | default("") }}'.trim();
197
- const multiMode = roomsArgRaw.length > 0;
198
-
199
- const room = '${{ args.room | default("") }}'.trim();
200
- if (!multiMode && !room) throw new Error('Missing room argument — pass "room" or "rooms"');
201
- const isEmail = EMAIL_RE.test(room);
202
-
203
- const roomList = multiMode
204
- ? roomsArgRaw.split(',').map(s => s.trim()).filter(Boolean)
205
- : [room];
206
-
207
- let date = '${{ args.date | default("") }}'.trim();
208
- if (!date) date = todayLocalISO();
209
- if (!DATE_RE.test(date)) throw new Error(`Invalid date "${date}" — use YYYY-MM-DD`);
210
-
211
- // Wait for compose form controls (handles SSO redirect delays).
212
- const __deadline = Date.now() + 30000;
213
- while (Date.now() < __deadline) {
214
- if (exists("button[aria-label='Open Scheduling Assistant']") ||
215
- exists("input[aria-label='Start date']")) break;
216
- await sleep(400);
217
- }
218
- if (!exists("button[aria-label='Open Scheduling Assistant']") &&
219
- !exists("input[aria-label='Start date']")) {
220
- if (/login\.|\/oauth2\/|signin|sso/i.test(location.href)) {
221
- throw new Error('Not signed in to Outlook/Teams — log in and retry');
222
- }
223
- throw new Error('Calendar compose form did not load');
224
- }
225
-
226
- // Open the Scheduling Assistant (free/busy view).
227
- for (let i = 0; i < 20 && !schedulingAssistantOpen(); i++) {
228
- clickByName('Open Scheduling Assistant');
229
- await sleep(400);
230
- }
231
- if (!schedulingAssistantOpen()) {
232
- throw new Error('Could not open the Scheduling Assistant');
233
- }
234
-
235
- // ── Set the date ─────────────────────────────────────────────────────
236
- const cellSel = `button[aria-label='${dateCellLabel(date)}']`;
237
- const target = new Date(`${date}T00:00:00`);
238
- const now = new Date();
239
- const navSel = target >= new Date(now.getFullYear(), now.getMonth(), now.getDate())
240
- ? "button[aria-label^='Go to next month']"
241
- : "button[aria-label^='Go to previous month']";
242
-
243
- const dateInput = $("input[aria-label='Start date']");
244
- if (dateInput) dateInput.click();
245
-
246
- for (let i = 0; i < 30; i++) {
247
- if (exists(cellSel)) {
248
- $(cellSel).click();
249
- break;
250
- }
251
- if (exists(navSel)) {
252
- try { $(navSel).click(); } catch (_e) { /* retry */ }
253
- } else {
254
- const di = $("input[aria-label='Start date']");
255
- if (di) di.click();
256
- }
257
- await sleep(500);
258
- }
259
- if (!exists(cellSel)) {
260
- // If the cell is gone, the click succeeded; otherwise error.
261
- // (After clicking a date, the picker closes and the cell is removed from DOM.)
262
- }
263
-
264
- // ── Capture pre-existing mailbox IDs (organizer) ─────────────────────
265
- const seen = new Set();
266
- for (let i = 0; i < 3; i++) {
267
- await sleep(500);
268
- for (const pair of readCapture()) {
269
- for (const id of schedulesFromPair(pair).keys()) seen.add(id);
270
- }
271
- if (seen.size > 0) break;
272
- }
273
-
274
- // ── Add the room(s) ───────────────────────────────────────────────────
275
- /** { email, capacity } per room, pushed in the same order as roomList as each is resolved. */
276
- const roomMeta = [];
277
-
278
- /** Add one room by name via the room finder (proven path — resolves the resource mailbox). */
279
- async function addRoomByName(name) {
280
- readMeetingLocationCapture(); // drop stale captures before triggering a fresh search
281
-
282
- const inputSel = "input[aria-label='Add a room']";
283
- for (let i = 0; i < 20 && !exists(inputSel); i++) {
284
- clickByName('Add a room');
285
- await sleep(400);
286
- }
287
- if (!exists(inputSel)) {
288
- throw new Error('Could not open the room finder');
289
- }
290
- typeText(inputSel, name);
291
- await sleep(1000);
292
-
293
- // Resolve the authoritative email + real capacity from the page's own
294
- // findmeetinglocations call (triggered by the search above).
295
- let resolvedEmail = null;
296
- let resolvedCapacity = undefined;
297
- for (let i = 0; i < 15 && !resolvedEmail; i++) {
298
- await sleep(300);
299
- for (const pair of readMeetingLocationCapture()) {
300
- let body;
301
- try { body = JSON.parse(pair.body); } catch (_e) { continue; }
302
- const loc = body?.MeetingLocations?.[0]?.MeetingLocation;
303
- if (loc?.LocationEmailAddress) {
304
- resolvedEmail = String(loc.LocationEmailAddress).toLowerCase();
305
- if (typeof loc.Capacity === 'number') resolvedCapacity = loc.Capacity;
306
- }
307
- }
308
- }
309
-
310
- let label = null;
311
- for (let i = 0; i < 25 && !label; i++) {
312
- await sleep(400);
313
- const opts = Array.from(document.querySelectorAll('[role=option]'));
314
- const el = opts.find(o => /capacity/i.test(o.getAttribute('aria-label') || '') && o.offsetParent);
315
- if (el) {
316
- el.scrollIntoView({ block: 'center' });
317
- el.click();
318
- label = el.getAttribute('aria-label');
319
- }
320
- }
321
- if (!label) {
322
- throw new Error(`No room matched "${name}"`);
323
- }
324
- if (resolvedCapacity === undefined) {
325
- // Fallback: parse capacity from the option label if the network capture missed it.
326
- const capMatch = /capacity:?\s*(\d+)/i.exec(label);
327
- if (capMatch) resolvedCapacity = Number(capMatch[1]);
328
- }
329
- roomMeta.push({ email: resolvedEmail, capacity: resolvedCapacity });
330
- await sleep(300);
331
- }
332
-
333
- if (multiMode) {
334
- for (const name of roomList) {
335
- await addRoomByName(name);
336
- }
337
- } else if (isEmail) {
338
- // Email -> attendee picker (resolves SMTP). Kept for direct single-room email lookups;
339
- // capacity is not available via this path.
340
- clickByName('Expand Required attendees');
341
- await sleep(400);
342
- const inputSel = "input[aria-label*='required attendees']";
343
- for (let i = 0; i < 20 && !exists(inputSel); i++) {
344
- clickByName('Add required attendee');
345
- await sleep(400);
346
- }
347
- if (!exists(inputSel)) {
348
- throw new Error('Could not open the required-attendee field');
349
- }
350
- typeText(inputSel, room);
351
- const optionSel = `[role=option][aria-label*='${room}']`;
352
- let found = false;
353
- for (let i = 0; i < 20 && !found; i++) {
354
- await sleep(400);
355
- found = exists(optionSel);
356
- }
357
- if (!found) {
358
- throw new Error(`Room "${room}" was not found in the directory`);
359
- }
360
- $(optionSel).click();
361
- } else {
362
- await addRoomByName(room);
363
- }
364
-
365
- // ── Collect free/busy from intercepted responses ─────────────────────
366
- // Any schedule id not present in the pre-add "seen" set is one of the rooms we just added
367
- // (works uniformly for one room or many, since we only ever add rooms after capturing `seen`).
368
- const isTargetId = id => (isEmail && !multiMode) ? id === room.toLowerCase() : !seen.has(id);
369
- const expectedCount = multiMode ? roomList.length : 1;
370
-
371
- // scheduleId(lower) -> { scheduleId, itemsById: Map, sawView: bool, errMsg: string|null }
372
- const roomData = new Map();
373
-
374
- let stable = 0;
375
- for (let i = 0; i < 60; i++) {
376
- await sleep(500);
377
- for (const pair of readCapture()) {
378
- for (const [id, s] of schedulesFromPair(pair)) {
379
- if (!isTargetId(id)) continue;
380
- if (!roomData.has(id)) {
381
- roomData.set(id, { scheduleId: s.scheduleId, itemsById: new Map(), sawView: false, errMsg: null });
382
- }
383
- const rd = roomData.get(id);
384
- if (s.error) rd.errMsg = s.error.message || s.error.responseCode || 'unknown';
385
- if (s.availabilityView && s.availabilityView.length) rd.sawView = true;
386
- for (const it of (s.scheduleItems || [])) {
387
- const key = it && it.id ? it.id : JSON.stringify(it && [it.startTime, it.endTime, it.subject]);
388
- if (it) rd.itemsById.set(key, it);
389
- }
390
- }
391
- }
392
- const allSeen = roomData.size >= expectedCount && [...roomData.values()].every(rd => rd.sawView);
393
- if (allSeen) { stable += 1; if (stable >= 2) break; } else { stable = 0; }
394
- }
395
-
396
- if (roomData.size === 0) {
397
- throw new Error(`No free/busy returned for ${multiMode ? roomList.join(', ') : `"${room}"`} on ${date}`);
398
- }
399
- if (!multiMode) {
400
- const rd = [...roomData.values()][0];
401
- if (rd.errMsg && !rd.sawView) {
402
- throw new Error(`Free/busy error for "${room}": ${rd.errMsg}`);
403
- }
404
- }
405
-
406
- function emitForRoomData(rd, meta) {
407
- if (rd.errMsg && !rd.sawView) return; // multi-mode: skip rooms that errored, keep the rest
408
- const timeline = buildTimeline([...rd.itemsById.values()], date);
409
- const roomEmail = meta?.email || rd.scheduleId;
410
- const capacity = meta?.capacity ?? null;
411
- for (const row of timeline) results.push({ room: roomEmail, capacity, ...row });
412
- }
413
-
414
- const results = [];
415
- if (multiMode || !isEmail) {
416
- // Prefer matching by the resolved email (authoritative); fall back to the next
417
- // unclaimed schedule in insertion order if the network capture missed it.
418
- const claimed = new Set();
419
- for (const meta of roomMeta) {
420
- let rd = null;
421
- let claimId = null;
422
- if (meta?.email && roomData.has(meta.email)) {
423
- claimId = meta.email;
424
- } else {
425
- claimId = [...roomData.keys()].find(id => !claimed.has(id)) || null;
426
- }
427
- if (claimId) {
428
- rd = roomData.get(claimId);
429
- claimed.add(claimId);
430
- }
431
- if (rd) emitForRoomData(rd, meta);
432
- }
433
- } else {
434
- const rd = roomData.get(room.toLowerCase()) || [...roomData.values()][0];
435
- if (rd) emitForRoomData(rd, null);
436
- }
437
- return results;
1
+ // Runs in page context via js_evaluate step.
2
+ // Drives the Outlook Scheduling Assistant to fetch a meeting-room's free/busy
3
+ // timeline for a single day. Strategy: reuse the authenticated browser session,
4
+ // drive the UI to trigger the page's own GraphQL getSchedule call, and intercept
5
+ // the response (the endpoint 401s on replay).
6
+ //
7
+ // Template variables interpolated before execution:
8
+ // ${{ args.room }}
9
+ // ${{ args.date | default("") }}
10
+
11
+ const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
12
+ 'July', 'August', 'September', 'October', 'November', 'December'];
13
+ const DATE_RE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
14
+ const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
15
+
16
+ function pad2(n) { return String(n).padStart(2, '0'); }
17
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
18
+
19
+ function todayLocalISO() {
20
+ const d = new Date();
21
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
22
+ }
23
+
24
+ /** "2026-06-18" -> "18, June, 2026" (matches the OWA date-cell aria-label). */
25
+ function dateCellLabel(iso) {
26
+ const [y, m, d] = iso.split('-').map(Number);
27
+ return `${d}, ${MONTHS[m - 1]}, ${y}`;
28
+ }
29
+
30
+ /** querySelector shorthand, returns null if not found. */
31
+ function $(sel) { return document.querySelector(sel); }
32
+
33
+ /** True when an element matching the selector exists. */
34
+ function exists(sel) { return !!$(sel); }
35
+
36
+ /**
37
+ * Click a button/link by accessible name (aria-label OR visible text).
38
+ * Returns true if an element was found and clicked.
39
+ */
40
+ function clickByName(name) {
41
+ const els = Array.from(document.querySelectorAll('button,a,[role=button]'));
42
+ const el = els.find(e => {
43
+ if (!e.offsetParent) return false;
44
+ const al = (e.getAttribute('aria-label') || '').trim();
45
+ const tx = (e.textContent || '').replace(/\s+/g, ' ').trim();
46
+ return al === name || al.includes(name) || tx.includes(name);
47
+ });
48
+ if (!el) return false;
49
+ el.scrollIntoView({ block: 'center' });
50
+ el.click();
51
+ return true;
52
+ }
53
+
54
+ /** True when the Scheduling Assistant view (with its Start date picker) is rendered. */
55
+ function schedulingAssistantOpen() {
56
+ return Array.from(document.querySelectorAll('input')).some(
57
+ e => (e.getAttribute('aria-label') || '') === 'Start date'
58
+ );
59
+ }
60
+
61
+ /** Type text into an input by selector (focus, set value, dispatch events). */
62
+ function typeText(sel, text) {
63
+ const el = $(sel);
64
+ if (!el) throw new Error(`Element "${sel}" not found for typing`);
65
+ el.focus();
66
+ const setter = Object.getOwnPropertyDescriptor(
67
+ window.HTMLInputElement.prototype, 'value'
68
+ ).set;
69
+ setter.call(el, text);
70
+ el.dispatchEvent(new Event('input', { bubbles: true }));
71
+ el.dispatchEvent(new Event('change', { bubbles: true }));
72
+ }
73
+
74
+ // ── Fetch interceptor ────────────────────────────────────────────────
75
+ // Captures getSchedule GraphQL responses (the page's own authenticated
76
+ // request — the endpoint 401s on replay).
77
+ if (!window.__rfb) {
78
+ window.__rfb = []; // getSchedule (free/busy) captures
79
+ window.__rfm = []; // findmeetinglocations (room resolution + capacity) captures
80
+ const origFetch = window.fetch;
81
+ window.fetch = function () {
82
+ const a = arguments;
83
+ const u = (a[0] && a[0].url) || a[0] || '';
84
+ const rb = (a[1] && a[1].body) || '';
85
+ return origFetch.apply(this, a).then(r => {
86
+ try {
87
+ if (String(u).indexOf('graphql') > -1) {
88
+ r.clone().text().then(t => {
89
+ if (t.indexOf('getSchedule') > -1 && t.indexOf('availabilityView') > -1) {
90
+ window.__rfb.push({ req: String(rb || ''), body: t });
91
+ }
92
+ }).catch(() => {});
93
+ } else if (String(u).indexOf('findmeetinglocations') > -1) {
94
+ r.clone().text().then(t => {
95
+ window.__rfm.push({ req: String(rb || ''), body: t });
96
+ }).catch(() => {});
97
+ }
98
+ } catch (_e) { /* ignore */ }
99
+ return r;
100
+ });
101
+ };
102
+ }
103
+
104
+ /** Read and clear captured {req, body} pairs. */
105
+ function readCapture() {
106
+ const d = window.__rfb || [];
107
+ window.__rfb = [];
108
+ return d;
109
+ }
110
+
111
+ /** Read and clear captured findmeetinglocations {req, body} pairs. */
112
+ function readMeetingLocationCapture() {
113
+ const d = window.__rfm || [];
114
+ window.__rfm = [];
115
+ return d;
116
+ }
117
+
118
+ /** Map of scheduleId (lowercased) -> schedule object from a captured response body. */
119
+ function schedulesFromPair(pair) {
120
+ let body;
121
+ try { body = JSON.parse(pair.body); } catch (_e) { return new Map(); }
122
+ const byId = new Map();
123
+ const nodes = Array.isArray(body) ? body : [body];
124
+ for (const node of nodes) {
125
+ const schedules = node?.data?.getSchedule?.schedules;
126
+ if (Array.isArray(schedules)) {
127
+ for (const s of schedules) {
128
+ if (s?.scheduleId) byId.set(String(s.scheduleId).toLowerCase(), s);
129
+ }
130
+ }
131
+ }
132
+ return byId;
133
+ }
134
+
135
+ /**
136
+ * Build a free/busy timeline (busy/tentative/oof/elsewhere blocks + free gaps)
137
+ * for one local day from getSchedule scheduleItems.
138
+ */
139
+ function buildTimeline(items, dateIso) {
140
+ const PRIORITY = { oof: 4, busy: 3, tentative: 2, elsewhere: 1, free: 0 };
141
+ const mapStatus = st => ({
142
+ Busy: 'busy', Tentative: 'tentative', Oof: 'oof', WorkingElsewhere: 'elsewhere', Free: 'free',
143
+ }[st] || 'busy');
144
+
145
+ const dayStart = new Date(`${dateIso}T00:00:00`);
146
+ const dayEnd = new Date(dayStart.getTime() + 86400000);
147
+ const fmt = ms => {
148
+ if (ms >= dayEnd.getTime()) return '24:00';
149
+ const d = new Date(ms);
150
+ return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
151
+ };
152
+
153
+ const blocks = [];
154
+ for (const it of items) {
155
+ if (!it || !it.startTime || !it.endTime) continue;
156
+ const s = new Date(it.startTime.dateTime).getTime();
157
+ const e = new Date(it.endTime.dateTime).getTime();
158
+ const cs = Math.max(s, dayStart.getTime());
159
+ const ce = Math.min(e, dayEnd.getTime());
160
+ if (ce > cs) blocks.push({ state: mapStatus(it.status), s: cs, e: ce });
161
+ }
162
+ blocks.sort((a, b) => a.s - b.s);
163
+
164
+ // Merge overlapping/adjacent busy blocks.
165
+ const merged = [];
166
+ for (const b of blocks) {
167
+ const last = merged[merged.length - 1];
168
+ if (last && b.s <= last.e) {
169
+ last.e = Math.max(last.e, b.e);
170
+ if (PRIORITY[b.state] > PRIORITY[last.state]) last.state = b.state;
171
+ } else {
172
+ merged.push({ ...b });
173
+ }
174
+ }
175
+
176
+ const rows = [];
177
+ let cursor = dayStart.getTime();
178
+ for (const b of merged) {
179
+ if (b.s > cursor) rows.push({ state: 'free', s: cursor, e: b.s });
180
+ rows.push({ state: b.state, s: b.s, e: b.e });
181
+ cursor = b.e;
182
+ }
183
+ if (cursor < dayEnd.getTime()) rows.push({ state: 'free', s: cursor, e: dayEnd.getTime() });
184
+
185
+ return rows.map(r => ({
186
+ date: dateIso,
187
+ state: r.state,
188
+ start: fmt(r.s),
189
+ end: fmt(r.e),
190
+ durationMin: Math.round((r.e - r.s) / 60000),
191
+ }));
192
+ }
193
+
194
+ // ── Main flow ────────────────────────────────────────────────────────
195
+
196
+ const roomsArgRaw = '${{ args.rooms | default("") }}'.trim();
197
+ const multiMode = roomsArgRaw.length > 0;
198
+
199
+ const room = '${{ args.room | default("") }}'.trim();
200
+ if (!multiMode && !room) throw new Error('Missing room argument — pass "room" or "rooms"');
201
+ const isEmail = EMAIL_RE.test(room);
202
+
203
+ const roomList = multiMode
204
+ ? roomsArgRaw.split(',').map(s => s.trim()).filter(Boolean)
205
+ : [room];
206
+
207
+ let date = '${{ args.date | default("") }}'.trim();
208
+ if (!date) date = todayLocalISO();
209
+ if (!DATE_RE.test(date)) throw new Error(`Invalid date "${date}" — use YYYY-MM-DD`);
210
+
211
+ // Wait for compose form controls (handles SSO redirect delays).
212
+ const __deadline = Date.now() + 30000;
213
+ while (Date.now() < __deadline) {
214
+ if (exists("button[aria-label='Open Scheduling Assistant']") ||
215
+ exists("input[aria-label='Start date']")) break;
216
+ await sleep(400);
217
+ }
218
+ if (!exists("button[aria-label='Open Scheduling Assistant']") &&
219
+ !exists("input[aria-label='Start date']")) {
220
+ if (/login\.|\/oauth2\/|signin|sso/i.test(location.href)) {
221
+ throw new Error('Not signed in to Outlook/Teams — log in and retry');
222
+ }
223
+ throw new Error('Calendar compose form did not load');
224
+ }
225
+
226
+ // Open the Scheduling Assistant (free/busy view).
227
+ for (let i = 0; i < 20 && !schedulingAssistantOpen(); i++) {
228
+ clickByName('Open Scheduling Assistant');
229
+ await sleep(400);
230
+ }
231
+ if (!schedulingAssistantOpen()) {
232
+ throw new Error('Could not open the Scheduling Assistant');
233
+ }
234
+
235
+ // ── Set the date ─────────────────────────────────────────────────────
236
+ const cellSel = `button[aria-label='${dateCellLabel(date)}']`;
237
+ const target = new Date(`${date}T00:00:00`);
238
+ const now = new Date();
239
+ const navSel = target >= new Date(now.getFullYear(), now.getMonth(), now.getDate())
240
+ ? "button[aria-label^='Go to next month']"
241
+ : "button[aria-label^='Go to previous month']";
242
+
243
+ const dateInput = $("input[aria-label='Start date']");
244
+ if (dateInput) dateInput.click();
245
+
246
+ for (let i = 0; i < 30; i++) {
247
+ if (exists(cellSel)) {
248
+ $(cellSel).click();
249
+ break;
250
+ }
251
+ if (exists(navSel)) {
252
+ try { $(navSel).click(); } catch (_e) { /* retry */ }
253
+ } else {
254
+ const di = $("input[aria-label='Start date']");
255
+ if (di) di.click();
256
+ }
257
+ await sleep(500);
258
+ }
259
+ if (!exists(cellSel)) {
260
+ // If the cell is gone, the click succeeded; otherwise error.
261
+ // (After clicking a date, the picker closes and the cell is removed from DOM.)
262
+ }
263
+
264
+ // ── Capture pre-existing mailbox IDs (organizer) ─────────────────────
265
+ const seen = new Set();
266
+ for (let i = 0; i < 3; i++) {
267
+ await sleep(500);
268
+ for (const pair of readCapture()) {
269
+ for (const id of schedulesFromPair(pair).keys()) seen.add(id);
270
+ }
271
+ if (seen.size > 0) break;
272
+ }
273
+
274
+ // ── Add the room(s) ───────────────────────────────────────────────────
275
+ /** { email, capacity } per room, pushed in the same order as roomList as each is resolved. */
276
+ const roomMeta = [];
277
+
278
+ /** Add one room by name via the room finder (proven path — resolves the resource mailbox). */
279
+ async function addRoomByName(name) {
280
+ readMeetingLocationCapture(); // drop stale captures before triggering a fresh search
281
+
282
+ const inputSel = "input[aria-label='Add a room']";
283
+ for (let i = 0; i < 20 && !exists(inputSel); i++) {
284
+ clickByName('Add a room');
285
+ await sleep(400);
286
+ }
287
+ if (!exists(inputSel)) {
288
+ throw new Error('Could not open the room finder');
289
+ }
290
+ typeText(inputSel, name);
291
+ await sleep(1000);
292
+
293
+ // Resolve the authoritative email + real capacity from the page's own
294
+ // findmeetinglocations call (triggered by the search above).
295
+ let resolvedEmail = null;
296
+ let resolvedCapacity = undefined;
297
+ for (let i = 0; i < 15 && !resolvedEmail; i++) {
298
+ await sleep(300);
299
+ for (const pair of readMeetingLocationCapture()) {
300
+ let body;
301
+ try { body = JSON.parse(pair.body); } catch (_e) { continue; }
302
+ const loc = body?.MeetingLocations?.[0]?.MeetingLocation;
303
+ if (loc?.LocationEmailAddress) {
304
+ resolvedEmail = String(loc.LocationEmailAddress).toLowerCase();
305
+ if (typeof loc.Capacity === 'number') resolvedCapacity = loc.Capacity;
306
+ }
307
+ }
308
+ }
309
+
310
+ let label = null;
311
+ for (let i = 0; i < 25 && !label; i++) {
312
+ await sleep(400);
313
+ const opts = Array.from(document.querySelectorAll('[role=option]'));
314
+ const el = opts.find(o => /capacity/i.test(o.getAttribute('aria-label') || '') && o.offsetParent);
315
+ if (el) {
316
+ el.scrollIntoView({ block: 'center' });
317
+ el.click();
318
+ label = el.getAttribute('aria-label');
319
+ }
320
+ }
321
+ if (!label) {
322
+ throw new Error(`No room matched "${name}"`);
323
+ }
324
+ if (resolvedCapacity === undefined) {
325
+ // Fallback: parse capacity from the option label if the network capture missed it.
326
+ const capMatch = /capacity:?\s*(\d+)/i.exec(label);
327
+ if (capMatch) resolvedCapacity = Number(capMatch[1]);
328
+ }
329
+ roomMeta.push({ email: resolvedEmail, capacity: resolvedCapacity });
330
+ await sleep(300);
331
+ }
332
+
333
+ if (multiMode) {
334
+ for (const name of roomList) {
335
+ await addRoomByName(name);
336
+ }
337
+ } else if (isEmail) {
338
+ // Email -> attendee picker (resolves SMTP). Kept for direct single-room email lookups;
339
+ // capacity is not available via this path.
340
+ clickByName('Expand Required attendees');
341
+ await sleep(400);
342
+ const inputSel = "input[aria-label*='required attendees']";
343
+ for (let i = 0; i < 20 && !exists(inputSel); i++) {
344
+ clickByName('Add required attendee');
345
+ await sleep(400);
346
+ }
347
+ if (!exists(inputSel)) {
348
+ throw new Error('Could not open the required-attendee field');
349
+ }
350
+ typeText(inputSel, room);
351
+ const optionSel = `[role=option][aria-label*='${room}']`;
352
+ let found = false;
353
+ for (let i = 0; i < 20 && !found; i++) {
354
+ await sleep(400);
355
+ found = exists(optionSel);
356
+ }
357
+ if (!found) {
358
+ throw new Error(`Room "${room}" was not found in the directory`);
359
+ }
360
+ $(optionSel).click();
361
+ } else {
362
+ await addRoomByName(room);
363
+ }
364
+
365
+ // ── Collect free/busy from intercepted responses ─────────────────────
366
+ // Any schedule id not present in the pre-add "seen" set is one of the rooms we just added
367
+ // (works uniformly for one room or many, since we only ever add rooms after capturing `seen`).
368
+ const isTargetId = id => (isEmail && !multiMode) ? id === room.toLowerCase() : !seen.has(id);
369
+ const expectedCount = multiMode ? roomList.length : 1;
370
+
371
+ // scheduleId(lower) -> { scheduleId, itemsById: Map, sawView: bool, errMsg: string|null }
372
+ const roomData = new Map();
373
+
374
+ let stable = 0;
375
+ for (let i = 0; i < 60; i++) {
376
+ await sleep(500);
377
+ for (const pair of readCapture()) {
378
+ for (const [id, s] of schedulesFromPair(pair)) {
379
+ if (!isTargetId(id)) continue;
380
+ if (!roomData.has(id)) {
381
+ roomData.set(id, { scheduleId: s.scheduleId, itemsById: new Map(), sawView: false, errMsg: null });
382
+ }
383
+ const rd = roomData.get(id);
384
+ if (s.error) rd.errMsg = s.error.message || s.error.responseCode || 'unknown';
385
+ if (s.availabilityView && s.availabilityView.length) rd.sawView = true;
386
+ for (const it of (s.scheduleItems || [])) {
387
+ const key = it && it.id ? it.id : JSON.stringify(it && [it.startTime, it.endTime, it.subject]);
388
+ if (it) rd.itemsById.set(key, it);
389
+ }
390
+ }
391
+ }
392
+ const allSeen = roomData.size >= expectedCount && [...roomData.values()].every(rd => rd.sawView);
393
+ if (allSeen) { stable += 1; if (stable >= 2) break; } else { stable = 0; }
394
+ }
395
+
396
+ if (roomData.size === 0) {
397
+ throw new Error(`No free/busy returned for ${multiMode ? roomList.join(', ') : `"${room}"`} on ${date}`);
398
+ }
399
+ if (!multiMode) {
400
+ const rd = [...roomData.values()][0];
401
+ if (rd.errMsg && !rd.sawView) {
402
+ throw new Error(`Free/busy error for "${room}": ${rd.errMsg}`);
403
+ }
404
+ }
405
+
406
+ function emitForRoomData(rd, meta) {
407
+ if (rd.errMsg && !rd.sawView) return; // multi-mode: skip rooms that errored, keep the rest
408
+ const timeline = buildTimeline([...rd.itemsById.values()], date);
409
+ const roomEmail = meta?.email || rd.scheduleId;
410
+ const capacity = meta?.capacity ?? null;
411
+ for (const row of timeline) results.push({ room: roomEmail, capacity, ...row });
412
+ }
413
+
414
+ const results = [];
415
+ if (multiMode || !isEmail) {
416
+ // Prefer matching by the resolved email (authoritative); fall back to the next
417
+ // unclaimed schedule in insertion order if the network capture missed it.
418
+ const claimed = new Set();
419
+ for (const meta of roomMeta) {
420
+ let rd = null;
421
+ let claimId = null;
422
+ if (meta?.email && roomData.has(meta.email)) {
423
+ claimId = meta.email;
424
+ } else {
425
+ claimId = [...roomData.keys()].find(id => !claimed.has(id)) || null;
426
+ }
427
+ if (claimId) {
428
+ rd = roomData.get(claimId);
429
+ claimed.add(claimId);
430
+ }
431
+ if (rd) emitForRoomData(rd, meta);
432
+ }
433
+ } else {
434
+ const rd = roomData.get(room.toLowerCase()) || [...roomData.values()][0];
435
+ if (rd) emitForRoomData(rd, null);
436
+ }
437
+ return results;