msteams-mcp 0.2.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.
Files changed (80) hide show
  1. package/README.md +229 -0
  2. package/dist/__fixtures__/api-responses.d.ts +228 -0
  3. package/dist/__fixtures__/api-responses.js +217 -0
  4. package/dist/api/chatsvc-api.d.ts +171 -0
  5. package/dist/api/chatsvc-api.js +459 -0
  6. package/dist/api/csa-api.d.ts +44 -0
  7. package/dist/api/csa-api.js +148 -0
  8. package/dist/api/index.d.ts +6 -0
  9. package/dist/api/index.js +6 -0
  10. package/dist/api/substrate-api.d.ts +50 -0
  11. package/dist/api/substrate-api.js +305 -0
  12. package/dist/auth/crypto.d.ts +32 -0
  13. package/dist/auth/crypto.js +66 -0
  14. package/dist/auth/index.d.ts +6 -0
  15. package/dist/auth/index.js +6 -0
  16. package/dist/auth/session-store.d.ts +82 -0
  17. package/dist/auth/session-store.js +136 -0
  18. package/dist/auth/token-extractor.d.ts +69 -0
  19. package/dist/auth/token-extractor.js +330 -0
  20. package/dist/browser/auth.d.ts +43 -0
  21. package/dist/browser/auth.js +232 -0
  22. package/dist/browser/context.d.ts +40 -0
  23. package/dist/browser/context.js +121 -0
  24. package/dist/browser/session.d.ts +34 -0
  25. package/dist/browser/session.js +92 -0
  26. package/dist/constants.d.ts +54 -0
  27. package/dist/constants.js +72 -0
  28. package/dist/index.d.ts +8 -0
  29. package/dist/index.js +12 -0
  30. package/dist/research/explore.d.ts +11 -0
  31. package/dist/research/explore.js +267 -0
  32. package/dist/research/search-research.d.ts +17 -0
  33. package/dist/research/search-research.js +317 -0
  34. package/dist/server.d.ts +64 -0
  35. package/dist/server.js +291 -0
  36. package/dist/teams/api-interceptor.d.ts +54 -0
  37. package/dist/teams/api-interceptor.js +391 -0
  38. package/dist/teams/direct-api.d.ts +321 -0
  39. package/dist/teams/direct-api.js +1305 -0
  40. package/dist/teams/messages.d.ts +14 -0
  41. package/dist/teams/messages.js +142 -0
  42. package/dist/teams/search.d.ts +40 -0
  43. package/dist/teams/search.js +458 -0
  44. package/dist/test/cli.d.ts +12 -0
  45. package/dist/test/cli.js +328 -0
  46. package/dist/test/debug-search.d.ts +10 -0
  47. package/dist/test/debug-search.js +147 -0
  48. package/dist/test/manual-test.d.ts +11 -0
  49. package/dist/test/manual-test.js +160 -0
  50. package/dist/test/mcp-harness.d.ts +17 -0
  51. package/dist/test/mcp-harness.js +427 -0
  52. package/dist/tools/auth-tools.d.ts +26 -0
  53. package/dist/tools/auth-tools.js +127 -0
  54. package/dist/tools/index.d.ts +45 -0
  55. package/dist/tools/index.js +12 -0
  56. package/dist/tools/message-tools.d.ts +139 -0
  57. package/dist/tools/message-tools.js +433 -0
  58. package/dist/tools/people-tools.d.ts +46 -0
  59. package/dist/tools/people-tools.js +123 -0
  60. package/dist/tools/registry.d.ts +23 -0
  61. package/dist/tools/registry.js +61 -0
  62. package/dist/tools/search-tools.d.ts +79 -0
  63. package/dist/tools/search-tools.js +168 -0
  64. package/dist/types/errors.d.ts +58 -0
  65. package/dist/types/errors.js +132 -0
  66. package/dist/types/result.d.ts +43 -0
  67. package/dist/types/result.js +51 -0
  68. package/dist/types/teams.d.ts +79 -0
  69. package/dist/types/teams.js +5 -0
  70. package/dist/utils/api-config.d.ts +66 -0
  71. package/dist/utils/api-config.js +113 -0
  72. package/dist/utils/auth-guards.d.ts +29 -0
  73. package/dist/utils/auth-guards.js +54 -0
  74. package/dist/utils/http.d.ts +29 -0
  75. package/dist/utils/http.js +111 -0
  76. package/dist/utils/parsers.d.ts +187 -0
  77. package/dist/utils/parsers.js +574 -0
  78. package/dist/utils/parsers.test.d.ts +7 -0
  79. package/dist/utils/parsers.test.js +360 -0
  80. package/package.json +58 -0
@@ -0,0 +1,574 @@
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
+ * Strips HTML tags from content for display.
10
+ */
11
+ export function stripHtml(html) {
12
+ return html
13
+ .replace(/<[^>]*>/g, ' ')
14
+ .replace(/&nbsp;/g, ' ')
15
+ .replace(/&amp;/g, '&')
16
+ .replace(/&lt;/g, '<')
17
+ .replace(/&gt;/g, '>')
18
+ .replace(/&quot;/g, '"')
19
+ .replace(/&#39;/g, "'")
20
+ .replace(/&apos;/g, "'")
21
+ .replace(/\s+/g, ' ')
22
+ .trim();
23
+ }
24
+ /**
25
+ * Builds a deep link to open a message in Teams.
26
+ *
27
+ * Format: https://teams.microsoft.com/l/message/{conversationId}/{messageTimestamp}
28
+ *
29
+ * @param conversationId - The conversation/thread ID (e.g., "19:xxx@thread.tacv2")
30
+ * @param messageTimestamp - The message timestamp in epoch milliseconds
31
+ */
32
+ export function buildMessageLink(conversationId, messageTimestamp) {
33
+ const timestamp = typeof messageTimestamp === 'string' ? messageTimestamp : String(messageTimestamp);
34
+ return `https://teams.microsoft.com/l/message/${encodeURIComponent(conversationId)}/${timestamp}`;
35
+ }
36
+ /**
37
+ * Extracts a timestamp-based message ID from various sources.
38
+ * Teams uses epoch milliseconds as message IDs in URLs.
39
+ *
40
+ * IMPORTANT: For channel threaded replies, the ;messageid= in ClientConversationId
41
+ * is the PARENT thread's ID, not this message's ID. We must prefer the actual
42
+ * message timestamp (DateTimeReceived/DateTimeSent) for accurate deep links.
43
+ */
44
+ export function extractMessageTimestamp(source, timestamp) {
45
+ // FIRST: Try to compute from the message's own timestamp
46
+ // This is the most reliable for channel threaded replies
47
+ if (timestamp) {
48
+ try {
49
+ const date = new Date(timestamp);
50
+ if (!isNaN(date.getTime())) {
51
+ return String(date.getTime());
52
+ }
53
+ }
54
+ catch {
55
+ // Ignore parsing errors
56
+ }
57
+ }
58
+ // SECOND: Try explicit MessageId fields
59
+ if (source) {
60
+ // Check for MessageId or Id in various formats
61
+ const messageId = source.MessageId ?? source.OriginalMessageId ?? source.ReferenceObjectId;
62
+ if (typeof messageId === 'string' && /^\d{13}$/.test(messageId)) {
63
+ return messageId;
64
+ }
65
+ // LAST RESORT: Check ClientConversationId for ;messageid=xxx suffix
66
+ // NOTE: For threaded replies, this is the PARENT message ID, so only use
67
+ // if we couldn't get the actual timestamp above
68
+ const clientConvId = source.ClientConversationId;
69
+ if (clientConvId && clientConvId.includes(';messageid=')) {
70
+ const match = clientConvId.match(/;messageid=(\d+)/);
71
+ if (match) {
72
+ return match[1];
73
+ }
74
+ }
75
+ }
76
+ return undefined;
77
+ }
78
+ /**
79
+ * Parses a person suggestion from the Substrate API response.
80
+ *
81
+ * The API can return IDs in various formats:
82
+ * - GUID with tenant: "ab76f827-...@tenant.onmicrosoft.com"
83
+ * - Base64-encoded GUID: "93qkaTtFGWpUHjyRafgdhg=="
84
+ */
85
+ export function parsePersonSuggestion(item) {
86
+ const rawId = item.Id;
87
+ if (!rawId)
88
+ return null;
89
+ // Extract the ID part (strip tenant suffix if present)
90
+ const idPart = rawId.includes('@') ? rawId.split('@')[0] : rawId;
91
+ // Convert to a proper GUID format
92
+ const objectId = extractObjectId(idPart);
93
+ if (!objectId) {
94
+ // If we can't parse the ID, skip this result
95
+ return null;
96
+ }
97
+ // Build MRI from the decoded GUID if not provided
98
+ const mri = item.MRI || `8:orgid:${objectId}`;
99
+ const displayName = item.DisplayName || '';
100
+ // EmailAddresses can be an array
101
+ const emailAddresses = item.EmailAddresses;
102
+ const email = emailAddresses?.[0];
103
+ return {
104
+ id: objectId,
105
+ mri: mri.includes('orgid:') && !mri.includes('-')
106
+ ? `8:orgid:${objectId}` // Rebuild MRI if it has base64
107
+ : mri,
108
+ displayName,
109
+ email,
110
+ givenName: item.GivenName,
111
+ surname: item.Surname,
112
+ jobTitle: item.JobTitle,
113
+ department: item.Department,
114
+ companyName: item.CompanyName,
115
+ };
116
+ }
117
+ /**
118
+ * Parses a v2 query result item into a search result.
119
+ */
120
+ export function parseV2Result(item) {
121
+ const content = item.HitHighlightedSummary ||
122
+ item.Summary ||
123
+ '';
124
+ if (content.length < MIN_CONTENT_LENGTH)
125
+ return null;
126
+ const id = item.Id ||
127
+ item.ReferenceId ||
128
+ `v2-${Date.now()}`;
129
+ // Strip HTML from content
130
+ const cleanContent = stripHtml(content);
131
+ const source = item.Source;
132
+ // Extract conversationId from extension fields or source properties
133
+ // For channel threaded replies, we want the thread ID (ClientThreadId) not the channel ID
134
+ let conversationId;
135
+ if (source) {
136
+ // Check ClientThreadId first - this is the specific thread for channel replies
137
+ // Using this ensures the deep link goes to the correct thread context
138
+ const clientThreadId = source.ClientThreadId;
139
+ if (typeof clientThreadId === 'string' && clientThreadId.length > 0) {
140
+ conversationId = clientThreadId;
141
+ }
142
+ // Fallback to Extensions.SkypeGroupId (the channel ID)
143
+ if (!conversationId) {
144
+ const extensions = source.Extensions;
145
+ if (extensions) {
146
+ const extId = extensions.SkypeSpaces_ConversationPost_Extension_SkypeGroupId;
147
+ if (typeof extId === 'string' && extId.length > 0) {
148
+ conversationId = extId;
149
+ }
150
+ }
151
+ }
152
+ // Fallback to ClientConversationId (strip ;messageid= suffix if present)
153
+ if (!conversationId) {
154
+ const clientConvId = source.ClientConversationId;
155
+ if (typeof clientConvId === 'string' && clientConvId.length > 0) {
156
+ conversationId = clientConvId.split(';')[0];
157
+ }
158
+ }
159
+ }
160
+ // Note: The API returns DateTimeReceived, DateTimeSent, DateTimeCreated (not ReceivedTime/CreatedDateTime)
161
+ const timestamp = source?.DateTimeReceived ||
162
+ source?.DateTimeSent ||
163
+ source?.DateTimeCreated ||
164
+ source?.ReceivedTime || // Legacy fallback
165
+ source?.CreatedDateTime; // Legacy fallback
166
+ // Extract message timestamp - used for both deep links and thread replies
167
+ const messageTimestamp = extractMessageTimestamp(source, timestamp);
168
+ // Build message link if we have the required data
169
+ let messageLink;
170
+ if (conversationId && messageTimestamp) {
171
+ messageLink = buildMessageLink(conversationId, messageTimestamp);
172
+ }
173
+ return {
174
+ id,
175
+ type: 'message',
176
+ content: cleanContent,
177
+ sender: source?.From || source?.Sender,
178
+ timestamp,
179
+ channelName: source?.ChannelName || source?.Topic,
180
+ teamName: source?.TeamName || source?.GroupName,
181
+ conversationId,
182
+ // Use the timestamp as messageId (required for thread replies)
183
+ // Fallback to ReferenceId if timestamp extraction fails
184
+ messageId: messageTimestamp || item.ReferenceId,
185
+ messageLink,
186
+ };
187
+ }
188
+ /**
189
+ * Parses user profile from a JWT payload.
190
+ *
191
+ * @param payload - Decoded JWT payload object
192
+ * @returns User profile or null if required fields are missing
193
+ */
194
+ export function parseJwtProfile(payload) {
195
+ const oid = payload.oid;
196
+ const name = payload.name;
197
+ if (!oid || !name) {
198
+ return null;
199
+ }
200
+ const profile = {
201
+ id: oid,
202
+ mri: `8:orgid:${oid}`,
203
+ email: (payload.upn || payload.preferred_username || payload.email || ''),
204
+ displayName: name,
205
+ tenantId: payload.tid,
206
+ };
207
+ // Try to extract given name and surname
208
+ if (payload.given_name) {
209
+ profile.givenName = payload.given_name;
210
+ }
211
+ if (payload.family_name) {
212
+ profile.surname = payload.family_name;
213
+ }
214
+ // If no given/family name, try to parse from displayName
215
+ if (!profile.givenName && profile.displayName.includes(',')) {
216
+ // Format: "Surname, GivenName"
217
+ const parts = profile.displayName.split(',').map(s => s.trim());
218
+ if (parts.length === 2) {
219
+ profile.surname = parts[0];
220
+ profile.givenName = parts[1];
221
+ }
222
+ }
223
+ else if (!profile.givenName && profile.displayName.includes(' ')) {
224
+ // Format: "GivenName Surname"
225
+ const parts = profile.displayName.split(' ');
226
+ profile.givenName = parts[0];
227
+ profile.surname = parts.slice(1).join(' ');
228
+ }
229
+ return profile;
230
+ }
231
+ /**
232
+ * Calculates token expiry status from an expiry timestamp.
233
+ *
234
+ * @param expiryMs - Token expiry time in milliseconds since epoch
235
+ * @param nowMs - Current time in milliseconds (for testing)
236
+ * @returns Token status including whether it's valid and time remaining
237
+ */
238
+ export function calculateTokenStatus(expiryMs, nowMs = Date.now()) {
239
+ const expiryDate = new Date(expiryMs);
240
+ return {
241
+ isValid: expiryMs > nowMs,
242
+ expiresAt: expiryDate.toISOString(),
243
+ minutesRemaining: Math.max(0, Math.round((expiryMs - nowMs) / 1000 / 60)),
244
+ };
245
+ }
246
+ /**
247
+ * Parses the pagination result from a search API response.
248
+ *
249
+ * @param entitySets - Raw EntitySets array from API response
250
+ * @param from - Starting offset used in request
251
+ * @param size - Page size used in request
252
+ * @returns Parsed results and pagination metadata
253
+ */
254
+ export function parseSearchResults(entitySets, from, size) {
255
+ const results = [];
256
+ let total;
257
+ if (!Array.isArray(entitySets)) {
258
+ return { results, total };
259
+ }
260
+ for (const entitySet of entitySets) {
261
+ const es = entitySet;
262
+ const resultSets = es.ResultSets;
263
+ if (Array.isArray(resultSets)) {
264
+ for (const resultSet of resultSets) {
265
+ const rs = resultSet;
266
+ // Try to get total
267
+ const rsTotal = rs.Total ?? rs.TotalCount ?? rs.TotalEstimate;
268
+ if (typeof rsTotal === 'number') {
269
+ total = rsTotal;
270
+ }
271
+ const items = rs.Results;
272
+ if (Array.isArray(items)) {
273
+ for (const item of items) {
274
+ const parsed = parseV2Result(item);
275
+ if (parsed)
276
+ results.push(parsed);
277
+ }
278
+ }
279
+ }
280
+ }
281
+ }
282
+ return { results, total };
283
+ }
284
+ /**
285
+ * Parses people search results from the Groups/Suggestions structure.
286
+ *
287
+ * @param groups - Raw Groups array from suggestions API response
288
+ * @returns Array of parsed person results
289
+ */
290
+ export function parsePeopleResults(groups) {
291
+ const results = [];
292
+ if (!Array.isArray(groups)) {
293
+ return results;
294
+ }
295
+ for (const group of groups) {
296
+ const g = group;
297
+ const suggestions = g.Suggestions;
298
+ if (Array.isArray(suggestions)) {
299
+ for (const suggestion of suggestions) {
300
+ const parsed = parsePersonSuggestion(suggestion);
301
+ if (parsed)
302
+ results.push(parsed);
303
+ }
304
+ }
305
+ }
306
+ return results;
307
+ }
308
+ /**
309
+ * Parses a single channel suggestion from the API response.
310
+ *
311
+ * @param suggestion - Raw suggestion object from API
312
+ * @returns Parsed channel result or null if required fields are missing
313
+ */
314
+ export function parseChannelSuggestion(suggestion) {
315
+ const name = suggestion.Name;
316
+ const threadId = suggestion.ThreadId;
317
+ const teamName = suggestion.TeamName;
318
+ const groupId = suggestion.GroupId;
319
+ // All required fields must be present
320
+ if (!name || !threadId || !teamName || !groupId) {
321
+ return null;
322
+ }
323
+ return {
324
+ channelId: threadId,
325
+ channelName: name,
326
+ teamName,
327
+ teamId: groupId,
328
+ channelType: suggestion.ChannelType || 'Standard',
329
+ description: suggestion.Description,
330
+ };
331
+ }
332
+ /**
333
+ * Parses channel search results from the Groups/Suggestions structure.
334
+ *
335
+ * @param groups - Raw Groups array from suggestions API response
336
+ * @returns Array of parsed channel results
337
+ */
338
+ export function parseChannelResults(groups) {
339
+ const results = [];
340
+ if (!Array.isArray(groups)) {
341
+ return results;
342
+ }
343
+ for (const group of groups) {
344
+ const g = group;
345
+ const suggestions = g.Suggestions;
346
+ if (Array.isArray(suggestions)) {
347
+ for (const suggestion of suggestions) {
348
+ const s = suggestion;
349
+ // Only parse ChannelSuggestion entities
350
+ if (s.EntityType === 'ChannelSuggestion') {
351
+ const parsed = parseChannelSuggestion(s);
352
+ if (parsed)
353
+ results.push(parsed);
354
+ }
355
+ }
356
+ }
357
+ }
358
+ return results;
359
+ }
360
+ /**
361
+ * Parses the Teams List API response to extract all teams and channels.
362
+ *
363
+ * @param data - Raw response data from /api/csa/{region}/api/v3/teams/users/me
364
+ * @returns Array of teams with their channels
365
+ */
366
+ export function parseTeamsList(data) {
367
+ const results = [];
368
+ if (!data)
369
+ return results;
370
+ const teams = data.teams;
371
+ if (!Array.isArray(teams))
372
+ return results;
373
+ for (const team of teams) {
374
+ const t = team;
375
+ // Team's id IS the thread ID (format: 19:xxx@thread.tacv2)
376
+ const threadId = t.id;
377
+ const displayName = t.displayName;
378
+ if (!threadId || !displayName)
379
+ continue;
380
+ const channels = [];
381
+ const channelList = t.channels;
382
+ if (Array.isArray(channelList)) {
383
+ for (const channel of channelList) {
384
+ const c = channel;
385
+ const channelId = c.id;
386
+ const channelName = c.displayName;
387
+ if (!channelId || !channelName)
388
+ continue;
389
+ // Channel has groupId directly, and channelType as a number
390
+ const groupId = c.groupId || '';
391
+ // Map numeric channelType to string (0=Standard, 1=Private, 2=Shared)
392
+ const channelTypeNum = c.channelType;
393
+ const channelType = channelTypeNum === 1 ? 'Private'
394
+ : channelTypeNum === 2 ? 'Shared'
395
+ : 'Standard';
396
+ channels.push({
397
+ channelId,
398
+ channelName,
399
+ teamName: displayName,
400
+ teamId: groupId,
401
+ channelType,
402
+ description: c.description,
403
+ isMember: true, // User is always a member for channels returned by this API
404
+ });
405
+ }
406
+ }
407
+ results.push({
408
+ teamId: threadId, // Use thread ID as team identifier
409
+ teamName: displayName,
410
+ threadId,
411
+ description: t.description,
412
+ channels,
413
+ });
414
+ }
415
+ return results;
416
+ }
417
+ /**
418
+ * Filters channels from the Teams List by name.
419
+ *
420
+ * @param teams - Array of teams with channels from parseTeamsList
421
+ * @param query - Search query (case-insensitive partial match)
422
+ * @returns Matching channels flattened into a single array
423
+ */
424
+ export function filterChannelsByName(teams, query) {
425
+ const lowerQuery = query.toLowerCase();
426
+ const results = [];
427
+ for (const team of teams) {
428
+ for (const channel of team.channels) {
429
+ if (channel.channelName.toLowerCase().includes(lowerQuery)) {
430
+ results.push(channel);
431
+ }
432
+ }
433
+ }
434
+ return results;
435
+ }
436
+ /**
437
+ * Decodes a base64-encoded GUID to its standard string representation.
438
+ *
439
+ * Microsoft encodes GUIDs as 16 bytes with little-endian ordering for the
440
+ * first three groups (Data1, Data2, Data3).
441
+ *
442
+ * @param base64 - Base64-encoded GUID (typically 24 chars with == padding)
443
+ * @returns The GUID string in standard format, or null if invalid
444
+ */
445
+ export function decodeBase64Guid(base64) {
446
+ try {
447
+ // Decode base64 to bytes
448
+ const bytes = Buffer.from(base64, 'base64');
449
+ // GUID is exactly 16 bytes
450
+ if (bytes.length !== 16) {
451
+ return null;
452
+ }
453
+ // Convert to hex
454
+ const hex = bytes.toString('hex');
455
+ // Format as GUID with little-endian byte ordering for first 3 groups
456
+ // Data1 (4 bytes), Data2 (2 bytes), Data3 (2 bytes) are little-endian
457
+ // Data4 (8 bytes) is big-endian
458
+ const guid = [
459
+ hex.slice(6, 8) + hex.slice(4, 6) + hex.slice(2, 4) + hex.slice(0, 2), // Data1
460
+ hex.slice(10, 12) + hex.slice(8, 10), // Data2
461
+ hex.slice(14, 16) + hex.slice(12, 14), // Data3
462
+ hex.slice(16, 20), // Data4a
463
+ hex.slice(20, 32), // Data4b
464
+ ].join('-');
465
+ return guid.toLowerCase();
466
+ }
467
+ catch {
468
+ return null;
469
+ }
470
+ }
471
+ /**
472
+ * Checks if a string appears to be a base64-encoded GUID.
473
+ * Base64-encoded 16 bytes = 24 characters (22 chars + 2 padding or no padding).
474
+ */
475
+ function isLikelyBase64Guid(str) {
476
+ // Check length (22-24 chars for 16 bytes)
477
+ if (str.length < 22 || str.length > 24) {
478
+ return false;
479
+ }
480
+ // Must contain only base64 characters
481
+ if (!/^[A-Za-z0-9+/]+=*$/.test(str)) {
482
+ return false;
483
+ }
484
+ // Typically ends with == for 16 bytes
485
+ return true;
486
+ }
487
+ /**
488
+ * Extracts the Azure AD object ID (GUID) from various formats.
489
+ *
490
+ * Handles:
491
+ * - MRI format: "8:orgid:ab76f827-27e2-4c67-a765-f1a53145fa24"
492
+ * - MRI with base64: "8:orgid:93qkaTtFGWpUHjyRafgdhg=="
493
+ * - Skype ID format: "orgid:ab76f827-27e2-4c67-a765-f1a53145fa24"
494
+ * - ID with tenant: "ab76f827-27e2-4c67-a765-f1a53145fa24@56b731a8-..."
495
+ * - Raw GUID: "ab76f827-27e2-4c67-a765-f1a53145fa24"
496
+ * - Base64-encoded GUID: "93qkaTtFGWpUHjyRafgdhg=="
497
+ *
498
+ * @param identifier - User identifier in any supported format
499
+ * @returns The extracted GUID or null if invalid format
500
+ */
501
+ export function extractObjectId(identifier) {
502
+ if (!identifier)
503
+ return null;
504
+ // Pattern for a GUID (with or without hyphens)
505
+ const guidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
506
+ // Handle MRI format: "8:orgid:GUID" or "8:orgid:base64"
507
+ if (identifier.startsWith('8:orgid:')) {
508
+ const idPart = identifier.substring(8);
509
+ if (guidPattern.test(idPart)) {
510
+ return idPart.toLowerCase();
511
+ }
512
+ // Try base64 decoding
513
+ if (isLikelyBase64Guid(idPart)) {
514
+ return decodeBase64Guid(idPart);
515
+ }
516
+ return null;
517
+ }
518
+ // Handle Skype ID format: "orgid:GUID" (from skype token's skypeid field)
519
+ if (identifier.startsWith('orgid:')) {
520
+ const idPart = identifier.substring(6);
521
+ if (guidPattern.test(idPart)) {
522
+ return idPart.toLowerCase();
523
+ }
524
+ // Try base64 decoding
525
+ if (isLikelyBase64Guid(idPart)) {
526
+ return decodeBase64Guid(idPart);
527
+ }
528
+ return null;
529
+ }
530
+ // Handle ID with tenant: "GUID@tenantId"
531
+ if (identifier.includes('@')) {
532
+ const idPart = identifier.split('@')[0];
533
+ if (guidPattern.test(idPart)) {
534
+ return idPart.toLowerCase();
535
+ }
536
+ // Try base64 decoding
537
+ if (isLikelyBase64Guid(idPart)) {
538
+ return decodeBase64Guid(idPart);
539
+ }
540
+ return null;
541
+ }
542
+ // Handle raw GUID
543
+ if (guidPattern.test(identifier)) {
544
+ return identifier.toLowerCase();
545
+ }
546
+ // Handle base64-encoded GUID
547
+ if (isLikelyBase64Guid(identifier)) {
548
+ return decodeBase64Guid(identifier);
549
+ }
550
+ return null;
551
+ }
552
+ /**
553
+ * Builds a 1:1 conversation ID from two user object IDs.
554
+ *
555
+ * The conversation ID format for 1:1 chats in Teams is:
556
+ * `19:{userId1}_{userId2}@unq.gbl.spaces`
557
+ *
558
+ * The user IDs are sorted lexicographically to ensure consistency -
559
+ * both participants will generate the same conversation ID.
560
+ *
561
+ * @param userId1 - First user's object ID (GUID, MRI, or ID with tenant)
562
+ * @param userId2 - Second user's object ID (GUID, MRI, or ID with tenant)
563
+ * @returns The constructed conversation ID, or null if either ID is invalid
564
+ */
565
+ export function buildOneOnOneConversationId(userId1, userId2) {
566
+ const id1 = extractObjectId(userId1);
567
+ const id2 = extractObjectId(userId2);
568
+ if (!id1 || !id2) {
569
+ return null;
570
+ }
571
+ // Sort lexicographically for consistent ID regardless of who initiates
572
+ const sorted = [id1, id2].sort();
573
+ return `19:${sorted[0]}_${sorted[1]}@unq.gbl.spaces`;
574
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Unit tests for parsing functions.
3
+ *
4
+ * Tests outcomes, not implementations - verify that given inputs
5
+ * produce expected outputs regardless of internal logic.
6
+ */
7
+ export {};