msteams-mcp 0.14.3 → 0.15.3
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/dist/api/chatsvc-api.js +36 -33
- package/dist/browser/auth.js +79 -54
- package/dist/tools/message-tools.js +2 -2
- package/dist/utils/parsers.d.ts +21 -0
- package/dist/utils/parsers.js +149 -0
- package/dist/utils/parsers.test.js +73 -1
- package/package.json +1 -1
package/dist/api/chatsvc-api.js
CHANGED
|
@@ -10,7 +10,7 @@ import { ErrorCode, createError } from '../types/errors.js';
|
|
|
10
10
|
import { ok, err } from '../types/result.js';
|
|
11
11
|
import { getUserDisplayName } from '../auth/token-extractor.js';
|
|
12
12
|
import { requireMessageAuth, getRegion, getTeamsBaseUrl } from '../utils/auth-guards.js';
|
|
13
|
-
import { stripHtml, extractLinks, buildMessageLink, buildOneOnOneConversationId, extractObjectId, extractActivityTimestamp, parseVirtualConversationMessage } from '../utils/parsers.js';
|
|
13
|
+
import { stripHtml, extractLinks, buildMessageLink, buildOneOnOneConversationId, extractObjectId, extractActivityTimestamp, parseVirtualConversationMessage, markdownToTeamsHtml } from '../utils/parsers.js';
|
|
14
14
|
import { DEFAULT_ACTIVITY_LIMIT, SAVED_MESSAGES_ID, FOLLOWED_THREADS_ID, VIRTUAL_CONVERSATION_PREFIX, SELF_CHAT_ID, MRI_ORGID_PREFIX } from '../constants.js';
|
|
15
15
|
// Reusable date formatter for human-readable timestamps with day of week
|
|
16
16
|
// Hoisted to module scope to avoid creating a new formatter per message
|
|
@@ -68,23 +68,12 @@ export async function sendMessage(conversationId, content, options = {}) {
|
|
|
68
68
|
const displayName = getUserDisplayName() || 'User';
|
|
69
69
|
// Generate unique message ID
|
|
70
70
|
const clientMessageId = Date.now().toString();
|
|
71
|
-
// Process content: handle mentions and
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
else {
|
|
79
|
-
// Process mentions and links together
|
|
80
|
-
const parsed = parseContentWithMentionsAndLinks(content);
|
|
81
|
-
processedContent = parsed.html;
|
|
82
|
-
mentionsToSend = parsed.mentions;
|
|
83
|
-
}
|
|
84
|
-
// Wrap content in paragraph if not already HTML
|
|
85
|
-
const htmlContent = processedContent.startsWith('<')
|
|
86
|
-
? processedContent
|
|
87
|
-
: `<p>${processedContent}</p>`;
|
|
71
|
+
// Process content: handle mentions, links, and markdown formatting
|
|
72
|
+
// Always convert through markdown→HTML pipeline (never pass raw HTML through,
|
|
73
|
+
// as Teams requires proper block-level wrapping like <p> tags)
|
|
74
|
+
const parsed = parseContentWithMentionsAndLinks(content);
|
|
75
|
+
const htmlContent = parsed.html;
|
|
76
|
+
const mentionsToSend = parsed.mentions;
|
|
88
77
|
// Build the message body
|
|
89
78
|
const body = {
|
|
90
79
|
content: htmlContent,
|
|
@@ -388,8 +377,9 @@ export async function editMessage(conversationId, messageId, newContent) {
|
|
|
388
377
|
const auth = authResult.value;
|
|
389
378
|
const { region, baseUrl } = getApiConfig();
|
|
390
379
|
const displayName = getUserDisplayName() || 'User';
|
|
391
|
-
//
|
|
392
|
-
|
|
380
|
+
// Always convert through markdown→HTML pipeline (never pass raw HTML through,
|
|
381
|
+
// as Teams requires proper block-level wrapping like <p> tags)
|
|
382
|
+
const htmlContent = markdownToTeamsHtml(newContent);
|
|
393
383
|
// Build the edit request body
|
|
394
384
|
// The API requires the message structure with updated content
|
|
395
385
|
const body = {
|
|
@@ -624,30 +614,43 @@ function parseContentWithMentionsAndLinks(content) {
|
|
|
624
614
|
...findAll(mentionPattern, 'mention'),
|
|
625
615
|
...findAll(linkPattern, 'link'),
|
|
626
616
|
].sort((a, b) => a.index - b.index);
|
|
627
|
-
//
|
|
617
|
+
// No mentions or links - use full markdown conversion
|
|
618
|
+
if (matches.length === 0) {
|
|
619
|
+
return { html: markdownToTeamsHtml(content), mentions: [] };
|
|
620
|
+
}
|
|
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.
|
|
628
624
|
const mentions = [];
|
|
629
|
-
|
|
630
|
-
let lastIndex = 0;
|
|
625
|
+
const placeholders = new Map();
|
|
631
626
|
let mentionId = 0;
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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;
|
|
636
633
|
if (m.type === 'mention') {
|
|
637
634
|
mentions.push({ mri: m.target, displayName: m.text });
|
|
638
|
-
|
|
635
|
+
html = buildMentionHtml(m.text, mentionId);
|
|
639
636
|
mentionId++;
|
|
640
637
|
}
|
|
641
638
|
else {
|
|
642
|
-
// Link
|
|
643
639
|
const safeText = escapeHtml(m.text);
|
|
644
640
|
const safeUrl = m.target.replace(/"/g, '"');
|
|
645
|
-
|
|
641
|
+
html = `<a href="${safeUrl}">${safeText}</a>`;
|
|
646
642
|
}
|
|
647
|
-
|
|
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);
|
|
648
653
|
}
|
|
649
|
-
// Add remaining text
|
|
650
|
-
result += escapeHtml(content.substring(lastIndex));
|
|
651
654
|
return { html: result, mentions };
|
|
652
655
|
}
|
|
653
656
|
/**
|
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
|
}
|
|
@@ -72,13 +72,13 @@ export const GetFollowedThreadsInputSchema = z.object({
|
|
|
72
72
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
73
73
|
const sendMessageToolDefinition = {
|
|
74
74
|
name: 'teams_send_message',
|
|
75
|
-
description: 'Send a message to a Teams conversation. Supports @mentions using @[Name](mri) syntax inline. Example: "Hey @[John Smith](8:orgid:abc...), check this". Get MRI from teams_search_people. Defaults to self-notes (48:notes). For channel thread replies, provide replyToMessageId.',
|
|
75
|
+
description: 'Send a message to a Teams conversation. Use markdown for formatting (not HTML): **bold**, *italic*, ~~strikethrough~~, `code`, ```code blocks```, lists, and newlines. Supports @mentions using @[Name](mri) syntax inline. Example: "Hey @[John Smith](8:orgid:abc...), check this". Get MRI from teams_search_people. Defaults to self-notes (48:notes). For channel thread replies, provide replyToMessageId.',
|
|
76
76
|
inputSchema: {
|
|
77
77
|
type: 'object',
|
|
78
78
|
properties: {
|
|
79
79
|
content: {
|
|
80
80
|
type: 'string',
|
|
81
|
-
description: 'The message content. For @mentions, use @[DisplayName](mri) syntax. Example: "Hey @[John Smith](8:orgid:abc...), can you review this?"',
|
|
81
|
+
description: 'The message content in markdown (not HTML). Supports: **bold**, *italic*, ~~strikethrough~~, `inline code`, ```code blocks```, bullet lists (- item), numbered lists (1. item), and newlines. Do NOT send raw HTML tags. For @mentions, use @[DisplayName](mri) syntax. Example: "Hey @[John Smith](8:orgid:abc...), can you review this?"',
|
|
82
82
|
},
|
|
83
83
|
conversationId: {
|
|
84
84
|
type: 'string',
|
package/dist/utils/parsers.d.ts
CHANGED
|
@@ -218,6 +218,27 @@ export declare function buildOneOnOneConversationId(userId1: string, userId2: st
|
|
|
218
218
|
* @returns ISO timestamp string, or null if no valid timestamp found
|
|
219
219
|
*/
|
|
220
220
|
export declare function extractActivityTimestamp(msg: Record<string, unknown>): string | null;
|
|
221
|
+
/**
|
|
222
|
+
* Converts markdown-formatted text to Teams-compatible HTML.
|
|
223
|
+
*
|
|
224
|
+
* Supports:
|
|
225
|
+
* - **bold** / __bold__ → <b>
|
|
226
|
+
* - *italic* / _italic_ → <i>
|
|
227
|
+
* - ~~strikethrough~~ → <s>
|
|
228
|
+
* - `inline code` → <code>
|
|
229
|
+
* - ```code blocks``` → <pre><code>
|
|
230
|
+
* - Newlines → paragraph breaks
|
|
231
|
+
* - Ordered lists (1. item) → <ol><li>
|
|
232
|
+
* - Unordered lists (- item, * item) → <ul><li>
|
|
233
|
+
*
|
|
234
|
+
* Plain text without any formatting is returned as-is (HTML-escaped).
|
|
235
|
+
*/
|
|
236
|
+
export declare function markdownToTeamsHtml(text: string): string;
|
|
237
|
+
/**
|
|
238
|
+
* Checks whether text contains any markdown formatting that would
|
|
239
|
+
* benefit from conversion to HTML.
|
|
240
|
+
*/
|
|
241
|
+
export declare function hasMarkdownFormatting(text: string): boolean;
|
|
221
242
|
/** Common fields from a virtual conversation message (48:saved, 48:threads, etc). */
|
|
222
243
|
export interface VirtualConversationItem {
|
|
223
244
|
id: string;
|
package/dist/utils/parsers.js
CHANGED
|
@@ -669,6 +669,155 @@ export function extractActivityTimestamp(msg) {
|
|
|
669
669
|
}
|
|
670
670
|
return null;
|
|
671
671
|
}
|
|
672
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
673
|
+
// Markdown to Teams HTML Conversion
|
|
674
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
675
|
+
/**
|
|
676
|
+
* Escapes HTML special characters in text.
|
|
677
|
+
*/
|
|
678
|
+
function escapeHtmlChars(text) {
|
|
679
|
+
return text
|
|
680
|
+
.replace(/&/g, '&')
|
|
681
|
+
.replace(/</g, '<')
|
|
682
|
+
.replace(/>/g, '>')
|
|
683
|
+
.replace(/"/g, '"');
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Converts inline markdown formatting to Teams HTML within a single line.
|
|
687
|
+
* Handles: bold, italic, strikethrough, inline code.
|
|
688
|
+
* Text outside of formatting markers is HTML-escaped.
|
|
689
|
+
*/
|
|
690
|
+
function convertInlineFormatting(line) {
|
|
691
|
+
// Process inline code first (to prevent other formatting inside code spans)
|
|
692
|
+
// Split on `code` patterns, escape and format alternately
|
|
693
|
+
const codeParts = line.split(/`([^`]+)`/);
|
|
694
|
+
let result = '';
|
|
695
|
+
for (let i = 0; i < codeParts.length; i++) {
|
|
696
|
+
if (i % 2 === 1) {
|
|
697
|
+
// Inside backticks - render as code, only escape HTML
|
|
698
|
+
result += `<code>${escapeHtmlChars(codeParts[i])}</code>`;
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
701
|
+
// Outside backticks - process other inline formatting
|
|
702
|
+
let segment = escapeHtmlChars(codeParts[i]);
|
|
703
|
+
// Bold: **text** or __text__
|
|
704
|
+
segment = segment.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
|
|
705
|
+
segment = segment.replace(/__(.+?)__/g, '<b>$1</b>');
|
|
706
|
+
// Italic: *text* or _text_ (but not inside words for underscore)
|
|
707
|
+
segment = segment.replace(/\*(.+?)\*/g, '<i>$1</i>');
|
|
708
|
+
segment = segment.replace(/(?<!\w)_(.+?)_(?!\w)/g, '<i>$1</i>');
|
|
709
|
+
// Strikethrough: ~~text~~
|
|
710
|
+
segment = segment.replace(/~~(.+?)~~/g, '<s>$1</s>');
|
|
711
|
+
result += segment;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return result;
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Converts markdown-formatted text to Teams-compatible HTML.
|
|
718
|
+
*
|
|
719
|
+
* Supports:
|
|
720
|
+
* - **bold** / __bold__ → <b>
|
|
721
|
+
* - *italic* / _italic_ → <i>
|
|
722
|
+
* - ~~strikethrough~~ → <s>
|
|
723
|
+
* - `inline code` → <code>
|
|
724
|
+
* - ```code blocks``` → <pre><code>
|
|
725
|
+
* - Newlines → paragraph breaks
|
|
726
|
+
* - Ordered lists (1. item) → <ol><li>
|
|
727
|
+
* - Unordered lists (- item, * item) → <ul><li>
|
|
728
|
+
*
|
|
729
|
+
* Plain text without any formatting is returned as-is (HTML-escaped).
|
|
730
|
+
*/
|
|
731
|
+
export function markdownToTeamsHtml(text) {
|
|
732
|
+
// Handle fenced code blocks first (```...```)
|
|
733
|
+
// Split text into code blocks and non-code-block segments
|
|
734
|
+
const segments = [];
|
|
735
|
+
const codeBlockRegex = /```(\w*)\n?([\s\S]*?)```/g;
|
|
736
|
+
let lastIndex = 0;
|
|
737
|
+
let match;
|
|
738
|
+
while ((match = codeBlockRegex.exec(text)) !== null) {
|
|
739
|
+
// Text before this code block
|
|
740
|
+
if (match.index > lastIndex) {
|
|
741
|
+
segments.push({ type: 'text', content: text.substring(lastIndex, match.index) });
|
|
742
|
+
}
|
|
743
|
+
segments.push({ type: 'codeblock', content: match[2], lang: match[1] || undefined });
|
|
744
|
+
lastIndex = match.index + match[0].length;
|
|
745
|
+
}
|
|
746
|
+
// Remaining text after last code block
|
|
747
|
+
if (lastIndex < text.length) {
|
|
748
|
+
segments.push({ type: 'text', content: text.substring(lastIndex) });
|
|
749
|
+
}
|
|
750
|
+
const htmlParts = [];
|
|
751
|
+
for (const segment of segments) {
|
|
752
|
+
if (segment.type === 'codeblock') {
|
|
753
|
+
// Code blocks: escape HTML but preserve whitespace
|
|
754
|
+
const escaped = escapeHtmlChars(segment.content.replace(/\n$/, ''));
|
|
755
|
+
htmlParts.push(`<pre><code>${escaped}</code></pre>`);
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
// Process text segments: split into paragraphs on double newlines
|
|
759
|
+
const paragraphs = segment.content.split(/\n{2,}/);
|
|
760
|
+
for (const para of paragraphs) {
|
|
761
|
+
const trimmed = para.trim();
|
|
762
|
+
if (!trimmed)
|
|
763
|
+
continue;
|
|
764
|
+
const lines = trimmed.split('\n');
|
|
765
|
+
// Check if this paragraph is a list
|
|
766
|
+
const isUnorderedList = lines.every(l => /^\s*[-*]\s+/.test(l));
|
|
767
|
+
const isOrderedList = lines.every(l => /^\s*\d+[.)]\s+/.test(l));
|
|
768
|
+
if (isUnorderedList) {
|
|
769
|
+
const items = lines.map(l => {
|
|
770
|
+
const content = l.replace(/^\s*[-*]\s+/, '');
|
|
771
|
+
return `<li>${convertInlineFormatting(content)}</li>`;
|
|
772
|
+
});
|
|
773
|
+
htmlParts.push(`<ul>${items.join('')}</ul>`);
|
|
774
|
+
}
|
|
775
|
+
else if (isOrderedList) {
|
|
776
|
+
const items = lines.map(l => {
|
|
777
|
+
const content = l.replace(/^\s*\d+[.)]\s+/, '');
|
|
778
|
+
return `<li>${convertInlineFormatting(content)}</li>`;
|
|
779
|
+
});
|
|
780
|
+
htmlParts.push(`<ol>${items.join('')}</ol>`);
|
|
781
|
+
}
|
|
782
|
+
else {
|
|
783
|
+
// Regular paragraph - join lines with <br>
|
|
784
|
+
const htmlLines = lines.map(l => convertInlineFormatting(l));
|
|
785
|
+
htmlParts.push(`<p>${htmlLines.join('<br>')}</p>`);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return htmlParts.join('') || '<p></p>';
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Checks whether text contains any markdown formatting that would
|
|
793
|
+
* benefit from conversion to HTML.
|
|
794
|
+
*/
|
|
795
|
+
export function hasMarkdownFormatting(text) {
|
|
796
|
+
// Code blocks
|
|
797
|
+
if (/```[\s\S]*```/.test(text))
|
|
798
|
+
return true;
|
|
799
|
+
// Inline code
|
|
800
|
+
if (/`[^`]+`/.test(text))
|
|
801
|
+
return true;
|
|
802
|
+
// Bold
|
|
803
|
+
if (/\*\*.+?\*\*/.test(text) || /__.+?__/.test(text))
|
|
804
|
+
return true;
|
|
805
|
+
// Italic (single * or _)
|
|
806
|
+
if (/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/.test(text))
|
|
807
|
+
return true;
|
|
808
|
+
// Strikethrough
|
|
809
|
+
if (/~~.+?~~/.test(text))
|
|
810
|
+
return true;
|
|
811
|
+
// Lists
|
|
812
|
+
if (/^\s*[-*]\s+/m.test(text))
|
|
813
|
+
return true;
|
|
814
|
+
if (/^\s*\d+[.)]\s+/m.test(text))
|
|
815
|
+
return true;
|
|
816
|
+
// Multiple newlines (paragraph breaks)
|
|
817
|
+
if (/\n/.test(text))
|
|
818
|
+
return true;
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
672
821
|
/**
|
|
673
822
|
* Parses a raw message from a virtual conversation (48:saved, 48:threads, etc).
|
|
674
823
|
*
|
|
@@ -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, } from './parsers.js';
|
|
8
|
+
import { stripHtml, extractLinks, buildMessageLink, getConversationType, extractMessageTimestamp, parsePersonSuggestion, parseV2Result, parseJwtProfile, calculateTokenStatus, parseSearchResults, parsePeopleResults, extractObjectId, buildOneOnOneConversationId, decodeBase64Guid, extractActivityTimestamp, markdownToTeamsHtml, } 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', () => {
|
|
@@ -509,3 +509,75 @@ describe('extractActivityTimestamp', () => {
|
|
|
509
509
|
expect(extractActivityTimestamp(msg)).toBeNull();
|
|
510
510
|
});
|
|
511
511
|
});
|
|
512
|
+
describe('markdownToTeamsHtml', () => {
|
|
513
|
+
it('wraps plain text in paragraph tags', () => {
|
|
514
|
+
expect(markdownToTeamsHtml('Hello world')).toBe('<p>Hello world</p>');
|
|
515
|
+
});
|
|
516
|
+
it('escapes HTML special characters', () => {
|
|
517
|
+
expect(markdownToTeamsHtml('1 < 2 & 3 > 0')).toBe('<p>1 < 2 & 3 > 0</p>');
|
|
518
|
+
});
|
|
519
|
+
it('converts bold markdown', () => {
|
|
520
|
+
expect(markdownToTeamsHtml('This is **bold** text')).toBe('<p>This is <b>bold</b> text</p>');
|
|
521
|
+
expect(markdownToTeamsHtml('This is __bold__ text')).toBe('<p>This is <b>bold</b> text</p>');
|
|
522
|
+
});
|
|
523
|
+
it('converts italic markdown', () => {
|
|
524
|
+
expect(markdownToTeamsHtml('This is *italic* text')).toBe('<p>This is <i>italic</i> text</p>');
|
|
525
|
+
});
|
|
526
|
+
it('converts strikethrough markdown', () => {
|
|
527
|
+
expect(markdownToTeamsHtml('This is ~~deleted~~ text')).toBe('<p>This is <s>deleted</s> text</p>');
|
|
528
|
+
});
|
|
529
|
+
it('converts inline code', () => {
|
|
530
|
+
expect(markdownToTeamsHtml('Use `console.log()` here')).toBe('<p>Use <code>console.log()</code> here</p>');
|
|
531
|
+
});
|
|
532
|
+
it('does not process markdown inside inline code', () => {
|
|
533
|
+
expect(markdownToTeamsHtml('Use `**not bold**` here')).toBe('<p>Use <code>**not bold**</code> here</p>');
|
|
534
|
+
});
|
|
535
|
+
it('escapes HTML inside inline code', () => {
|
|
536
|
+
expect(markdownToTeamsHtml('Use `<div>` tag')).toBe('<p>Use <code><div></code> tag</p>');
|
|
537
|
+
});
|
|
538
|
+
it('converts fenced code blocks', () => {
|
|
539
|
+
expect(markdownToTeamsHtml('```\nconst x = 1;\n```')).toBe('<pre><code>const x = 1;</code></pre>');
|
|
540
|
+
});
|
|
541
|
+
it('converts fenced code blocks with language', () => {
|
|
542
|
+
expect(markdownToTeamsHtml('```js\nconst x = 1;\n```')).toBe('<pre><code>const x = 1;</code></pre>');
|
|
543
|
+
});
|
|
544
|
+
it('escapes HTML inside code blocks', () => {
|
|
545
|
+
expect(markdownToTeamsHtml('```\n<div>test</div>\n```')).toBe('<pre><code><div>test</div></code></pre>');
|
|
546
|
+
});
|
|
547
|
+
it('handles text before and after code blocks', () => {
|
|
548
|
+
const input = 'Before\n\n```\ncode\n```\n\nAfter';
|
|
549
|
+
expect(markdownToTeamsHtml(input)).toBe('<p>Before</p><pre><code>code</code></pre><p>After</p>');
|
|
550
|
+
});
|
|
551
|
+
it('converts single newlines to br tags', () => {
|
|
552
|
+
expect(markdownToTeamsHtml('Line 1\nLine 2')).toBe('<p>Line 1<br>Line 2</p>');
|
|
553
|
+
});
|
|
554
|
+
it('converts double newlines to separate paragraphs', () => {
|
|
555
|
+
expect(markdownToTeamsHtml('Para 1\n\nPara 2')).toBe('<p>Para 1</p><p>Para 2</p>');
|
|
556
|
+
});
|
|
557
|
+
it('converts unordered lists', () => {
|
|
558
|
+
expect(markdownToTeamsHtml('- Item 1\n- Item 2\n- Item 3')).toBe('<ul><li>Item 1</li><li>Item 2</li><li>Item 3</li></ul>');
|
|
559
|
+
});
|
|
560
|
+
it('converts unordered lists with * marker', () => {
|
|
561
|
+
expect(markdownToTeamsHtml('* Item 1\n* Item 2')).toBe('<ul><li>Item 1</li><li>Item 2</li></ul>');
|
|
562
|
+
});
|
|
563
|
+
it('converts ordered lists', () => {
|
|
564
|
+
expect(markdownToTeamsHtml('1. First\n2. Second\n3. Third')).toBe('<ol><li>First</li><li>Second</li><li>Third</li></ol>');
|
|
565
|
+
});
|
|
566
|
+
it('handles inline formatting inside list items', () => {
|
|
567
|
+
expect(markdownToTeamsHtml('- **Bold** item\n- *Italic* item')).toBe('<ul><li><b>Bold</b> item</li><li><i>Italic</i> item</li></ul>');
|
|
568
|
+
});
|
|
569
|
+
it('handles combined formatting', () => {
|
|
570
|
+
const input = '**Bold** and *italic* and `code`';
|
|
571
|
+
expect(markdownToTeamsHtml(input)).toBe('<p><b>Bold</b> and <i>italic</i> and <code>code</code></p>');
|
|
572
|
+
});
|
|
573
|
+
it('handles complex multi-paragraph message', () => {
|
|
574
|
+
const input = 'Hello **team**!\n\nHere are the updates:\n\n- Item 1\n- Item 2\n\nThanks!';
|
|
575
|
+
expect(markdownToTeamsHtml(input)).toBe('<p>Hello <b>team</b>!</p><p>Here are the updates:</p><ul><li>Item 1</li><li>Item 2</li></ul><p>Thanks!</p>');
|
|
576
|
+
});
|
|
577
|
+
it('returns empty paragraph for empty string', () => {
|
|
578
|
+
expect(markdownToTeamsHtml('')).toBe('<p></p>');
|
|
579
|
+
});
|
|
580
|
+
it('handles whitespace-only input', () => {
|
|
581
|
+
expect(markdownToTeamsHtml(' ')).toBe('<p></p>');
|
|
582
|
+
});
|
|
583
|
+
});
|