msteams-mcp 0.2.1

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.

Potentially problematic release.


This version of msteams-mcp might be problematic. Click here for more details.

Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +261 -0
  3. package/dist/__fixtures__/api-responses.d.ts +254 -0
  4. package/dist/__fixtures__/api-responses.js +245 -0
  5. package/dist/api/calendar-api.d.ts +66 -0
  6. package/dist/api/calendar-api.js +179 -0
  7. package/dist/api/chatsvc-api.d.ts +352 -0
  8. package/dist/api/chatsvc-api.js +1100 -0
  9. package/dist/api/csa-api.d.ts +64 -0
  10. package/dist/api/csa-api.js +200 -0
  11. package/dist/api/index.d.ts +7 -0
  12. package/dist/api/index.js +7 -0
  13. package/dist/api/substrate-api.d.ts +50 -0
  14. package/dist/api/substrate-api.js +305 -0
  15. package/dist/auth/crypto.d.ts +32 -0
  16. package/dist/auth/crypto.js +66 -0
  17. package/dist/auth/index.d.ts +7 -0
  18. package/dist/auth/index.js +7 -0
  19. package/dist/auth/session-store.d.ts +87 -0
  20. package/dist/auth/session-store.js +230 -0
  21. package/dist/auth/token-extractor.d.ts +185 -0
  22. package/dist/auth/token-extractor.js +674 -0
  23. package/dist/auth/token-refresh.d.ts +25 -0
  24. package/dist/auth/token-refresh.js +85 -0
  25. package/dist/browser/auth.d.ts +53 -0
  26. package/dist/browser/auth.js +603 -0
  27. package/dist/browser/context.d.ts +40 -0
  28. package/dist/browser/context.js +122 -0
  29. package/dist/constants.d.ts +104 -0
  30. package/dist/constants.js +195 -0
  31. package/dist/index.d.ts +8 -0
  32. package/dist/index.js +12 -0
  33. package/dist/research/auth-research.d.ts +10 -0
  34. package/dist/research/auth-research.js +175 -0
  35. package/dist/research/explore.d.ts +11 -0
  36. package/dist/research/explore.js +270 -0
  37. package/dist/research/search-research.d.ts +17 -0
  38. package/dist/research/search-research.js +317 -0
  39. package/dist/server.d.ts +66 -0
  40. package/dist/server.js +295 -0
  41. package/dist/test/debug-search.d.ts +10 -0
  42. package/dist/test/debug-search.js +147 -0
  43. package/dist/test/mcp-harness.d.ts +17 -0
  44. package/dist/test/mcp-harness.js +474 -0
  45. package/dist/tools/auth-tools.d.ts +26 -0
  46. package/dist/tools/auth-tools.js +191 -0
  47. package/dist/tools/index.d.ts +56 -0
  48. package/dist/tools/index.js +34 -0
  49. package/dist/tools/meeting-tools.d.ts +33 -0
  50. package/dist/tools/meeting-tools.js +64 -0
  51. package/dist/tools/message-tools.d.ts +269 -0
  52. package/dist/tools/message-tools.js +856 -0
  53. package/dist/tools/people-tools.d.ts +46 -0
  54. package/dist/tools/people-tools.js +112 -0
  55. package/dist/tools/registry.d.ts +23 -0
  56. package/dist/tools/registry.js +63 -0
  57. package/dist/tools/search-tools.d.ts +91 -0
  58. package/dist/tools/search-tools.js +222 -0
  59. package/dist/types/errors.d.ts +58 -0
  60. package/dist/types/errors.js +132 -0
  61. package/dist/types/result.d.ts +43 -0
  62. package/dist/types/result.js +51 -0
  63. package/dist/types/server.d.ts +27 -0
  64. package/dist/types/server.js +7 -0
  65. package/dist/types/teams.d.ts +85 -0
  66. package/dist/types/teams.js +4 -0
  67. package/dist/utils/api-config.d.ts +103 -0
  68. package/dist/utils/api-config.js +158 -0
  69. package/dist/utils/auth-guards.d.ts +67 -0
  70. package/dist/utils/auth-guards.js +147 -0
  71. package/dist/utils/http.d.ts +29 -0
  72. package/dist/utils/http.js +112 -0
  73. package/dist/utils/parsers.d.ts +247 -0
  74. package/dist/utils/parsers.js +731 -0
  75. package/dist/utils/parsers.test.d.ts +7 -0
  76. package/dist/utils/parsers.test.js +511 -0
  77. package/package.json +62 -0
@@ -0,0 +1,731 @@
1
+ /**
2
+ * Pure parsing functions for Teams API responses.
3
+ *
4
+ * These functions transform raw API responses into our internal types.
5
+ * They are extracted here for testability - no side effects or external dependencies.
6
+ */
7
+ import { MIN_CONTENT_LENGTH } from '../constants.js';
8
+ /**
9
+ * Extracts links from HTML content before stripping.
10
+ * Returns an array of { url, text } objects.
11
+ */
12
+ export function extractLinks(html) {
13
+ const links = [];
14
+ const linkRegex = /<a\s+[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
15
+ let match;
16
+ while ((match = linkRegex.exec(html)) !== null) {
17
+ const url = match[1];
18
+ const text = stripHtml(match[2]); // Clean nested HTML in link text
19
+ if (url && !url.startsWith('javascript:')) {
20
+ links.push({ url, text: text || url });
21
+ }
22
+ }
23
+ return links;
24
+ }
25
+ /**
26
+ * Strips HTML tags from content for display.
27
+ */
28
+ export function stripHtml(html) {
29
+ return html
30
+ .replace(/<[^>]*>/g, ' ')
31
+ .replace(/&nbsp;/g, ' ')
32
+ .replace(/&amp;/g, '&')
33
+ .replace(/&lt;/g, '<')
34
+ .replace(/&gt;/g, '>')
35
+ .replace(/&quot;/g, '"')
36
+ .replace(/&#39;/g, "'")
37
+ .replace(/&apos;/g, "'")
38
+ .replace(/\s+/g, ' ')
39
+ .trim();
40
+ }
41
+ /**
42
+ * Determines the conversation type from a Teams conversation ID.
43
+ *
44
+ * Conversation ID formats:
45
+ * - Channels: 19:xxx@thread.tacv2
46
+ * - Meetings: 19:meeting_xxx@thread.v2
47
+ * - 1:1 chats: 19:guid_guid@unq.gbl.spaces
48
+ * - Group chats: 19:xxx@thread.v2 (non-meeting)
49
+ */
50
+ export function getConversationType(conversationId) {
51
+ if (conversationId.includes('@thread.tacv2')) {
52
+ return 'channel';
53
+ }
54
+ if (conversationId.includes('meeting_')) {
55
+ return 'meeting';
56
+ }
57
+ // 1:1 chats (@unq.gbl.spaces) and group chats (@thread.v2) both need chat context
58
+ return 'chat';
59
+ }
60
+ /** Default Teams base URL for message links. */
61
+ const DEFAULT_TEAMS_LINK_BASE = 'https://teams.microsoft.com';
62
+ /**
63
+ * Builds a deep link to open a message in Teams.
64
+ *
65
+ * Different conversation types require different URL formats:
66
+ * - Channels: /l/message/{channelId}/{msgId}?parentMessageId={parentId} (for thread replies)
67
+ * - Chats/Meetings: /l/message/{chatId}/{msgId}?context={"contextType":"chat"}
68
+ *
69
+ * @param conversationId - The conversation/thread ID (e.g., "19:xxx@thread.tacv2")
70
+ * @param messageTimestamp - The message timestamp in epoch milliseconds
71
+ * @param parentMessageId - For channel thread replies, the ID of the parent/root message
72
+ * @param teamsBaseUrl - Optional Teams base URL (for GCC/GCC-High support)
73
+ */
74
+ export function buildMessageLink(conversationId, messageTimestamp, parentMessageId, teamsBaseUrl = DEFAULT_TEAMS_LINK_BASE) {
75
+ const timestamp = typeof messageTimestamp === 'string' ? messageTimestamp : String(messageTimestamp);
76
+ const linkUrl = `${teamsBaseUrl}/l/message/${encodeURIComponent(conversationId)}/${timestamp}`;
77
+ const convType = getConversationType(conversationId);
78
+ // Chats and meetings require the context parameter
79
+ if (convType === 'chat' || convType === 'meeting') {
80
+ const context = encodeURIComponent('{"contextType":"chat"}');
81
+ return `${linkUrl}?context=${context}`;
82
+ }
83
+ // Channel messages - add parentMessageId for thread replies
84
+ if (convType === 'channel' && parentMessageId && parentMessageId !== timestamp) {
85
+ return `${linkUrl}?parentMessageId=${parentMessageId}`;
86
+ }
87
+ return linkUrl;
88
+ }
89
+ /**
90
+ * Extracts a timestamp-based message ID from various sources.
91
+ * Teams uses epoch milliseconds as message IDs in URLs.
92
+ *
93
+ * IMPORTANT: For channel threaded replies, the ;messageid= in ClientConversationId
94
+ * is the PARENT thread's ID, not this message's ID. We must prefer the actual
95
+ * message timestamp (DateTimeReceived/DateTimeSent) for accurate deep links.
96
+ */
97
+ export function extractMessageTimestamp(source, timestamp) {
98
+ // FIRST: Try to compute from the message's own timestamp
99
+ // This is the most reliable for channel threaded replies
100
+ if (timestamp) {
101
+ try {
102
+ const date = new Date(timestamp);
103
+ if (!isNaN(date.getTime())) {
104
+ return String(date.getTime());
105
+ }
106
+ }
107
+ catch {
108
+ // Ignore parsing errors
109
+ }
110
+ }
111
+ // SECOND: Try explicit MessageId fields
112
+ if (source) {
113
+ // Check for MessageId or Id in various formats
114
+ const messageId = source.MessageId ?? source.OriginalMessageId ?? source.ReferenceObjectId;
115
+ if (typeof messageId === 'string' && /^\d{13}$/.test(messageId)) {
116
+ return messageId;
117
+ }
118
+ // LAST RESORT: Check ClientConversationId for ;messageid=xxx suffix
119
+ // NOTE: For threaded replies, this is the PARENT message ID, so only use
120
+ // if we couldn't get the actual timestamp above
121
+ const clientConvId = source.ClientConversationId;
122
+ if (clientConvId && clientConvId.includes(';messageid=')) {
123
+ const match = clientConvId.match(/;messageid=(\d+)/);
124
+ if (match) {
125
+ return match[1];
126
+ }
127
+ }
128
+ }
129
+ return undefined;
130
+ }
131
+ /**
132
+ * Parses a person suggestion from the Substrate API response.
133
+ *
134
+ * The API can return IDs in various formats:
135
+ * - GUID with tenant: "ab76f827-...@tenant.onmicrosoft.com"
136
+ * - Base64-encoded GUID: "93qkaTtFGWpUHjyRafgdhg=="
137
+ */
138
+ export function parsePersonSuggestion(item) {
139
+ const rawId = item.Id;
140
+ if (!rawId)
141
+ return null;
142
+ // Extract the ID part (strip tenant suffix if present)
143
+ const idPart = rawId.includes('@') ? rawId.split('@')[0] : rawId;
144
+ // Convert to a proper GUID format
145
+ const objectId = extractObjectId(idPart);
146
+ if (!objectId) {
147
+ // If we can't parse the ID, skip this result
148
+ return null;
149
+ }
150
+ // Build MRI from the decoded GUID if not provided
151
+ const mri = item.MRI || `8:orgid:${objectId}`;
152
+ const displayName = item.DisplayName || '';
153
+ // EmailAddresses can be an array
154
+ const emailAddresses = item.EmailAddresses;
155
+ const email = emailAddresses?.[0];
156
+ return {
157
+ id: objectId,
158
+ mri: mri.includes('orgid:') && !mri.includes('-')
159
+ ? `8:orgid:${objectId}` // Rebuild MRI if it has base64
160
+ : mri,
161
+ displayName,
162
+ email,
163
+ givenName: item.GivenName,
164
+ surname: item.Surname,
165
+ jobTitle: item.JobTitle,
166
+ department: item.Department,
167
+ companyName: item.CompanyName,
168
+ };
169
+ }
170
+ /**
171
+ * Parses a v2 query result item into a search result.
172
+ */
173
+ export function parseV2Result(item) {
174
+ const content = item.HitHighlightedSummary ||
175
+ item.Summary ||
176
+ '';
177
+ if (content.length < MIN_CONTENT_LENGTH)
178
+ return null;
179
+ const id = item.Id ||
180
+ item.ReferenceId ||
181
+ `v2-${Date.now()}`;
182
+ // Extract links before stripping HTML
183
+ const links = extractLinks(content);
184
+ const cleanContent = stripHtml(content);
185
+ const source = item.Source;
186
+ // Extract conversationId from extension fields or source properties
187
+ // For channel threaded replies, we want the thread ID (ClientThreadId) not the channel ID
188
+ let conversationId;
189
+ if (source) {
190
+ // Check ClientThreadId first - this is the specific thread for channel replies
191
+ // Using this ensures the deep link goes to the correct thread context
192
+ const clientThreadId = source.ClientThreadId;
193
+ if (typeof clientThreadId === 'string' && clientThreadId.length > 0) {
194
+ conversationId = clientThreadId;
195
+ }
196
+ // Fallback to Extensions.SkypeGroupId (the channel ID)
197
+ if (!conversationId) {
198
+ const extensions = source.Extensions;
199
+ if (extensions) {
200
+ const extId = extensions.SkypeSpaces_ConversationPost_Extension_SkypeGroupId;
201
+ if (typeof extId === 'string' && extId.length > 0) {
202
+ conversationId = extId;
203
+ }
204
+ }
205
+ }
206
+ // Fallback to ClientConversationId (strip ;messageid= suffix if present)
207
+ if (!conversationId) {
208
+ const clientConvId = source.ClientConversationId;
209
+ if (typeof clientConvId === 'string' && clientConvId.length > 0) {
210
+ conversationId = clientConvId.split(';')[0];
211
+ }
212
+ }
213
+ }
214
+ // Note: The API returns DateTimeReceived, DateTimeSent, DateTimeCreated (not ReceivedTime/CreatedDateTime)
215
+ const timestamp = source?.DateTimeReceived ||
216
+ source?.DateTimeSent ||
217
+ source?.DateTimeCreated ||
218
+ source?.ReceivedTime || // Legacy fallback
219
+ source?.CreatedDateTime; // Legacy fallback
220
+ // Extract message timestamp - used for both deep links and thread replies
221
+ const messageTimestamp = extractMessageTimestamp(source, timestamp);
222
+ // Extract parent message ID from ClientConversationId for thread replies
223
+ // Format: "19:xxx@thread.tacv2;messageid=1769237777958"
224
+ // If the messageid differs from the message's own timestamp, it's a thread reply
225
+ let parentMessageId;
226
+ if (source) {
227
+ const clientConvId = source.ClientConversationId;
228
+ if (clientConvId?.includes(';messageid=')) {
229
+ const match = clientConvId.match(/;messageid=(\d+)/);
230
+ if (match) {
231
+ parentMessageId = match[1];
232
+ }
233
+ }
234
+ }
235
+ // Build message link if we have the required data
236
+ let messageLink;
237
+ if (conversationId && messageTimestamp) {
238
+ messageLink = buildMessageLink(conversationId, messageTimestamp, parentMessageId);
239
+ }
240
+ return {
241
+ id,
242
+ type: 'message',
243
+ content: cleanContent,
244
+ sender: source?.From || source?.Sender,
245
+ timestamp,
246
+ channelName: source?.ChannelName || source?.Topic,
247
+ teamName: source?.TeamName || source?.GroupName,
248
+ conversationId,
249
+ // Use the timestamp as messageId (required for thread replies)
250
+ // Fallback to ReferenceId if timestamp extraction fails
251
+ messageId: messageTimestamp || item.ReferenceId,
252
+ messageLink,
253
+ links: links.length > 0 ? links : undefined,
254
+ };
255
+ }
256
+ /**
257
+ * Parses user profile from a JWT payload.
258
+ *
259
+ * @param payload - Decoded JWT payload object
260
+ * @returns User profile or null if required fields are missing
261
+ */
262
+ export function parseJwtProfile(payload) {
263
+ const oid = payload.oid;
264
+ const name = payload.name;
265
+ if (!oid || !name) {
266
+ return null;
267
+ }
268
+ const profile = {
269
+ id: oid,
270
+ mri: `8:orgid:${oid}`,
271
+ email: (payload.upn || payload.preferred_username || payload.email || ''),
272
+ displayName: name,
273
+ tenantId: payload.tid,
274
+ };
275
+ // Try to extract given name and surname
276
+ if (payload.given_name) {
277
+ profile.givenName = payload.given_name;
278
+ }
279
+ if (payload.family_name) {
280
+ profile.surname = payload.family_name;
281
+ }
282
+ // If no given/family name, try to parse from displayName
283
+ if (!profile.givenName && profile.displayName.includes(',')) {
284
+ // Format: "Surname, GivenName"
285
+ const parts = profile.displayName.split(',').map(s => s.trim());
286
+ if (parts.length === 2) {
287
+ profile.surname = parts[0];
288
+ profile.givenName = parts[1];
289
+ }
290
+ }
291
+ else if (!profile.givenName && profile.displayName.includes(' ')) {
292
+ // Format: "GivenName Surname"
293
+ const parts = profile.displayName.split(' ');
294
+ profile.givenName = parts[0];
295
+ profile.surname = parts.slice(1).join(' ');
296
+ }
297
+ return profile;
298
+ }
299
+ /**
300
+ * Calculates token expiry status from an expiry timestamp.
301
+ *
302
+ * @param expiryMs - Token expiry time in milliseconds since epoch
303
+ * @param nowMs - Current time in milliseconds (for testing)
304
+ * @returns Token status including whether it's valid and time remaining
305
+ */
306
+ export function calculateTokenStatus(expiryMs, nowMs = Date.now()) {
307
+ const expiryDate = new Date(expiryMs);
308
+ return {
309
+ isValid: expiryMs > nowMs,
310
+ expiresAt: expiryDate.toISOString(),
311
+ minutesRemaining: Math.max(0, Math.round((expiryMs - nowMs) / 1000 / 60)),
312
+ };
313
+ }
314
+ /**
315
+ * Parses the pagination result from a search API response.
316
+ *
317
+ * @param entitySets - Raw EntitySets array from API response
318
+ * @returns Parsed results and total count if available
319
+ */
320
+ export function parseSearchResults(entitySets) {
321
+ const results = [];
322
+ let total;
323
+ if (!Array.isArray(entitySets)) {
324
+ return { results, total };
325
+ }
326
+ for (const entitySet of entitySets) {
327
+ const es = entitySet;
328
+ const resultSets = es.ResultSets;
329
+ if (Array.isArray(resultSets)) {
330
+ for (const resultSet of resultSets) {
331
+ const rs = resultSet;
332
+ // Try to get total
333
+ const rsTotal = rs.Total ?? rs.TotalCount ?? rs.TotalEstimate;
334
+ if (typeof rsTotal === 'number') {
335
+ total = rsTotal;
336
+ }
337
+ const items = rs.Results;
338
+ if (Array.isArray(items)) {
339
+ for (const item of items) {
340
+ const parsed = parseV2Result(item);
341
+ if (parsed)
342
+ results.push(parsed);
343
+ }
344
+ }
345
+ }
346
+ }
347
+ }
348
+ return { results, total };
349
+ }
350
+ /**
351
+ * Parses people search results from the Groups/Suggestions structure.
352
+ *
353
+ * @param groups - Raw Groups array from suggestions API response
354
+ * @returns Array of parsed person results
355
+ */
356
+ export function parsePeopleResults(groups) {
357
+ const results = [];
358
+ if (!Array.isArray(groups)) {
359
+ return results;
360
+ }
361
+ for (const group of groups) {
362
+ const g = group;
363
+ const suggestions = g.Suggestions;
364
+ if (Array.isArray(suggestions)) {
365
+ for (const suggestion of suggestions) {
366
+ const parsed = parsePersonSuggestion(suggestion);
367
+ if (parsed)
368
+ results.push(parsed);
369
+ }
370
+ }
371
+ }
372
+ return results;
373
+ }
374
+ /**
375
+ * Parses a single channel suggestion from the API response.
376
+ *
377
+ * @param suggestion - Raw suggestion object from API
378
+ * @returns Parsed channel result or null if required fields are missing
379
+ */
380
+ export function parseChannelSuggestion(suggestion) {
381
+ const name = suggestion.Name;
382
+ const threadId = suggestion.ThreadId;
383
+ const teamName = suggestion.TeamName;
384
+ const groupId = suggestion.GroupId;
385
+ // All required fields must be present
386
+ if (!name || !threadId || !teamName || !groupId) {
387
+ return null;
388
+ }
389
+ return {
390
+ channelId: threadId,
391
+ channelName: name,
392
+ teamName,
393
+ teamId: groupId,
394
+ channelType: suggestion.ChannelType || 'Standard',
395
+ description: suggestion.Description,
396
+ };
397
+ }
398
+ /**
399
+ * Parses channel search results from the Groups/Suggestions structure.
400
+ *
401
+ * @param groups - Raw Groups array from suggestions API response
402
+ * @returns Array of parsed channel results
403
+ */
404
+ export function parseChannelResults(groups) {
405
+ const results = [];
406
+ if (!Array.isArray(groups)) {
407
+ return results;
408
+ }
409
+ for (const group of groups) {
410
+ const g = group;
411
+ const suggestions = g.Suggestions;
412
+ if (Array.isArray(suggestions)) {
413
+ for (const suggestion of suggestions) {
414
+ const s = suggestion;
415
+ // Only parse ChannelSuggestion entities
416
+ if (s.EntityType === 'ChannelSuggestion') {
417
+ const parsed = parseChannelSuggestion(s);
418
+ if (parsed)
419
+ results.push(parsed);
420
+ }
421
+ }
422
+ }
423
+ }
424
+ return results;
425
+ }
426
+ /**
427
+ * Parses the Teams List API response to extract all teams and channels.
428
+ *
429
+ * @param data - Raw response data from /api/csa/{region}/api/v3/teams/users/me
430
+ * @returns Array of teams with their channels
431
+ */
432
+ export function parseTeamsList(data) {
433
+ const results = [];
434
+ if (!data)
435
+ return results;
436
+ const teams = data.teams;
437
+ if (!Array.isArray(teams))
438
+ return results;
439
+ for (const team of teams) {
440
+ const t = team;
441
+ // Team's id IS the thread ID (format: 19:xxx@thread.tacv2)
442
+ const threadId = t.id;
443
+ const displayName = t.displayName;
444
+ if (!threadId || !displayName)
445
+ continue;
446
+ const channels = [];
447
+ const channelList = t.channels;
448
+ if (Array.isArray(channelList)) {
449
+ for (const channel of channelList) {
450
+ const c = channel;
451
+ const channelId = c.id;
452
+ const channelName = c.displayName;
453
+ if (!channelId || !channelName)
454
+ continue;
455
+ // Channel has groupId directly, and channelType as a number
456
+ const groupId = c.groupId || '';
457
+ // Map numeric channelType to string (0=Standard, 1=Private, 2=Shared)
458
+ const channelTypeNum = c.channelType;
459
+ const channelType = channelTypeNum === 1 ? 'Private'
460
+ : channelTypeNum === 2 ? 'Shared'
461
+ : 'Standard';
462
+ channels.push({
463
+ channelId,
464
+ channelName,
465
+ teamName: displayName,
466
+ teamId: groupId,
467
+ channelType,
468
+ description: c.description,
469
+ isMember: true, // User is always a member for channels returned by this API
470
+ });
471
+ }
472
+ }
473
+ results.push({
474
+ teamId: threadId, // Use thread ID as team identifier
475
+ teamName: displayName,
476
+ threadId,
477
+ description: t.description,
478
+ channels,
479
+ });
480
+ }
481
+ return results;
482
+ }
483
+ /**
484
+ * Filters channels from the Teams List by name.
485
+ *
486
+ * @param teams - Array of teams with channels from parseTeamsList
487
+ * @param query - Search query (case-insensitive partial match)
488
+ * @returns Matching channels flattened into a single array
489
+ */
490
+ export function filterChannelsByName(teams, query) {
491
+ const lowerQuery = query.toLowerCase();
492
+ const results = [];
493
+ for (const team of teams) {
494
+ for (const channel of team.channels) {
495
+ if (channel.channelName.toLowerCase().includes(lowerQuery)) {
496
+ results.push(channel);
497
+ }
498
+ }
499
+ }
500
+ return results;
501
+ }
502
+ /**
503
+ * Decodes a base64-encoded GUID to its standard string representation.
504
+ *
505
+ * Microsoft encodes GUIDs as 16 bytes with little-endian ordering for the
506
+ * first three groups (Data1, Data2, Data3).
507
+ *
508
+ * @param base64 - Base64-encoded GUID (typically 24 chars with == padding)
509
+ * @returns The GUID string in standard format, or null if invalid
510
+ */
511
+ export function decodeBase64Guid(base64) {
512
+ try {
513
+ // Decode base64 to bytes
514
+ const bytes = Buffer.from(base64, 'base64');
515
+ // GUID is exactly 16 bytes
516
+ if (bytes.length !== 16) {
517
+ return null;
518
+ }
519
+ // Convert to hex
520
+ const hex = bytes.toString('hex');
521
+ // Format as GUID with little-endian byte ordering for first 3 groups
522
+ // Data1 (4 bytes), Data2 (2 bytes), Data3 (2 bytes) are little-endian
523
+ // Data4 (8 bytes) is big-endian
524
+ const guid = [
525
+ hex.slice(6, 8) + hex.slice(4, 6) + hex.slice(2, 4) + hex.slice(0, 2), // Data1
526
+ hex.slice(10, 12) + hex.slice(8, 10), // Data2
527
+ hex.slice(14, 16) + hex.slice(12, 14), // Data3
528
+ hex.slice(16, 20), // Data4a
529
+ hex.slice(20, 32), // Data4b
530
+ ].join('-');
531
+ return guid.toLowerCase();
532
+ }
533
+ catch {
534
+ return null;
535
+ }
536
+ }
537
+ /**
538
+ * Checks if a string appears to be a base64-encoded GUID.
539
+ * Base64-encoded 16 bytes = 24 characters (22 chars + 2 padding or no padding).
540
+ */
541
+ function isLikelyBase64Guid(str) {
542
+ // Check length (22-24 chars for 16 bytes)
543
+ if (str.length < 22 || str.length > 24) {
544
+ return false;
545
+ }
546
+ // Must contain only base64 characters
547
+ if (!/^[A-Za-z0-9+/]+=*$/.test(str)) {
548
+ return false;
549
+ }
550
+ // Typically ends with == for 16 bytes
551
+ return true;
552
+ }
553
+ /**
554
+ * Extracts the Azure AD object ID (GUID) from various formats.
555
+ *
556
+ * Handles:
557
+ * - MRI format: "8:orgid:ab76f827-27e2-4c67-a765-f1a53145fa24"
558
+ * - MRI with base64: "8:orgid:93qkaTtFGWpUHjyRafgdhg=="
559
+ * - Skype ID format: "orgid:ab76f827-27e2-4c67-a765-f1a53145fa24"
560
+ * - ID with tenant: "ab76f827-27e2-4c67-a765-f1a53145fa24@56b731a8-..."
561
+ * - Raw GUID: "ab76f827-27e2-4c67-a765-f1a53145fa24"
562
+ * - Base64-encoded GUID: "93qkaTtFGWpUHjyRafgdhg=="
563
+ *
564
+ * @param identifier - User identifier in any supported format
565
+ * @returns The extracted GUID or null if invalid format
566
+ */
567
+ export function extractObjectId(identifier) {
568
+ if (!identifier)
569
+ return null;
570
+ // Pattern for a GUID (with or without hyphens)
571
+ const guidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
572
+ // Handle MRI format: "8:orgid:GUID" or "8:orgid:base64"
573
+ if (identifier.startsWith('8:orgid:')) {
574
+ const idPart = identifier.substring(8);
575
+ if (guidPattern.test(idPart)) {
576
+ return idPart.toLowerCase();
577
+ }
578
+ // Try base64 decoding
579
+ if (isLikelyBase64Guid(idPart)) {
580
+ return decodeBase64Guid(idPart);
581
+ }
582
+ return null;
583
+ }
584
+ // Handle Skype ID format: "orgid:GUID" (from skype token's skypeid field)
585
+ if (identifier.startsWith('orgid:')) {
586
+ const idPart = identifier.substring(6);
587
+ if (guidPattern.test(idPart)) {
588
+ return idPart.toLowerCase();
589
+ }
590
+ // Try base64 decoding
591
+ if (isLikelyBase64Guid(idPart)) {
592
+ return decodeBase64Guid(idPart);
593
+ }
594
+ return null;
595
+ }
596
+ // Handle ID with tenant: "GUID@tenantId"
597
+ if (identifier.includes('@')) {
598
+ const idPart = identifier.split('@')[0];
599
+ if (guidPattern.test(idPart)) {
600
+ return idPart.toLowerCase();
601
+ }
602
+ // Try base64 decoding
603
+ if (isLikelyBase64Guid(idPart)) {
604
+ return decodeBase64Guid(idPart);
605
+ }
606
+ return null;
607
+ }
608
+ // Handle raw GUID
609
+ if (guidPattern.test(identifier)) {
610
+ return identifier.toLowerCase();
611
+ }
612
+ // Handle base64-encoded GUID
613
+ if (isLikelyBase64Guid(identifier)) {
614
+ return decodeBase64Guid(identifier);
615
+ }
616
+ return null;
617
+ }
618
+ /**
619
+ * Builds a 1:1 conversation ID from two user object IDs.
620
+ *
621
+ * The conversation ID format for 1:1 chats in Teams is:
622
+ * `19:{userId1}_{userId2}@unq.gbl.spaces`
623
+ *
624
+ * The user IDs are sorted lexicographically to ensure consistency -
625
+ * both participants will generate the same conversation ID.
626
+ *
627
+ * @param userId1 - First user's object ID (GUID, MRI, or ID with tenant)
628
+ * @param userId2 - Second user's object ID (GUID, MRI, or ID with tenant)
629
+ * @returns The constructed conversation ID, or null if either ID is invalid
630
+ */
631
+ export function buildOneOnOneConversationId(userId1, userId2) {
632
+ const id1 = extractObjectId(userId1);
633
+ const id2 = extractObjectId(userId2);
634
+ if (!id1 || !id2) {
635
+ return null;
636
+ }
637
+ // Sort lexicographically for consistent ID regardless of who initiates
638
+ const sorted = [id1, id2].sort();
639
+ return `19:${sorted[0]}_${sorted[1]}@unq.gbl.spaces`;
640
+ }
641
+ /**
642
+ * Safely extracts a timestamp from an activity feed message.
643
+ *
644
+ * Tries multiple sources in order of preference:
645
+ * 1. originalarrivaltime - Primary timestamp field
646
+ * 2. composetime - When message was composed
647
+ * 3. id as numeric timestamp - Fallback if ID is a Unix timestamp
648
+ *
649
+ * Returns null if no valid timestamp can be determined, preventing
650
+ * RangeError from Date operations on invalid values.
651
+ *
652
+ * @param msg - Raw message object from activity feed API
653
+ * @returns ISO timestamp string, or null if no valid timestamp found
654
+ */
655
+ export function extractActivityTimestamp(msg) {
656
+ const arrivalTime = msg.originalarrivaltime;
657
+ const composeTime = msg.composetime;
658
+ if (arrivalTime)
659
+ return arrivalTime;
660
+ if (composeTime)
661
+ return composeTime;
662
+ // Try parsing the message ID as a numeric timestamp
663
+ const id = msg.id;
664
+ if (id) {
665
+ const numericId = parseInt(id, 10);
666
+ if (!isNaN(numericId) && numericId > 0) {
667
+ return new Date(numericId).toISOString();
668
+ }
669
+ }
670
+ return null;
671
+ }
672
+ /**
673
+ * Parses a raw message from a virtual conversation (48:saved, 48:threads, etc).
674
+ *
675
+ * Virtual conversations contain references to messages in other conversations.
676
+ * The clumpId field contains the source conversation ID, and secondaryReferenceId
677
+ * contains a composite key with the source message/post ID.
678
+ *
679
+ * @param msg - Raw message object from virtual conversation API
680
+ * @param referencePattern - Regex to extract source ID from secondaryReferenceId
681
+ * @returns Parsed virtual conversation item, or null if message should be skipped
682
+ */
683
+ export function parseVirtualConversationMessage(msg, referencePattern) {
684
+ // Skip non-message types
685
+ const messageType = msg.messagetype || msg.type;
686
+ if (!messageType || messageType.startsWith('Control/')) {
687
+ return null;
688
+ }
689
+ const id = msg.id;
690
+ if (!id)
691
+ return null;
692
+ const content = msg.content || '';
693
+ const contentType = messageType || 'Text';
694
+ const fromMri = msg.from || '';
695
+ const displayName = msg.imdisplayname || msg.displayName;
696
+ // Safe timestamp extraction - use extractActivityTimestamp pattern
697
+ const timestamp = extractActivityTimestamp(msg);
698
+ if (!timestamp)
699
+ return null;
700
+ // clumpId contains the original conversation where the message lives
701
+ const sourceConversationId = msg.clumpId || '';
702
+ // Extract source reference ID from secondaryReferenceId if available
703
+ let sourceReferenceId;
704
+ const secondaryRef = msg.secondaryReferenceId;
705
+ if (secondaryRef) {
706
+ const match = secondaryRef.match(referencePattern);
707
+ if (match) {
708
+ sourceReferenceId = match[1];
709
+ }
710
+ }
711
+ // Build message link to original message
712
+ const messageLink = sourceConversationId && sourceReferenceId
713
+ ? buildMessageLink(sourceConversationId, sourceReferenceId)
714
+ : undefined;
715
+ // Extract links before stripping HTML
716
+ const links = extractLinks(content);
717
+ return {
718
+ id,
719
+ content: stripHtml(content),
720
+ contentType,
721
+ sender: {
722
+ mri: fromMri,
723
+ displayName,
724
+ },
725
+ timestamp,
726
+ sourceConversationId,
727
+ sourceReferenceId,
728
+ messageLink,
729
+ links: links.length > 0 ? links : undefined,
730
+ };
731
+ }