msteams-mcp 0.15.0 → 0.16.0
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 +3 -2
- package/dist/api/chatsvc-api.js +22 -19
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/transcript-api.d.ts +41 -0
- package/dist/api/transcript-api.js +121 -0
- package/dist/browser/auth.js +79 -54
- package/dist/research/explore.js +38 -21
- package/dist/test/dump-tokens.d.ts +7 -0
- package/dist/test/dump-tokens.js +90 -0
- package/dist/test/test-transcript.d.ts +6 -0
- package/dist/test/test-transcript.js +47 -0
- package/dist/tools/meeting-tools.d.ts +22 -2
- package/dist/tools/meeting-tools.js +41 -1
- package/dist/utils/parsers.d.ts +21 -0
- package/dist/utils/parsers.js +42 -0
- package/dist/utils/parsers.test.js +30 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -121,13 +121,14 @@ The server uses your system's Chrome (macOS/Linux) or Edge (Windows) for authent
|
|
|
121
121
|
|
|
122
122
|
**Quick reactions:** `like`, `heart`, `laugh`, `surprised`, `sad`, `angry` can be used directly without searching.
|
|
123
123
|
|
|
124
|
-
### Calendar
|
|
124
|
+
### Calendar & Meetings
|
|
125
125
|
|
|
126
126
|
| Tool | Description |
|
|
127
127
|
|------|-------------|
|
|
128
128
|
| `teams_get_meetings` | Get meetings from calendar (defaults to next 7 days) |
|
|
129
|
+
| `teams_get_transcript` | Get meeting transcript (requires `threadId` from `teams_get_meetings`) |
|
|
129
130
|
|
|
130
|
-
|
|
131
|
+
`teams_get_meetings` returns: subject, times, organiser, join URL, `threadId` for meeting chat. Use `threadId` with `teams_get_thread` to read meeting chat, or with `teams_get_transcript` to get the full transcript with speakers and timestamps.
|
|
131
132
|
|
|
132
133
|
### Session
|
|
133
134
|
|
package/dist/api/chatsvc-api.js
CHANGED
|
@@ -618,35 +618,38 @@ function parseContentWithMentionsAndLinks(content) {
|
|
|
618
618
|
if (matches.length === 0) {
|
|
619
619
|
return { html: markdownToTeamsHtml(content), mentions: [] };
|
|
620
620
|
}
|
|
621
|
-
//
|
|
622
|
-
//
|
|
621
|
+
// Strategy: replace mentions/links with unique placeholders, run the whole
|
|
622
|
+
// content through markdownToTeamsHtml (so links stay inline within their
|
|
623
|
+
// paragraph), then substitute placeholders back with actual HTML.
|
|
623
624
|
const mentions = [];
|
|
624
|
-
|
|
625
|
-
let lastIndex = 0;
|
|
625
|
+
const placeholders = new Map();
|
|
626
626
|
let mentionId = 0;
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
627
|
+
// Build content with placeholders (process in reverse to preserve indices)
|
|
628
|
+
let placeholderContent = content;
|
|
629
|
+
for (let i = matches.length - 1; i >= 0; i--) {
|
|
630
|
+
const m = matches[i];
|
|
631
|
+
const placeholder = `\uE000MCP_PH_${i}\uE001`;
|
|
632
|
+
let html;
|
|
633
633
|
if (m.type === 'mention') {
|
|
634
634
|
mentions.push({ mri: m.target, displayName: m.text });
|
|
635
|
-
|
|
635
|
+
html = buildMentionHtml(m.text, mentionId);
|
|
636
636
|
mentionId++;
|
|
637
637
|
}
|
|
638
638
|
else {
|
|
639
|
-
// Link
|
|
640
639
|
const safeText = escapeHtml(m.text);
|
|
641
640
|
const safeUrl = m.target.replace(/"/g, '"');
|
|
642
|
-
|
|
641
|
+
html = `<a href="${safeUrl}">${safeText}</a>`;
|
|
643
642
|
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
643
|
+
placeholders.set(placeholder, html);
|
|
644
|
+
placeholderContent = placeholderContent.substring(0, m.index) + placeholder + placeholderContent.substring(m.index + m.length);
|
|
645
|
+
}
|
|
646
|
+
// Reverse mentions array since we processed in reverse order
|
|
647
|
+
mentions.reverse();
|
|
648
|
+
// Convert the whole content (with placeholders) through markdown pipeline
|
|
649
|
+
let result = markdownToTeamsHtml(placeholderContent);
|
|
650
|
+
// Substitute placeholders back with actual HTML
|
|
651
|
+
for (const [placeholder, html] of placeholders) {
|
|
652
|
+
result = result.replace(placeholder, html);
|
|
650
653
|
}
|
|
651
654
|
return { html: result, mentions };
|
|
652
655
|
}
|
package/dist/api/index.d.ts
CHANGED
package/dist/api/index.js
CHANGED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript API client for meeting transcript operations.
|
|
3
|
+
*
|
|
4
|
+
* Uses Substrate WorkingSetFiles API to fetch meeting transcripts.
|
|
5
|
+
* The transcript is embedded as JSON in the WorkingSetFiles response,
|
|
6
|
+
* using the same Substrate token already used for search.
|
|
7
|
+
*
|
|
8
|
+
* Flow: threadId → Substrate WorkingSetFiles (filter by MeetingThreadId) → parse TranscriptJson
|
|
9
|
+
*/
|
|
10
|
+
import { type Result } from '../types/result.js';
|
|
11
|
+
import { type TranscriptEntry } from '../utils/parsers.js';
|
|
12
|
+
/** Result of fetching a transcript. */
|
|
13
|
+
export interface TranscriptResult {
|
|
14
|
+
/** Meeting title from the recording metadata. */
|
|
15
|
+
meetingTitle?: string;
|
|
16
|
+
/** Meeting thread ID used for the lookup. */
|
|
17
|
+
threadId: string;
|
|
18
|
+
/** When the recording started. */
|
|
19
|
+
recordingStartTime?: string;
|
|
20
|
+
/** When the recording ended. */
|
|
21
|
+
recordingEndTime?: string;
|
|
22
|
+
/** Parsed transcript entries with timestamps and speakers. */
|
|
23
|
+
entries: TranscriptEntry[];
|
|
24
|
+
/** Formatted readable transcript text. */
|
|
25
|
+
formattedText: string;
|
|
26
|
+
/** Number of transcript entries. */
|
|
27
|
+
entryCount: number;
|
|
28
|
+
/** List of unique speakers in the transcript. */
|
|
29
|
+
speakers: string[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Gets the transcript for a meeting by its thread ID.
|
|
33
|
+
*
|
|
34
|
+
* Uses Substrate WorkingSetFiles to find the recording associated with the
|
|
35
|
+
* meeting thread, then extracts the embedded TranscriptJson.
|
|
36
|
+
*
|
|
37
|
+
* @param threadId - The meeting thread ID (e.g., "19:meeting_xxx@thread.v2")
|
|
38
|
+
* @param meetingDate - ISO date string of the meeting (used to narrow the search window)
|
|
39
|
+
* @returns Parsed transcript with entries and formatted text
|
|
40
|
+
*/
|
|
41
|
+
export declare function getTranscriptContent(threadId: string, meetingDate?: string): Promise<Result<TranscriptResult>>;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript API client for meeting transcript operations.
|
|
3
|
+
*
|
|
4
|
+
* Uses Substrate WorkingSetFiles API to fetch meeting transcripts.
|
|
5
|
+
* The transcript is embedded as JSON in the WorkingSetFiles response,
|
|
6
|
+
* using the same Substrate token already used for search.
|
|
7
|
+
*
|
|
8
|
+
* Flow: threadId → Substrate WorkingSetFiles (filter by MeetingThreadId) → parse TranscriptJson
|
|
9
|
+
*/
|
|
10
|
+
import { httpRequest } from '../utils/http.js';
|
|
11
|
+
import { ok, err } from '../types/result.js';
|
|
12
|
+
import { ErrorCode, createError } from '../types/errors.js';
|
|
13
|
+
import { requireSubstrateTokenAsync } from '../utils/auth-guards.js';
|
|
14
|
+
import { formatTranscriptText } from '../utils/parsers.js';
|
|
15
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
// Constants
|
|
17
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
18
|
+
/** Substrate WorkingSetFiles endpoint for finding meeting recordings + transcripts. */
|
|
19
|
+
const WORKING_SET_FILES_URL = 'https://substrate.office.com/api/beta/me/WorkingSetFiles/';
|
|
20
|
+
/** Fields to select from WorkingSetFiles. */
|
|
21
|
+
const WORKING_SET_SELECT = [
|
|
22
|
+
'SharePointItem',
|
|
23
|
+
'Visualization',
|
|
24
|
+
'ItemProperties/Default/MeetingCallId',
|
|
25
|
+
'ItemProperties/Default/DriveId',
|
|
26
|
+
'ItemProperties/Default/RecordingStartDateTime',
|
|
27
|
+
'ItemProperties/Default/RecordingEndDateTime',
|
|
28
|
+
'ItemProperties/Default/TranscriptJson',
|
|
29
|
+
'ItemProperties/Default/DocumentLink',
|
|
30
|
+
].join(',');
|
|
31
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
// API Functions
|
|
33
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
/**
|
|
35
|
+
* Gets the transcript for a meeting by its thread ID.
|
|
36
|
+
*
|
|
37
|
+
* Uses Substrate WorkingSetFiles to find the recording associated with the
|
|
38
|
+
* meeting thread, then extracts the embedded TranscriptJson.
|
|
39
|
+
*
|
|
40
|
+
* @param threadId - The meeting thread ID (e.g., "19:meeting_xxx@thread.v2")
|
|
41
|
+
* @param meetingDate - ISO date string of the meeting (used to narrow the search window)
|
|
42
|
+
* @returns Parsed transcript with entries and formatted text
|
|
43
|
+
*/
|
|
44
|
+
export async function getTranscriptContent(threadId, meetingDate) {
|
|
45
|
+
const authResult = await requireSubstrateTokenAsync();
|
|
46
|
+
if (!authResult.ok)
|
|
47
|
+
return authResult;
|
|
48
|
+
const token = authResult.value;
|
|
49
|
+
// Build date filter: search ±1 day around the meeting date, or last 30 days
|
|
50
|
+
let dateFilter = '';
|
|
51
|
+
if (meetingDate) {
|
|
52
|
+
const date = new Date(meetingDate);
|
|
53
|
+
const dayBefore = new Date(date);
|
|
54
|
+
dayBefore.setDate(dayBefore.getDate() - 1);
|
|
55
|
+
const dayAfter = new Date(date);
|
|
56
|
+
dayAfter.setDate(dayAfter.getDate() + 1);
|
|
57
|
+
dateFilter = ` AND FileCreatedTime gt ${dayBefore.toISOString()} AND FileCreatedTime lt ${dayAfter.toISOString()}`;
|
|
58
|
+
}
|
|
59
|
+
const filter = `ItemProperties/Default/MeetingThreadId eq '${threadId}'${dateFilter}`;
|
|
60
|
+
const url = `${WORKING_SET_FILES_URL}?$filter=${encodeURIComponent(filter)}&$orderby=${encodeURIComponent('FileCreatedTime desc')}&$select=${encodeURIComponent(WORKING_SET_SELECT)}`;
|
|
61
|
+
const response = await httpRequest(url, {
|
|
62
|
+
method: 'GET',
|
|
63
|
+
headers: {
|
|
64
|
+
'Authorization': `Bearer ${token}`,
|
|
65
|
+
'Accept': 'application/json',
|
|
66
|
+
'Content-Type': 'application/json',
|
|
67
|
+
'Prefer': 'substrate.flexibleschema,outlook.data-source="Substrate",exchange.behavior="SubstrateFiles"',
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
return response;
|
|
72
|
+
}
|
|
73
|
+
const data = response.value.data;
|
|
74
|
+
const items = data.value;
|
|
75
|
+
if (!items || items.length === 0) {
|
|
76
|
+
return err(createError(ErrorCode.NOT_FOUND, 'No recording found for this meeting. Transcription may not have been enabled, or the recording has not finished processing.', { suggestions: [
|
|
77
|
+
'Check that transcription/recording was enabled during the meeting',
|
|
78
|
+
'Wait a few minutes if the meeting just ended',
|
|
79
|
+
'The meeting organiser must have recording enabled',
|
|
80
|
+
] }));
|
|
81
|
+
}
|
|
82
|
+
const item = items[0];
|
|
83
|
+
const props = item.ItemProperties?.Default;
|
|
84
|
+
const viz = item.Visualization;
|
|
85
|
+
// Extract TranscriptJson
|
|
86
|
+
const transcriptJsonStr = props?.TranscriptJson;
|
|
87
|
+
if (!transcriptJsonStr) {
|
|
88
|
+
return err(createError(ErrorCode.NOT_FOUND, 'Recording found but no transcript available. Transcription may not have been enabled for this meeting.', { suggestions: ['Check that transcription was enabled during the meeting'] }));
|
|
89
|
+
}
|
|
90
|
+
// Parse the transcript JSON
|
|
91
|
+
let transcriptData;
|
|
92
|
+
try {
|
|
93
|
+
transcriptData = JSON.parse(transcriptJsonStr);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return err(createError(ErrorCode.UNKNOWN, 'Failed to parse transcript data.'));
|
|
97
|
+
}
|
|
98
|
+
const rawEntries = transcriptData.entries || [];
|
|
99
|
+
if (rawEntries.length === 0) {
|
|
100
|
+
return err(createError(ErrorCode.NOT_FOUND, 'Transcript is empty — no speech was detected during the meeting.'));
|
|
101
|
+
}
|
|
102
|
+
// Map to TranscriptEntry format
|
|
103
|
+
const entries = rawEntries.map(e => ({
|
|
104
|
+
startTime: (e.startOffset || '').replace(/0{4}$/, ''),
|
|
105
|
+
endTime: (e.endOffset || '').replace(/0{4}$/, ''),
|
|
106
|
+
speaker: e.speakerDisplayName || '',
|
|
107
|
+
text: e.text || '',
|
|
108
|
+
}));
|
|
109
|
+
const formattedText = formatTranscriptText(entries);
|
|
110
|
+
const speakers = [...new Set(entries.map(e => e.speaker).filter(s => s.length > 0))];
|
|
111
|
+
return ok({
|
|
112
|
+
meetingTitle: viz?.Title,
|
|
113
|
+
threadId,
|
|
114
|
+
recordingStartTime: props?.RecordingStartDateTime,
|
|
115
|
+
recordingEndTime: props?.RecordingEndDateTime,
|
|
116
|
+
entries,
|
|
117
|
+
formattedText,
|
|
118
|
+
entryCount: entries.length,
|
|
119
|
+
speakers,
|
|
120
|
+
});
|
|
121
|
+
}
|
package/dist/browser/auth.js
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { saveSessionState } from './context.js';
|
|
6
6
|
import { OVERLAY_STEP_PAUSE_MS, OVERLAY_COMPLETE_PAUSE_MS, } from '../constants.js';
|
|
7
|
-
import { extractSubstrateToken, clearTokenCache } from '../auth/token-extractor.js';
|
|
8
7
|
/**
|
|
9
8
|
* Default Teams URL for initial login.
|
|
10
9
|
*
|
|
@@ -285,18 +284,61 @@ async function hasAuthenticatedContent(page) {
|
|
|
285
284
|
}
|
|
286
285
|
return false;
|
|
287
286
|
}
|
|
287
|
+
/**
|
|
288
|
+
* Waits for the Teams SPA to be fully loaded and interactive.
|
|
289
|
+
*
|
|
290
|
+
* Teams is a heavy SPA — after navigation, the shell loads quickly but MSAL
|
|
291
|
+
* (which manages tokens) needs time to bootstrap. We wait for key UI elements
|
|
292
|
+
* that only render after the app has fully initialised, then wait for network
|
|
293
|
+
* activity to settle (indicating MSAL token requests have completed).
|
|
294
|
+
*/
|
|
295
|
+
async function waitForTeamsReady(page, log, timeoutMs = 60000) {
|
|
296
|
+
log('Waiting for Teams to fully load...');
|
|
297
|
+
try {
|
|
298
|
+
// Wait for any SPA UI element that indicates the app has bootstrapped
|
|
299
|
+
await page.waitForSelector(AUTH_SUCCESS_SELECTORS.join(', '), { timeout: timeoutMs });
|
|
300
|
+
log('Teams UI elements detected.');
|
|
301
|
+
// Wait for network to settle — MSAL token refresh requests should complete
|
|
302
|
+
try {
|
|
303
|
+
await page.waitForLoadState('networkidle', { timeout: 15000 });
|
|
304
|
+
log('Network settled.');
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
// Network may not become fully idle (websockets, polling), that's OK
|
|
308
|
+
log('Network did not fully settle, continuing...');
|
|
309
|
+
}
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
log('Teams did not fully load within timeout.');
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
288
317
|
/**
|
|
289
318
|
* Triggers MSAL to acquire the Substrate token.
|
|
290
319
|
*
|
|
291
320
|
* MSAL only acquires tokens for specific scopes when the app makes API calls
|
|
292
321
|
* requiring those scopes. The Substrate API is only used for search, so we
|
|
293
|
-
* perform a
|
|
322
|
+
* perform a search to trigger token acquisition.
|
|
323
|
+
*
|
|
324
|
+
* Returns true if a Substrate API call was detected, false otherwise.
|
|
294
325
|
*/
|
|
295
326
|
async function triggerTokenAcquisition(page, log) {
|
|
296
327
|
log('Triggering token acquisition...');
|
|
297
328
|
try {
|
|
298
|
-
// Wait for
|
|
299
|
-
await page
|
|
329
|
+
// Wait for Teams SPA to be fully loaded (MSAL must bootstrap first)
|
|
330
|
+
const ready = await waitForTeamsReady(page, log);
|
|
331
|
+
if (!ready) {
|
|
332
|
+
log('Teams did not load — cannot trigger token acquisition.');
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
// Set up Substrate API listener BEFORE triggering search
|
|
336
|
+
let substrateDetected = false;
|
|
337
|
+
const substratePromise = page.waitForResponse(resp => resp.url().includes('substrate.office.com') && resp.status() === 200, { timeout: 30000 }).then(() => {
|
|
338
|
+
substrateDetected = true;
|
|
339
|
+
}).catch(() => {
|
|
340
|
+
// Timeout — no Substrate call detected
|
|
341
|
+
});
|
|
300
342
|
// Try multiple methods to trigger search
|
|
301
343
|
let searchTriggered = false;
|
|
302
344
|
// Method 1: Navigate to search results URL (triggers Substrate API call directly)
|
|
@@ -306,7 +348,6 @@ async function triggerTokenAcquisition(page, log) {
|
|
|
306
348
|
waitUntil: 'domcontentloaded',
|
|
307
349
|
timeout: 30000,
|
|
308
350
|
});
|
|
309
|
-
await page.waitForTimeout(5000);
|
|
310
351
|
searchTriggered = true;
|
|
311
352
|
log('Search results page loaded.');
|
|
312
353
|
}
|
|
@@ -355,28 +396,31 @@ async function triggerTokenAcquisition(page, log) {
|
|
|
355
396
|
await page.keyboard.press('Enter');
|
|
356
397
|
searchTriggered = true;
|
|
357
398
|
}
|
|
358
|
-
// Wait for the Substrate
|
|
359
|
-
log('Waiting for
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
page.waitForResponse(resp => resp.url().includes('substrate.office.com') && resp.status() === 200, { timeout: 15000 }),
|
|
364
|
-
page.waitForTimeout(15000),
|
|
365
|
-
]);
|
|
366
|
-
log('Substrate API call detected.');
|
|
399
|
+
// Wait for the Substrate API response
|
|
400
|
+
log('Waiting for Substrate API...');
|
|
401
|
+
await substratePromise;
|
|
402
|
+
if (substrateDetected) {
|
|
403
|
+
log('Substrate API call detected — tokens acquired.');
|
|
367
404
|
}
|
|
368
|
-
|
|
369
|
-
log('No Substrate API call detected
|
|
405
|
+
else {
|
|
406
|
+
log('No Substrate API call detected within timeout.');
|
|
370
407
|
}
|
|
408
|
+
// Give MSAL a moment to persist tokens to localStorage
|
|
371
409
|
await page.waitForTimeout(2000);
|
|
372
410
|
// Close search and reset
|
|
373
|
-
|
|
374
|
-
|
|
411
|
+
try {
|
|
412
|
+
await page.keyboard.press('Escape');
|
|
413
|
+
await page.waitForTimeout(500);
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
// Page may have navigated, ignore
|
|
417
|
+
}
|
|
375
418
|
log('Token acquisition complete.');
|
|
419
|
+
return substrateDetected;
|
|
376
420
|
}
|
|
377
421
|
catch (error) {
|
|
378
422
|
log(`Token acquisition warning: ${error instanceof Error ? error.message : String(error)}`);
|
|
379
|
-
|
|
423
|
+
return false;
|
|
380
424
|
}
|
|
381
425
|
}
|
|
382
426
|
/**
|
|
@@ -517,23 +561,20 @@ export async function waitForManualLogin(page, context, timeoutMs = 5 * 60 * 100
|
|
|
517
561
|
await showLoginProgress(page, 'acquiring');
|
|
518
562
|
}
|
|
519
563
|
// Trigger a search to cause MSAL to acquire the Substrate token
|
|
520
|
-
await triggerTokenAcquisition(page, log);
|
|
564
|
+
const acquired = await triggerTokenAcquisition(page, log);
|
|
565
|
+
if (!acquired) {
|
|
566
|
+
log('Warning: Substrate API call was not detected after login.');
|
|
567
|
+
if (showOverlay) {
|
|
568
|
+
await showLoginProgress(page, 'error', { pause: true });
|
|
569
|
+
}
|
|
570
|
+
throw new Error('Login completed but token acquisition failed. Please try again.');
|
|
571
|
+
}
|
|
521
572
|
if (showOverlay) {
|
|
522
573
|
await showLoginProgress(page, 'saving');
|
|
523
574
|
}
|
|
524
575
|
// Save the session state with fresh tokens
|
|
525
576
|
await saveSessionState(context);
|
|
526
|
-
clearTokenCache();
|
|
527
577
|
log('Session state saved.');
|
|
528
|
-
// Verify tokens were actually acquired
|
|
529
|
-
const token = extractSubstrateToken();
|
|
530
|
-
if (!token || token.expiry.getTime() <= Date.now()) {
|
|
531
|
-
log('Warning: No valid Substrate token found after login. You may need to try again.');
|
|
532
|
-
if (showOverlay) {
|
|
533
|
-
await showLoginProgress(page, 'error', { pause: true });
|
|
534
|
-
}
|
|
535
|
-
throw new Error('Login completed but token acquisition failed. Please try again.');
|
|
536
|
-
}
|
|
537
578
|
if (showOverlay) {
|
|
538
579
|
await showLoginProgress(page, 'complete', { pause: true });
|
|
539
580
|
}
|
|
@@ -566,39 +607,23 @@ export async function ensureAuthenticated(page, context, onProgress, showOverlay
|
|
|
566
607
|
const status = await navigateToTeams(page);
|
|
567
608
|
if (status.isAuthenticated) {
|
|
568
609
|
log('Already authenticated.');
|
|
569
|
-
// Snapshot token expiry before refresh attempt
|
|
570
|
-
const beforeToken = extractSubstrateToken();
|
|
571
|
-
const beforeExpiry = beforeToken?.expiry?.getTime() ?? 0;
|
|
572
610
|
if (showOverlay) {
|
|
573
611
|
await showLoginProgress(page, 'refreshing');
|
|
574
612
|
}
|
|
575
613
|
// Trigger a search to cause MSAL to acquire/refresh the Substrate token
|
|
576
|
-
await triggerTokenAcquisition(page, log);
|
|
614
|
+
const acquired = await triggerTokenAcquisition(page, log);
|
|
615
|
+
if (!acquired) {
|
|
616
|
+
log('Token refresh failed: Substrate API call was not detected.');
|
|
617
|
+
if (showOverlay) {
|
|
618
|
+
await showLoginProgress(page, 'error', { pause: true });
|
|
619
|
+
}
|
|
620
|
+
throw new Error('Token refresh failed. Please use teams_login with forceNew to re-authenticate.');
|
|
621
|
+
}
|
|
577
622
|
if (showOverlay) {
|
|
578
623
|
await showLoginProgress(page, 'saving');
|
|
579
624
|
}
|
|
580
625
|
// Save the session state with fresh tokens
|
|
581
626
|
await saveSessionState(context);
|
|
582
|
-
// Clear token cache so we re-extract from the freshly saved session
|
|
583
|
-
clearTokenCache();
|
|
584
|
-
// Verify tokens were actually refreshed
|
|
585
|
-
const afterToken = extractSubstrateToken();
|
|
586
|
-
const afterExpiry = afterToken?.expiry?.getTime() ?? 0;
|
|
587
|
-
const tokenIsValid = afterToken !== null && afterExpiry > Date.now();
|
|
588
|
-
const tokenWasRefreshed = afterExpiry > beforeExpiry;
|
|
589
|
-
if (!tokenIsValid) {
|
|
590
|
-
log('Warning: No valid token found after refresh attempt.');
|
|
591
|
-
if (headless) {
|
|
592
|
-
throw new Error('Token refresh failed: no valid token after SSO. Browser-based re-authentication required.');
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
else if (!tokenWasRefreshed && beforeToken && beforeExpiry <= Date.now() + 10 * 60 * 1000) {
|
|
596
|
-
// Token was close to expiry but wasn't refreshed - MSAL likely didn't initialise in time
|
|
597
|
-
log('Warning: Token was not refreshed despite being close to expiry.');
|
|
598
|
-
if (headless) {
|
|
599
|
-
throw new Error('Token refresh failed: token not refreshed despite being close to expiry. Browser-based re-authentication required.');
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
627
|
if (showOverlay) {
|
|
603
628
|
await showLoginProgress(page, 'complete', { pause: true });
|
|
604
629
|
}
|
package/dist/research/explore.js
CHANGED
|
@@ -11,18 +11,14 @@
|
|
|
11
11
|
import { chromium } from 'playwright';
|
|
12
12
|
import * as fs from 'fs';
|
|
13
13
|
import * as path from 'path';
|
|
14
|
-
import {
|
|
15
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
-
const PROJECT_ROOT = path.resolve(__dirname, '../..');
|
|
17
|
-
const USER_DATA_DIR = path.join(PROJECT_ROOT, '.user-data');
|
|
18
|
-
const SESSION_STATE_PATH = path.join(PROJECT_ROOT, 'session-state.json');
|
|
14
|
+
import { PROJECT_ROOT, USER_DATA_DIR, readSessionState, writeSessionState, } from '../auth/session-store.js';
|
|
19
15
|
const FINDINGS_PATH = path.join(PROJECT_ROOT, 'research-findings.json');
|
|
20
16
|
// Track interesting API calls
|
|
21
17
|
const capturedNetworkData = {
|
|
22
18
|
requests: [],
|
|
23
19
|
responses: [],
|
|
24
20
|
};
|
|
25
|
-
// Patterns that indicate
|
|
21
|
+
// Patterns that indicate interesting API calls
|
|
26
22
|
const SEARCH_PATTERNS = [
|
|
27
23
|
/search/i,
|
|
28
24
|
/query/i,
|
|
@@ -32,6 +28,13 @@ const SEARCH_PATTERNS = [
|
|
|
32
28
|
/teams.*api/i,
|
|
33
29
|
/chatservice/i,
|
|
34
30
|
/emea\.ng\.msg/i,
|
|
31
|
+
/transcript/i,
|
|
32
|
+
/recording/i,
|
|
33
|
+
/onlineMeeting/i,
|
|
34
|
+
/callRecord/i,
|
|
35
|
+
/\.vtt/i,
|
|
36
|
+
/asyncgw/i,
|
|
37
|
+
/mediaservice/i,
|
|
35
38
|
];
|
|
36
39
|
function isInterestingUrl(url) {
|
|
37
40
|
// Skip static assets, telemetry, and auth-related noise
|
|
@@ -90,7 +93,7 @@ async function captureResponse(response) {
|
|
|
90
93
|
let body;
|
|
91
94
|
try {
|
|
92
95
|
const contentType = response.headers()['content-type'] || '';
|
|
93
|
-
if (contentType.includes('application/json')) {
|
|
96
|
+
if (contentType.includes('application/json') || contentType.includes('text/vtt') || contentType.includes('text/plain')) {
|
|
94
97
|
body = await response.text();
|
|
95
98
|
}
|
|
96
99
|
}
|
|
@@ -190,15 +193,17 @@ async function main() {
|
|
|
190
193
|
let context;
|
|
191
194
|
try {
|
|
192
195
|
// Launch browser with persistent context
|
|
196
|
+
const channel = process.platform === 'win32' ? 'msedge' : 'chrome';
|
|
193
197
|
browser = await chromium.launch({
|
|
194
198
|
headless: false, // Must be visible for manual login
|
|
199
|
+
channel,
|
|
195
200
|
});
|
|
196
|
-
// Check if we have saved session state
|
|
197
|
-
const
|
|
198
|
-
if (
|
|
201
|
+
// Check if we have saved session state (uses encrypted store)
|
|
202
|
+
const existingState = readSessionState();
|
|
203
|
+
if (existingState) {
|
|
199
204
|
console.log('📂 Found existing session state, attempting to restore...');
|
|
200
205
|
context = await browser.newContext({
|
|
201
|
-
storageState:
|
|
206
|
+
storageState: existingState,
|
|
202
207
|
viewport: { width: 1280, height: 800 },
|
|
203
208
|
});
|
|
204
209
|
}
|
|
@@ -221,7 +226,8 @@ async function main() {
|
|
|
221
226
|
await waitForAuthentication(page);
|
|
222
227
|
// Save session state after successful authentication
|
|
223
228
|
console.log('💾 Saving session state...');
|
|
224
|
-
await context.storageState(
|
|
229
|
+
const state = await context.storageState();
|
|
230
|
+
writeSessionState(state);
|
|
225
231
|
console.log('✅ Session state saved!');
|
|
226
232
|
}
|
|
227
233
|
else {
|
|
@@ -246,23 +252,34 @@ async function main() {
|
|
|
246
252
|
resolve();
|
|
247
253
|
});
|
|
248
254
|
});
|
|
249
|
-
// Save
|
|
250
|
-
console.log('💾 Saving final session state...');
|
|
251
|
-
await context.storageState({ path: SESSION_STATE_PATH });
|
|
252
|
-
// Save research findings
|
|
255
|
+
// Save research findings FIRST (before browser might close)
|
|
253
256
|
await saveFindings();
|
|
257
|
+
// Try to save session state (may fail if browser already closed)
|
|
258
|
+
try {
|
|
259
|
+
console.log('💾 Saving final session state...');
|
|
260
|
+
const finalState = await context.storageState();
|
|
261
|
+
writeSessionState(finalState);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
console.log('⚠️ Could not save session state (browser already closed)');
|
|
265
|
+
}
|
|
254
266
|
}
|
|
255
267
|
catch (error) {
|
|
268
|
+
// Still try to save findings on error
|
|
269
|
+
await saveFindings();
|
|
256
270
|
console.error('❌ Error:', error);
|
|
257
|
-
throw error;
|
|
258
271
|
}
|
|
259
272
|
finally {
|
|
260
|
-
|
|
261
|
-
|
|
273
|
+
try {
|
|
274
|
+
if (context)
|
|
275
|
+
await context.close();
|
|
262
276
|
}
|
|
263
|
-
|
|
264
|
-
|
|
277
|
+
catch { /* already closed */ }
|
|
278
|
+
try {
|
|
279
|
+
if (browser)
|
|
280
|
+
await browser.close();
|
|
265
281
|
}
|
|
282
|
+
catch { /* already closed */ }
|
|
266
283
|
}
|
|
267
284
|
console.log('\n✅ Research session complete!');
|
|
268
285
|
console.log(` Review findings in: ${FINDINGS_PATH}`);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Quick diagnostic: dump all MSAL token targets from the session.
|
|
4
|
+
* Run: npm run build && node dist/test/dump-tokens.js
|
|
5
|
+
* Or: npx tsx src/test/dump-tokens.ts
|
|
6
|
+
*/
|
|
7
|
+
import { readSessionState, getTeamsOrigin } from '../auth/session-store.js';
|
|
8
|
+
function decodeJwtPayload(token) {
|
|
9
|
+
try {
|
|
10
|
+
const parts = token.split('.');
|
|
11
|
+
if (parts.length < 2)
|
|
12
|
+
return null;
|
|
13
|
+
return JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const sessionState = readSessionState();
|
|
20
|
+
if (!sessionState) {
|
|
21
|
+
console.log('No session state found. Run teams_login first.');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
const teamsOrigin = getTeamsOrigin(sessionState);
|
|
25
|
+
const localStorage = teamsOrigin?.localStorage ?? [];
|
|
26
|
+
console.log(`Found ${localStorage.length} localStorage entries\n`);
|
|
27
|
+
// Collect all tokens with their targets
|
|
28
|
+
const tokens = [];
|
|
29
|
+
for (const item of localStorage) {
|
|
30
|
+
try {
|
|
31
|
+
const entry = JSON.parse(item.value);
|
|
32
|
+
const target = entry.target;
|
|
33
|
+
const secret = entry.secret;
|
|
34
|
+
if (!target || !secret || !secret.startsWith('ey'))
|
|
35
|
+
continue;
|
|
36
|
+
const payload = decodeJwtPayload(secret);
|
|
37
|
+
if (!payload?.exp || typeof payload.exp !== 'number')
|
|
38
|
+
continue;
|
|
39
|
+
const expiry = new Date(payload.exp * 1000);
|
|
40
|
+
const minutesLeft = Math.round((expiry.getTime() - Date.now()) / 1000 / 60);
|
|
41
|
+
// Extract scopes from the token's scp claim
|
|
42
|
+
const scp = payload.scp;
|
|
43
|
+
const scopes = scp ? scp.split(' ') : [];
|
|
44
|
+
tokens.push({
|
|
45
|
+
target,
|
|
46
|
+
scopes,
|
|
47
|
+
expiry: expiry.toISOString(),
|
|
48
|
+
minutesLeft,
|
|
49
|
+
hasGraph: target.includes('graph.microsoft.com'),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
console.log('=== ALL MSAL TOKENS ===\n');
|
|
57
|
+
for (const t of tokens) {
|
|
58
|
+
const status = t.minutesLeft > 0 ? `✅ ${t.minutesLeft}m left` : '❌ EXPIRED';
|
|
59
|
+
console.log(`Target: ${t.target}`);
|
|
60
|
+
console.log(`Status: ${status}`);
|
|
61
|
+
if (t.scopes.length > 0) {
|
|
62
|
+
console.log(`Scopes: ${t.scopes.join(', ')}`);
|
|
63
|
+
}
|
|
64
|
+
console.log();
|
|
65
|
+
}
|
|
66
|
+
// Highlight Graph tokens
|
|
67
|
+
const graphTokens = tokens.filter(t => t.hasGraph);
|
|
68
|
+
console.log('=== GRAPH TOKENS ===\n');
|
|
69
|
+
if (graphTokens.length === 0) {
|
|
70
|
+
console.log('⚠️ No graph.microsoft.com tokens found in session.');
|
|
71
|
+
console.log(' The Teams web app may not store Graph tokens in localStorage.');
|
|
72
|
+
console.log(' Transcript API will need to use the middle tier instead.');
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
for (const t of graphTokens) {
|
|
76
|
+
console.log(`Target: ${t.target}`);
|
|
77
|
+
console.log(`Scopes: ${t.scopes.join(', ')}`);
|
|
78
|
+
console.log(`Expiry: ${t.expiry} (${t.minutesLeft}m left)`);
|
|
79
|
+
const hasTranscriptScope = t.scopes.some(s => s.toLowerCase().includes('transcript') ||
|
|
80
|
+
s.toLowerCase().includes('onlinemeeting'));
|
|
81
|
+
if (hasTranscriptScope) {
|
|
82
|
+
console.log('✅ Has transcript-related scopes!');
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
console.log('⚠️ No transcript-specific scopes found in this token.');
|
|
86
|
+
console.log(' May still work if the first-party Teams app has implicit access.');
|
|
87
|
+
}
|
|
88
|
+
console.log();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Test the transcript API end-to-end.
|
|
4
|
+
* Run: npx tsx src/test/test-transcript.ts
|
|
5
|
+
*/
|
|
6
|
+
import { getCalendarView } from '../api/calendar-api.js';
|
|
7
|
+
import { getTranscriptContent } from '../api/transcript-api.js';
|
|
8
|
+
async function main() {
|
|
9
|
+
console.log('=== Get recent meetings with threadId ===');
|
|
10
|
+
const now = new Date();
|
|
11
|
+
const pastWeek = new Date(now);
|
|
12
|
+
pastWeek.setDate(pastWeek.getDate() - 14);
|
|
13
|
+
const meetingsResult = await getCalendarView({
|
|
14
|
+
startDate: pastWeek.toISOString(),
|
|
15
|
+
endDate: now.toISOString(),
|
|
16
|
+
limit: 50,
|
|
17
|
+
});
|
|
18
|
+
if (!meetingsResult.ok) {
|
|
19
|
+
console.log(`❌ ${meetingsResult.error.message}`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
const meetings = meetingsResult.value.meetings.filter(m => m.threadId);
|
|
23
|
+
console.log(`Found ${meetings.length} meetings with threadId\n`);
|
|
24
|
+
// Try each meeting until we find one with a transcript
|
|
25
|
+
for (const meeting of meetings.slice(0, 10)) {
|
|
26
|
+
console.log(`--- "${meeting.subject}" (${meeting.startTime}) ---`);
|
|
27
|
+
console.log(`Thread: ${meeting.threadId}`);
|
|
28
|
+
const result = await getTranscriptContent(meeting.threadId, meeting.startTime);
|
|
29
|
+
if (result.ok) {
|
|
30
|
+
console.log(`\n✅ SUCCESS!`);
|
|
31
|
+
console.log(`Title: ${result.value.meetingTitle}`);
|
|
32
|
+
console.log(`Speakers: ${result.value.speakers.join(', ')}`);
|
|
33
|
+
console.log(`Entries: ${result.value.entryCount}`);
|
|
34
|
+
console.log(`Recording: ${result.value.recordingStartTime} → ${result.value.recordingEndTime}`);
|
|
35
|
+
console.log(`\nFirst 800 chars:\n${result.value.formattedText.substring(0, 800)}`);
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
console.log(` ❌ ${result.error.code}: ${result.error.message}\n`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
console.log('No transcripts found for any recent meeting.');
|
|
43
|
+
}
|
|
44
|
+
main().catch(e => {
|
|
45
|
+
console.error('Fatal error:', e);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
});
|
|
@@ -16,9 +16,20 @@ export declare const GetMeetingsInputSchema: z.ZodObject<{
|
|
|
16
16
|
startDate?: string | undefined;
|
|
17
17
|
endDate?: string | undefined;
|
|
18
18
|
}>;
|
|
19
|
+
export declare const GetTranscriptInputSchema: z.ZodObject<{
|
|
20
|
+
threadId: z.ZodString;
|
|
21
|
+
meetingDate: z.ZodOptional<z.ZodString>;
|
|
22
|
+
}, "strip", z.ZodTypeAny, {
|
|
23
|
+
threadId: string;
|
|
24
|
+
meetingDate?: string | undefined;
|
|
25
|
+
}, {
|
|
26
|
+
threadId: string;
|
|
27
|
+
meetingDate?: string | undefined;
|
|
28
|
+
}>;
|
|
19
29
|
export declare const getMeetingsTool: RegisteredTool<typeof GetMeetingsInputSchema>;
|
|
30
|
+
export declare const getTranscriptTool: RegisteredTool<typeof GetTranscriptInputSchema>;
|
|
20
31
|
/** All meeting-related tools. */
|
|
21
|
-
export declare const meetingTools: RegisteredTool<z.ZodObject<{
|
|
32
|
+
export declare const meetingTools: (RegisteredTool<z.ZodObject<{
|
|
22
33
|
startDate: z.ZodOptional<z.ZodString>;
|
|
23
34
|
endDate: z.ZodOptional<z.ZodString>;
|
|
24
35
|
limit: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
@@ -30,4 +41,13 @@ export declare const meetingTools: RegisteredTool<z.ZodObject<{
|
|
|
30
41
|
limit?: number | undefined;
|
|
31
42
|
startDate?: string | undefined;
|
|
32
43
|
endDate?: string | undefined;
|
|
33
|
-
}>>
|
|
44
|
+
}>> | RegisteredTool<z.ZodObject<{
|
|
45
|
+
threadId: z.ZodString;
|
|
46
|
+
meetingDate: z.ZodOptional<z.ZodString>;
|
|
47
|
+
}, "strip", z.ZodTypeAny, {
|
|
48
|
+
threadId: string;
|
|
49
|
+
meetingDate?: string | undefined;
|
|
50
|
+
}, {
|
|
51
|
+
threadId: string;
|
|
52
|
+
meetingDate?: string | undefined;
|
|
53
|
+
}>>)[];
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { handleApiResult } from './index.js';
|
|
6
6
|
import { getCalendarView } from '../api/calendar-api.js';
|
|
7
|
+
import { getTranscriptContent } from '../api/transcript-api.js';
|
|
7
8
|
import { DEFAULT_MEETING_LIMIT, MAX_MEETING_LIMIT, } from '../constants.js';
|
|
8
9
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
9
10
|
// Schemas
|
|
@@ -13,6 +14,10 @@ export const GetMeetingsInputSchema = z.object({
|
|
|
13
14
|
endDate: z.string().optional(),
|
|
14
15
|
limit: z.number().min(1).max(MAX_MEETING_LIMIT).optional().default(DEFAULT_MEETING_LIMIT),
|
|
15
16
|
});
|
|
17
|
+
export const GetTranscriptInputSchema = z.object({
|
|
18
|
+
threadId: z.string(),
|
|
19
|
+
meetingDate: z.string().optional(),
|
|
20
|
+
});
|
|
16
21
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
22
|
// Tool Definitions
|
|
18
23
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -53,6 +58,36 @@ async function handleGetMeetings(input, _ctx) {
|
|
|
53
58
|
}));
|
|
54
59
|
}
|
|
55
60
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
61
|
+
// Transcript Tool
|
|
62
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
63
|
+
const getTranscriptToolDefinition = {
|
|
64
|
+
name: 'teams_get_transcript',
|
|
65
|
+
description: 'Get the transcript of a Teams meeting. Requires the meeting\'s threadId (from teams_get_meetings). Returns the full transcript with timestamps and speakers, formatted as readable text. The meeting must have had transcription enabled. Optionally pass meetingDate (ISO string, e.g. the startTime from teams_get_meetings) to narrow the search.',
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
properties: {
|
|
69
|
+
threadId: {
|
|
70
|
+
type: 'string',
|
|
71
|
+
description: 'The meeting thread ID (from the threadId field of teams_get_meetings results, e.g., "19:meeting_xxx@thread.v2").',
|
|
72
|
+
},
|
|
73
|
+
meetingDate: {
|
|
74
|
+
type: 'string',
|
|
75
|
+
description: 'Optional ISO date/time of the meeting (e.g., the startTime from teams_get_meetings). Helps narrow the search for recurring meetings.',
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
required: ['threadId'],
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
async function handleGetTranscript(input, _ctx) {
|
|
82
|
+
const result = await getTranscriptContent(input.threadId, input.meetingDate);
|
|
83
|
+
return handleApiResult(result, (value) => ({
|
|
84
|
+
meetingTitle: value.meetingTitle,
|
|
85
|
+
speakers: value.speakers,
|
|
86
|
+
entryCount: value.entryCount,
|
|
87
|
+
transcript: value.formattedText,
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
56
91
|
// Exports
|
|
57
92
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
58
93
|
export const getMeetingsTool = {
|
|
@@ -60,5 +95,10 @@ export const getMeetingsTool = {
|
|
|
60
95
|
schema: GetMeetingsInputSchema,
|
|
61
96
|
handler: handleGetMeetings,
|
|
62
97
|
};
|
|
98
|
+
export const getTranscriptTool = {
|
|
99
|
+
definition: getTranscriptToolDefinition,
|
|
100
|
+
schema: GetTranscriptInputSchema,
|
|
101
|
+
handler: handleGetTranscript,
|
|
102
|
+
};
|
|
63
103
|
/** All meeting-related tools. */
|
|
64
|
-
export const meetingTools = [getMeetingsTool];
|
|
104
|
+
export const meetingTools = [getMeetingsTool, getTranscriptTool];
|
package/dist/utils/parsers.d.ts
CHANGED
|
@@ -266,3 +266,24 @@ export interface VirtualConversationItem {
|
|
|
266
266
|
* @returns Parsed virtual conversation item, or null if message should be skipped
|
|
267
267
|
*/
|
|
268
268
|
export declare function parseVirtualConversationMessage(msg: Record<string, unknown>, referencePattern: RegExp): VirtualConversationItem | null;
|
|
269
|
+
/** A single entry from a meeting transcript. */
|
|
270
|
+
export interface TranscriptEntry {
|
|
271
|
+
/** Start time (e.g., "00:00:22.287"). */
|
|
272
|
+
startTime: string;
|
|
273
|
+
/** End time (e.g., "00:00:23.167"). */
|
|
274
|
+
endTime: string;
|
|
275
|
+
/** Speaker display name. */
|
|
276
|
+
speaker: string;
|
|
277
|
+
/** Spoken text content. */
|
|
278
|
+
text: string;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Formats transcript entries into a readable text format.
|
|
282
|
+
*
|
|
283
|
+
* Merges consecutive entries from the same speaker into a single block
|
|
284
|
+
* to reduce noise and improve readability.
|
|
285
|
+
*
|
|
286
|
+
* @param entries - Transcript entries
|
|
287
|
+
* @returns Formatted transcript string
|
|
288
|
+
*/
|
|
289
|
+
export declare function formatTranscriptText(entries: TranscriptEntry[]): string;
|
package/dist/utils/parsers.js
CHANGED
|
@@ -878,3 +878,45 @@ export function parseVirtualConversationMessage(msg, referencePattern) {
|
|
|
878
878
|
links: links.length > 0 ? links : undefined,
|
|
879
879
|
};
|
|
880
880
|
}
|
|
881
|
+
/**
|
|
882
|
+
* Formats transcript entries into a readable text format.
|
|
883
|
+
*
|
|
884
|
+
* Merges consecutive entries from the same speaker into a single block
|
|
885
|
+
* to reduce noise and improve readability.
|
|
886
|
+
*
|
|
887
|
+
* @param entries - Transcript entries
|
|
888
|
+
* @returns Formatted transcript string
|
|
889
|
+
*/
|
|
890
|
+
export function formatTranscriptText(entries) {
|
|
891
|
+
if (entries.length === 0)
|
|
892
|
+
return '';
|
|
893
|
+
const blocks = [];
|
|
894
|
+
let currentSpeaker = null;
|
|
895
|
+
let currentTexts = [];
|
|
896
|
+
let blockStartTime = '';
|
|
897
|
+
for (const entry of entries) {
|
|
898
|
+
if (entry.speaker !== currentSpeaker) {
|
|
899
|
+
// Flush previous block
|
|
900
|
+
if (currentTexts.length > 0) {
|
|
901
|
+
const prefix = currentSpeaker
|
|
902
|
+
? `[${blockStartTime}] ${currentSpeaker}:`
|
|
903
|
+
: `[${blockStartTime}]`;
|
|
904
|
+
blocks.push(`${prefix}\n${currentTexts.join(' ')}`);
|
|
905
|
+
}
|
|
906
|
+
currentSpeaker = entry.speaker;
|
|
907
|
+
currentTexts = [entry.text];
|
|
908
|
+
blockStartTime = entry.startTime;
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
currentTexts.push(entry.text);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
// Flush last block
|
|
915
|
+
if (currentTexts.length > 0) {
|
|
916
|
+
const prefix = currentSpeaker
|
|
917
|
+
? `[${blockStartTime}] ${currentSpeaker}:`
|
|
918
|
+
: `[${blockStartTime}]`;
|
|
919
|
+
blocks.push(`${prefix}\n${currentTexts.join(' ')}`);
|
|
920
|
+
}
|
|
921
|
+
return blocks.join('\n\n');
|
|
922
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* produce expected outputs regardless of internal logic.
|
|
6
6
|
*/
|
|
7
7
|
import { describe, it, expect } from 'vitest';
|
|
8
|
-
import { stripHtml, extractLinks, buildMessageLink, getConversationType, extractMessageTimestamp, parsePersonSuggestion, parseV2Result, parseJwtProfile, calculateTokenStatus, parseSearchResults, parsePeopleResults, extractObjectId, buildOneOnOneConversationId, decodeBase64Guid, extractActivityTimestamp, markdownToTeamsHtml, } from './parsers.js';
|
|
8
|
+
import { stripHtml, extractLinks, buildMessageLink, getConversationType, extractMessageTimestamp, parsePersonSuggestion, parseV2Result, parseJwtProfile, calculateTokenStatus, parseSearchResults, parsePeopleResults, extractObjectId, buildOneOnOneConversationId, decodeBase64Guid, extractActivityTimestamp, markdownToTeamsHtml, formatTranscriptText, } from './parsers.js';
|
|
9
9
|
import { searchResultItem, searchResultWithHtml, searchResultMinimal, searchResultTooShort, searchResultThreadReply, searchEntitySetsResponse, personSuggestion, personMinimal, personWithBase64Id, peopleGroupsResponse, jwtPayloadFull, jwtPayloadMinimal, jwtPayloadCommaName, jwtPayloadSpaceName, sourceWithMessageId, sourceWithConvIdMessageId, } from '../__fixtures__/api-responses.js';
|
|
10
10
|
describe('stripHtml', () => {
|
|
11
11
|
it('removes HTML tags', () => {
|
|
@@ -581,3 +581,32 @@ describe('markdownToTeamsHtml', () => {
|
|
|
581
581
|
expect(markdownToTeamsHtml(' ')).toBe('<p></p>');
|
|
582
582
|
});
|
|
583
583
|
});
|
|
584
|
+
describe('formatTranscriptText', () => {
|
|
585
|
+
it('formats entries with speaker names and timestamps', () => {
|
|
586
|
+
const entries = [
|
|
587
|
+
{ startTime: '00:00:01.000', endTime: '00:00:05.000', speaker: 'Alice', text: 'Hello everyone.' },
|
|
588
|
+
{ startTime: '00:00:06.000', endTime: '00:00:10.000', speaker: 'Bob', text: 'Hi Alice!' },
|
|
589
|
+
];
|
|
590
|
+
const result = formatTranscriptText(entries);
|
|
591
|
+
expect(result).toBe('[00:00:01.000] Alice:\nHello everyone.\n\n[00:00:06.000] Bob:\nHi Alice!');
|
|
592
|
+
});
|
|
593
|
+
it('merges consecutive entries from the same speaker', () => {
|
|
594
|
+
const entries = [
|
|
595
|
+
{ startTime: '00:00:01.000', endTime: '00:00:03.000', speaker: 'Alice', text: 'First part.' },
|
|
596
|
+
{ startTime: '00:00:03.000', endTime: '00:00:06.000', speaker: 'Alice', text: 'Second part.' },
|
|
597
|
+
{ startTime: '00:00:07.000', endTime: '00:00:10.000', speaker: 'Bob', text: 'Response.' },
|
|
598
|
+
];
|
|
599
|
+
const result = formatTranscriptText(entries);
|
|
600
|
+
expect(result).toBe('[00:00:01.000] Alice:\nFirst part. Second part.\n\n[00:00:07.000] Bob:\nResponse.');
|
|
601
|
+
});
|
|
602
|
+
it('handles entries without speaker names', () => {
|
|
603
|
+
const entries = [
|
|
604
|
+
{ startTime: '00:00:01.000', endTime: '00:00:05.000', speaker: '', text: 'Unknown speaker.' },
|
|
605
|
+
];
|
|
606
|
+
const result = formatTranscriptText(entries);
|
|
607
|
+
expect(result).toBe('[00:00:01.000]\nUnknown speaker.');
|
|
608
|
+
});
|
|
609
|
+
it('returns empty string for empty entries', () => {
|
|
610
|
+
expect(formatTranscriptText([])).toBe('');
|
|
611
|
+
});
|
|
612
|
+
});
|