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,1100 @@
1
+ /**
2
+ * Chat Service API client for messaging operations.
3
+ *
4
+ * Handles all calls to teams.microsoft.com/api/chatsvc endpoints.
5
+ * Base URL is extracted from session config to support different Teams environments.
6
+ */
7
+ import { httpRequest } from '../utils/http.js';
8
+ import { CHATSVC_API, getMessagingHeaders, getSkypeAuthHeaders, getTeamsHeaders } from '../utils/api-config.js';
9
+ import { ErrorCode, createError } from '../types/errors.js';
10
+ import { ok, err } from '../types/result.js';
11
+ import { getUserDisplayName } from '../auth/token-extractor.js';
12
+ import { requireMessageAuth, getRegion, getTeamsBaseUrl } from '../utils/auth-guards.js';
13
+ import { stripHtml, extractLinks, buildMessageLink, buildOneOnOneConversationId, extractObjectId, extractActivityTimestamp, parseVirtualConversationMessage } from '../utils/parsers.js';
14
+ import { DEFAULT_ACTIVITY_LIMIT, SAVED_MESSAGES_ID, FOLLOWED_THREADS_ID, VIRTUAL_CONVERSATION_PREFIX, SELF_CHAT_ID, MRI_ORGID_PREFIX } from '../constants.js';
15
+ // Reusable date formatter for human-readable timestamps with day of week
16
+ // Hoisted to module scope to avoid creating a new formatter per message
17
+ const humanReadableDateFormatter = new Intl.DateTimeFormat('en-US', {
18
+ weekday: 'long',
19
+ day: 'numeric',
20
+ month: 'long',
21
+ year: 'numeric',
22
+ hour: '2-digit',
23
+ minute: '2-digit',
24
+ timeZone: 'UTC',
25
+ timeZoneName: 'short',
26
+ });
27
+ /**
28
+ * Formats an ISO timestamp into a human-readable string with day of week.
29
+ * This helps LLMs correctly identify the day without needing to calculate it.
30
+ * Example: "Friday, January 30, 2026, 10:45 AM UTC"
31
+ */
32
+ function formatHumanReadableDate(isoTimestamp) {
33
+ try {
34
+ const date = new Date(isoTimestamp);
35
+ if (isNaN(date.getTime()))
36
+ return '';
37
+ return humanReadableDateFormatter.format(date);
38
+ }
39
+ catch {
40
+ return '';
41
+ }
42
+ }
43
+ /** Gets region and base URL together for API calls. */
44
+ function getApiConfig() {
45
+ return {
46
+ region: getRegion(),
47
+ baseUrl: getTeamsBaseUrl(),
48
+ };
49
+ }
50
+ /**
51
+ * Sends a message to a Teams conversation.
52
+ *
53
+ * For channels, you can either:
54
+ * - Post a new top-level message: just provide the channel's conversationId
55
+ * - Reply to a thread: provide the channel's conversationId AND replyToMessageId
56
+ *
57
+ * For chats (1:1, group, meeting), all messages go to the same conversation
58
+ * without threading - just provide the conversationId.
59
+ */
60
+ export async function sendMessage(conversationId, content, options = {}) {
61
+ const authResult = requireMessageAuth();
62
+ if (!authResult.ok) {
63
+ return authResult;
64
+ }
65
+ const auth = authResult.value;
66
+ const { replyToMessageId } = options;
67
+ const { region, baseUrl } = getApiConfig();
68
+ const displayName = getUserDisplayName() || 'User';
69
+ // Generate unique message ID
70
+ const clientMessageId = Date.now().toString();
71
+ // Process content: handle mentions and links
72
+ let processedContent;
73
+ let mentionsToSend = [];
74
+ // If content is already HTML, pass through as-is
75
+ if (content.startsWith('<')) {
76
+ processedContent = content;
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>`;
88
+ // Build the message body
89
+ const body = {
90
+ content: htmlContent,
91
+ messagetype: 'RichText/Html',
92
+ contenttype: 'text',
93
+ imdisplayname: displayName,
94
+ clientmessageid: clientMessageId,
95
+ };
96
+ // Add mentions property if mentions were found
97
+ if (mentionsToSend.length > 0) {
98
+ body.properties = {
99
+ mentions: buildMentionsProperty(mentionsToSend),
100
+ };
101
+ }
102
+ const url = CHATSVC_API.messages(region, conversationId, replyToMessageId, baseUrl);
103
+ const response = await httpRequest(url, {
104
+ method: 'POST',
105
+ headers: getMessagingHeaders(auth.skypeToken, auth.authToken, baseUrl),
106
+ body: JSON.stringify(body),
107
+ });
108
+ if (!response.ok) {
109
+ return response;
110
+ }
111
+ return ok({
112
+ messageId: clientMessageId,
113
+ timestamp: response.value.data.OriginalArrivalTime,
114
+ });
115
+ }
116
+ /**
117
+ * Sends a message to your own notes/self-chat.
118
+ */
119
+ export async function sendNoteToSelf(content) {
120
+ return sendMessage(SELF_CHAT_ID, content);
121
+ }
122
+ /**
123
+ * Gets messages from a Teams conversation/thread.
124
+ *
125
+ * @param conversationId - The conversation ID to fetch messages from
126
+ * @param options.limit - Maximum messages to return (default 50)
127
+ * @param options.startTime - Fetch messages from this timestamp onwards
128
+ * @param options.order - Sort order: 'desc' (newest-first, default) or 'asc' (oldest-first)
129
+ */
130
+ export async function getThreadMessages(conversationId, options = {}) {
131
+ const authResult = requireMessageAuth();
132
+ if (!authResult.ok) {
133
+ return authResult;
134
+ }
135
+ const auth = authResult.value;
136
+ const { region, baseUrl } = getApiConfig();
137
+ const limit = options.limit ?? 50;
138
+ let url = CHATSVC_API.messages(region, conversationId, undefined, baseUrl);
139
+ url += `?view=msnp24Equivalent&pageSize=${limit}`;
140
+ if (options.startTime) {
141
+ url += `&startTime=${options.startTime}`;
142
+ }
143
+ const response = await httpRequest(url, {
144
+ method: 'GET',
145
+ headers: getSkypeAuthHeaders(auth.skypeToken, auth.authToken, baseUrl),
146
+ });
147
+ if (!response.ok) {
148
+ return response;
149
+ }
150
+ const rawMessages = response.value.data.messages;
151
+ if (!Array.isArray(rawMessages)) {
152
+ return ok({
153
+ conversationId,
154
+ messages: [],
155
+ });
156
+ }
157
+ const messages = [];
158
+ for (const raw of rawMessages) {
159
+ const msg = raw;
160
+ // Skip non-message types
161
+ const messageType = msg.messagetype;
162
+ if (!messageType || messageType.startsWith('Control/') || messageType === 'ThreadActivity/AddMember') {
163
+ continue;
164
+ }
165
+ // Skip deleted messages (they have empty content and a deletetime property)
166
+ const properties = msg.properties;
167
+ if (properties?.deletetime) {
168
+ continue;
169
+ }
170
+ const id = msg.id || msg.originalarrivaltime;
171
+ if (!id)
172
+ continue;
173
+ const content = msg.content || '';
174
+ const contentType = msg.messagetype || 'Text';
175
+ const fromMri = msg.from || '';
176
+ const displayName = msg.imdisplayname || msg.displayName;
177
+ const timestamp = msg.originalarrivaltime ||
178
+ msg.composetime ||
179
+ new Date(parseInt(id, 10)).toISOString();
180
+ // Build message link
181
+ const messageLink = /^\d+$/.test(id)
182
+ ? buildMessageLink(conversationId, id)
183
+ : undefined;
184
+ // Extract links before stripping HTML
185
+ const links = extractLinks(content);
186
+ // Format human-readable date with day of week to help LLMs
187
+ const when = formatHumanReadableDate(timestamp);
188
+ // Extract thread root ID for channel messages
189
+ // When rootMessageId differs from id, this message is a reply within a thread
190
+ const rootMessageId = msg.rootMessageId;
191
+ const isThreadReply = !!rootMessageId && rootMessageId !== id;
192
+ messages.push({
193
+ id,
194
+ content: stripHtml(content),
195
+ contentType,
196
+ sender: {
197
+ mri: fromMri,
198
+ displayName,
199
+ },
200
+ timestamp,
201
+ when: when || undefined,
202
+ conversationId,
203
+ clientMessageId: msg.clientmessageid,
204
+ isFromMe: fromMri === auth.userMri,
205
+ messageLink,
206
+ links: links.length > 0 ? links : undefined,
207
+ threadRootId: isThreadReply ? rootMessageId : undefined,
208
+ isThreadReply: isThreadReply || undefined,
209
+ });
210
+ }
211
+ // Sort by timestamp - default to newest-first (desc) for "what's latest" use cases
212
+ const order = options.order ?? 'desc';
213
+ if (order === 'asc') {
214
+ // Oldest first (chronological reading order)
215
+ messages.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
216
+ }
217
+ else {
218
+ // Newest first (default - latest activity at top)
219
+ messages.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
220
+ }
221
+ return ok({
222
+ conversationId,
223
+ messages,
224
+ });
225
+ }
226
+ // Regex pattern for extracting message ID from saved messages: T_{conversationId}_M_{messageId}
227
+ const SAVED_MESSAGE_PATTERN = /_M_(\d+)$/;
228
+ // Regex pattern for extracting post ID from followed threads: T_{conversationId}_P_{postId}_Threads
229
+ const FOLLOWED_THREAD_PATTERN = /_P_(\d+)_Threads$/;
230
+ /**
231
+ * Gets saved (bookmarked) messages from the virtual 48:saved conversation.
232
+ * Returns messages the user has bookmarked across all conversations.
233
+ */
234
+ export async function getSavedMessages(options = {}) {
235
+ const authResult = requireMessageAuth();
236
+ if (!authResult.ok) {
237
+ return authResult;
238
+ }
239
+ const auth = authResult.value;
240
+ const { region, baseUrl } = getApiConfig();
241
+ const limit = options.limit ?? 50;
242
+ let url = CHATSVC_API.messages(region, SAVED_MESSAGES_ID, undefined, baseUrl);
243
+ url += `?view=msnp24Equivalent|supportsMessageProperties&pageSize=${limit}&startTime=1`;
244
+ const response = await httpRequest(url, {
245
+ method: 'GET',
246
+ headers: getSkypeAuthHeaders(auth.skypeToken, auth.authToken, baseUrl),
247
+ });
248
+ if (!response.ok) {
249
+ return response;
250
+ }
251
+ const rawMessages = response.value.data.messages;
252
+ if (!Array.isArray(rawMessages)) {
253
+ return ok({ messages: [] });
254
+ }
255
+ const messages = [];
256
+ for (const raw of rawMessages) {
257
+ const parsed = parseVirtualConversationMessage(raw, SAVED_MESSAGE_PATTERN);
258
+ if (!parsed)
259
+ continue;
260
+ messages.push({
261
+ id: parsed.id,
262
+ content: parsed.content,
263
+ contentType: parsed.contentType,
264
+ sender: parsed.sender,
265
+ timestamp: parsed.timestamp,
266
+ sourceConversationId: parsed.sourceConversationId,
267
+ sourceMessageId: parsed.sourceReferenceId,
268
+ messageLink: parsed.messageLink,
269
+ });
270
+ }
271
+ // Sort by timestamp (newest first for saved messages)
272
+ messages.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
273
+ return ok({ messages });
274
+ }
275
+ /**
276
+ * Gets followed threads from the virtual 48:threads conversation.
277
+ * Returns threads the user is following for updates.
278
+ */
279
+ export async function getFollowedThreads(options = {}) {
280
+ const authResult = requireMessageAuth();
281
+ if (!authResult.ok) {
282
+ return authResult;
283
+ }
284
+ const auth = authResult.value;
285
+ const { region, baseUrl } = getApiConfig();
286
+ const limit = options.limit ?? 50;
287
+ let url = CHATSVC_API.messages(region, FOLLOWED_THREADS_ID, undefined, baseUrl);
288
+ url += `?view=msnp24Equivalent|supportsMessageProperties&pageSize=${limit}&startTime=1`;
289
+ const response = await httpRequest(url, {
290
+ method: 'GET',
291
+ headers: getSkypeAuthHeaders(auth.skypeToken, auth.authToken, baseUrl),
292
+ });
293
+ if (!response.ok) {
294
+ return response;
295
+ }
296
+ const rawMessages = response.value.data.messages;
297
+ if (!Array.isArray(rawMessages)) {
298
+ return ok({ threads: [] });
299
+ }
300
+ const threads = [];
301
+ for (const raw of rawMessages) {
302
+ const parsed = parseVirtualConversationMessage(raw, FOLLOWED_THREAD_PATTERN);
303
+ if (!parsed)
304
+ continue;
305
+ threads.push({
306
+ id: parsed.id,
307
+ content: parsed.content,
308
+ contentType: parsed.contentType,
309
+ sender: parsed.sender,
310
+ timestamp: parsed.timestamp,
311
+ sourceConversationId: parsed.sourceConversationId,
312
+ sourcePostId: parsed.sourceReferenceId,
313
+ messageLink: parsed.messageLink,
314
+ });
315
+ }
316
+ // Sort by timestamp (newest first for followed threads)
317
+ threads.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
318
+ return ok({ threads });
319
+ }
320
+ /**
321
+ * Saves (bookmarks) a message.
322
+ *
323
+ * @param rootMessageId - For channel threaded replies, the ID of the thread root post.
324
+ * For top-level posts and non-channel messages, omit or pass undefined.
325
+ */
326
+ export async function saveMessage(conversationId, messageId, rootMessageId) {
327
+ return setMessageSavedState(conversationId, messageId, true, rootMessageId);
328
+ }
329
+ /**
330
+ * Unsaves (removes bookmark from) a message.
331
+ *
332
+ * @param rootMessageId - For channel threaded replies, the ID of the thread root post.
333
+ * For top-level posts and non-channel messages, omit or pass undefined.
334
+ */
335
+ export async function unsaveMessage(conversationId, messageId, rootMessageId) {
336
+ return setMessageSavedState(conversationId, messageId, false, rootMessageId);
337
+ }
338
+ /**
339
+ * Internal function to set the saved state of a message.
340
+ *
341
+ * The rcmetadata API uses a two-ID system:
342
+ * - URL path: rootMessageId (thread root for channel replies, or messageId for top-level)
343
+ * - Body: mid (the actual message being saved/unsaved)
344
+ */
345
+ async function setMessageSavedState(conversationId, messageId, saved, rootMessageId) {
346
+ const authResult = requireMessageAuth();
347
+ if (!authResult.ok) {
348
+ return authResult;
349
+ }
350
+ const auth = authResult.value;
351
+ const { region, baseUrl } = getApiConfig();
352
+ // For channel threaded replies, rootMessageId is the thread root post ID
353
+ // For top-level posts and non-channel messages, use the messageId itself
354
+ const urlMessageId = rootMessageId ?? messageId;
355
+ const url = CHATSVC_API.messageMetadata(region, conversationId, urlMessageId, baseUrl);
356
+ const response = await httpRequest(url, {
357
+ method: 'PUT',
358
+ headers: getSkypeAuthHeaders(auth.skypeToken, auth.authToken, baseUrl),
359
+ body: JSON.stringify({
360
+ s: saved ? 1 : 0,
361
+ mid: parseInt(messageId, 10),
362
+ }),
363
+ });
364
+ if (!response.ok) {
365
+ return response;
366
+ }
367
+ return ok({
368
+ conversationId,
369
+ messageId,
370
+ saved,
371
+ });
372
+ }
373
+ /**
374
+ * Edits an existing message.
375
+ *
376
+ * Note: You can only edit your own messages. The API will reject
377
+ * attempts to edit messages from other users.
378
+ *
379
+ * @param conversationId - The conversation containing the message
380
+ * @param messageId - The ID of the message to edit
381
+ * @param newContent - The new content for the message
382
+ */
383
+ export async function editMessage(conversationId, messageId, newContent) {
384
+ const authResult = requireMessageAuth();
385
+ if (!authResult.ok) {
386
+ return authResult;
387
+ }
388
+ const auth = authResult.value;
389
+ const { region, baseUrl } = getApiConfig();
390
+ const displayName = getUserDisplayName() || 'User';
391
+ // Wrap content in paragraph if not already HTML
392
+ const htmlContent = newContent.startsWith('<') ? newContent : `<p>${escapeHtml(newContent)}</p>`;
393
+ // Build the edit request body
394
+ // The API requires the message structure with updated content
395
+ const body = {
396
+ id: messageId,
397
+ type: 'Message',
398
+ conversationid: conversationId,
399
+ content: htmlContent,
400
+ messagetype: 'RichText/Html',
401
+ contenttype: 'text',
402
+ imdisplayname: displayName,
403
+ };
404
+ const url = CHATSVC_API.editMessage(region, conversationId, messageId, baseUrl);
405
+ const response = await httpRequest(url, {
406
+ method: 'PUT',
407
+ headers: getMessagingHeaders(auth.skypeToken, auth.authToken, baseUrl),
408
+ body: JSON.stringify(body),
409
+ });
410
+ if (!response.ok) {
411
+ return response;
412
+ }
413
+ return ok({
414
+ messageId,
415
+ conversationId,
416
+ });
417
+ }
418
+ /**
419
+ * Deletes a message (soft delete).
420
+ *
421
+ * Note: You can only delete your own messages, unless you are a
422
+ * channel owner/moderator. The API will reject unauthorised attempts.
423
+ *
424
+ * @param conversationId - The conversation containing the message
425
+ * @param messageId - The ID of the message to delete
426
+ */
427
+ export async function deleteMessage(conversationId, messageId) {
428
+ const authResult = requireMessageAuth();
429
+ if (!authResult.ok) {
430
+ return authResult;
431
+ }
432
+ const auth = authResult.value;
433
+ const { region, baseUrl } = getApiConfig();
434
+ const url = CHATSVC_API.deleteMessage(region, conversationId, messageId, baseUrl);
435
+ const response = await httpRequest(url, {
436
+ method: 'DELETE',
437
+ headers: getMessagingHeaders(auth.skypeToken, auth.authToken, baseUrl),
438
+ });
439
+ if (!response.ok) {
440
+ return response;
441
+ }
442
+ return ok({
443
+ messageId,
444
+ conversationId,
445
+ });
446
+ }
447
+ /**
448
+ * Gets properties for a single conversation.
449
+ */
450
+ export async function getConversationProperties(conversationId) {
451
+ const authResult = requireMessageAuth();
452
+ if (!authResult.ok) {
453
+ return authResult;
454
+ }
455
+ const auth = authResult.value;
456
+ const { region, baseUrl } = getApiConfig();
457
+ const url = CHATSVC_API.conversation(region, conversationId, baseUrl) + '?view=msnp24Equivalent';
458
+ const response = await httpRequest(url, {
459
+ method: 'GET',
460
+ headers: getSkypeAuthHeaders(auth.skypeToken, auth.authToken, baseUrl),
461
+ });
462
+ if (!response.ok) {
463
+ return response;
464
+ }
465
+ const data = response.value.data;
466
+ const threadProps = data.threadProperties;
467
+ const productType = threadProps?.productThreadType;
468
+ // Try to get display name from various sources
469
+ let displayName;
470
+ if (threadProps?.topicThreadTopic) {
471
+ displayName = threadProps.topicThreadTopic;
472
+ }
473
+ if (!displayName && threadProps?.topic) {
474
+ displayName = threadProps.topic;
475
+ }
476
+ if (!displayName && threadProps?.spaceThreadTopic) {
477
+ displayName = threadProps.spaceThreadTopic;
478
+ }
479
+ if (!displayName && threadProps?.threadtopic) {
480
+ displayName = threadProps.threadtopic;
481
+ }
482
+ // For chats without a topic: build from members
483
+ if (!displayName) {
484
+ const members = data.members;
485
+ if (members && members.length > 0) {
486
+ const otherMembers = members
487
+ .filter(m => m.mri !== auth.userMri && m.id !== auth.userMri)
488
+ .map(m => (m.friendlyName || m.displayName || m.name))
489
+ .filter((name) => !!name);
490
+ if (otherMembers.length > 0) {
491
+ displayName = otherMembers.length <= 3
492
+ ? otherMembers.join(', ')
493
+ : `${otherMembers.slice(0, 3).join(', ')} + ${otherMembers.length - 3} more`;
494
+ }
495
+ }
496
+ }
497
+ // Determine conversation type
498
+ let conversationType;
499
+ if (productType) {
500
+ if (productType === 'Meeting') {
501
+ conversationType = 'Meeting';
502
+ }
503
+ else if (productType.includes('Channel') || productType === 'TeamsTeam') {
504
+ conversationType = 'Channel';
505
+ }
506
+ else if (productType === 'Chat' || productType === 'OneOnOne') {
507
+ conversationType = 'Chat';
508
+ }
509
+ }
510
+ // Fallback to ID pattern detection
511
+ if (!conversationType) {
512
+ if (conversationId.includes('meeting_')) {
513
+ conversationType = 'Meeting';
514
+ }
515
+ else if (threadProps?.groupId) {
516
+ conversationType = 'Channel';
517
+ }
518
+ else if (conversationId.includes('@thread.tacv2') || conversationId.includes('@thread.v2')) {
519
+ conversationType = 'Chat';
520
+ }
521
+ else if (conversationId.startsWith('8:')) {
522
+ conversationType = 'Chat';
523
+ }
524
+ }
525
+ return ok({ displayName, conversationType });
526
+ }
527
+ /**
528
+ * Extracts unique participant names from recent messages.
529
+ */
530
+ export async function extractParticipantNames(conversationId) {
531
+ const authResult = requireMessageAuth();
532
+ if (!authResult.ok) {
533
+ return ok(undefined); // Non-critical: just return undefined if not authenticated
534
+ }
535
+ const auth = authResult.value;
536
+ const { region, baseUrl } = getApiConfig();
537
+ let url = CHATSVC_API.messages(region, conversationId, undefined, baseUrl);
538
+ url += '?view=msnp24Equivalent&pageSize=10';
539
+ const response = await httpRequest(url, {
540
+ method: 'GET',
541
+ headers: getSkypeAuthHeaders(auth.skypeToken, auth.authToken, baseUrl),
542
+ });
543
+ if (!response.ok) {
544
+ return ok(undefined);
545
+ }
546
+ const messages = response.value.data.messages;
547
+ if (!messages || messages.length === 0) {
548
+ return ok(undefined);
549
+ }
550
+ const senderNames = new Set();
551
+ for (const msg of messages) {
552
+ const m = msg;
553
+ const fromMri = m.from || '';
554
+ const displayName = m.imdisplayname;
555
+ if (fromMri === auth.userMri || !displayName) {
556
+ continue;
557
+ }
558
+ senderNames.add(displayName);
559
+ }
560
+ if (senderNames.size === 0) {
561
+ return ok(undefined);
562
+ }
563
+ const names = Array.from(senderNames);
564
+ const result = names.length <= 3
565
+ ? names.join(', ')
566
+ : `${names.slice(0, 3).join(', ')} + ${names.length - 3} more`;
567
+ return ok(result);
568
+ }
569
+ /**
570
+ * Escapes HTML special characters.
571
+ */
572
+ function escapeHtml(text) {
573
+ return text
574
+ .replace(/&/g, '&amp;')
575
+ .replace(/</g, '&lt;')
576
+ .replace(/>/g, '&gt;')
577
+ .replace(/"/g, '&quot;');
578
+ }
579
+ /**
580
+ * Builds the HTML for a single mention.
581
+ */
582
+ function buildMentionHtml(displayName, itemId) {
583
+ return `<readonly class="skipProofing" itemtype="http://schema.skype.com/Mention" contenteditable="false" spellcheck="false"><span itemtype="http://schema.skype.com/Mention" itemscope itemid="${itemId}">${escapeHtml(displayName)}</span></readonly>`;
584
+ }
585
+ /**
586
+ * Builds the mentions property array for the API request.
587
+ */
588
+ function buildMentionsProperty(mentions) {
589
+ const mentionObjects = mentions.map((mention, index) => ({
590
+ '@type': 'http://schema.skype.com/Mention',
591
+ 'itemid': String(index),
592
+ 'mri': mention.mri,
593
+ 'mentionType': 'person',
594
+ 'displayName': mention.displayName,
595
+ }));
596
+ return JSON.stringify(mentionObjects);
597
+ }
598
+ /**
599
+ * Parses content for both mentions @[Name](mri) and links [text](url).
600
+ * Processes them in a single pass to avoid escaping conflicts.
601
+ */
602
+ function parseContentWithMentionsAndLinks(content) {
603
+ // Patterns for mentions and links
604
+ // Note: Link pattern uses [^)\s] to reject URLs with spaces
605
+ const mentionPattern = /@\[([^\]]+)\]\(([^)]+)\)/g;
606
+ const linkPattern = /(?<!@)\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;
607
+ // Helper to find all matches for a pattern
608
+ const findAll = (pattern, type) => {
609
+ const results = [];
610
+ let match;
611
+ while ((match = pattern.exec(content)) !== null) {
612
+ results.push({
613
+ index: match.index,
614
+ length: match[0].length,
615
+ type,
616
+ text: match[1],
617
+ target: match[2],
618
+ });
619
+ }
620
+ return results;
621
+ };
622
+ // Find all mentions and links, then sort by position
623
+ const matches = [
624
+ ...findAll(mentionPattern, 'mention'),
625
+ ...findAll(linkPattern, 'link'),
626
+ ].sort((a, b) => a.index - b.index);
627
+ // Build result
628
+ const mentions = [];
629
+ let result = '';
630
+ let lastIndex = 0;
631
+ let mentionId = 0;
632
+ for (const m of matches) {
633
+ // Add escaped text before this match
634
+ const textBefore = content.substring(lastIndex, m.index);
635
+ result += escapeHtml(textBefore);
636
+ if (m.type === 'mention') {
637
+ mentions.push({ mri: m.target, displayName: m.text });
638
+ result += buildMentionHtml(m.text, mentionId);
639
+ mentionId++;
640
+ }
641
+ else {
642
+ // Link
643
+ const safeText = escapeHtml(m.text);
644
+ const safeUrl = m.target.replace(/"/g, '&quot;');
645
+ result += `<a href="${safeUrl}">${safeText}</a>`;
646
+ }
647
+ lastIndex = m.index + m.length;
648
+ }
649
+ // Add remaining text
650
+ result += escapeHtml(content.substring(lastIndex));
651
+ return { html: result, mentions };
652
+ }
653
+ /**
654
+ * Gets the conversation ID for a 1:1 chat with another user.
655
+ *
656
+ * Constructs the predictable format: `19:{id1}_{id2}@unq.gbl.spaces`
657
+ * where IDs are sorted lexicographically. The conversation is created
658
+ * implicitly when the first message is sent.
659
+ */
660
+ export function getOneOnOneChatId(otherUserIdentifier) {
661
+ const authResult = requireMessageAuth();
662
+ if (!authResult.ok) {
663
+ return authResult;
664
+ }
665
+ const auth = authResult.value;
666
+ // Extract the current user's object ID from their MRI
667
+ const currentUserId = extractObjectId(auth.userMri);
668
+ if (!currentUserId) {
669
+ return err(createError(ErrorCode.AUTH_REQUIRED, 'Could not extract user ID from session. Please try logging in again.'));
670
+ }
671
+ // Extract the other user's object ID
672
+ const otherUserId = extractObjectId(otherUserIdentifier);
673
+ if (!otherUserId) {
674
+ return err(createError(ErrorCode.INVALID_INPUT, `Invalid user identifier: ${otherUserIdentifier}. Expected MRI (8:orgid:guid), ID with tenant (guid@tenant), or raw GUID.`));
675
+ }
676
+ const conversationId = buildOneOnOneConversationId(currentUserId, otherUserId);
677
+ if (!conversationId) {
678
+ // This shouldn't happen if both IDs were validated above, but handle it anyway
679
+ return err(createError(ErrorCode.UNKNOWN, 'Failed to construct conversation ID.'));
680
+ }
681
+ return ok({
682
+ conversationId,
683
+ otherUserId,
684
+ currentUserId,
685
+ });
686
+ }
687
+ /**
688
+ * Creates a new group chat with multiple members.
689
+ *
690
+ * Unlike 1:1 chats which have a predictable ID format, group chats require
691
+ * an API call to create. The conversation ID is returned by the server.
692
+ *
693
+ * @param memberIdentifiers - Array of user identifiers (MRI, object ID, or GUID)
694
+ * @param topic - Optional chat topic/name
695
+ * @param region - API region (default: 'amer')
696
+ *
697
+ * @example
698
+ * ```typescript
699
+ * // Create a group chat with 2 other people
700
+ * const result = await createGroupChat(
701
+ * ['8:orgid:abc123...', '8:orgid:def456...'],
702
+ * 'Project Discussion'
703
+ * );
704
+ * if (result.ok) {
705
+ * console.log('Created chat:', result.value.conversationId);
706
+ * }
707
+ * ```
708
+ */
709
+ export async function createGroupChat(memberIdentifiers, topic) {
710
+ const authResult = requireMessageAuth();
711
+ if (!authResult.ok) {
712
+ return authResult;
713
+ }
714
+ const auth = authResult.value;
715
+ // Validate we have at least 2 other members (plus current user = 3+ total)
716
+ if (!memberIdentifiers || memberIdentifiers.length < 2) {
717
+ return err(createError(ErrorCode.INVALID_INPUT, 'Group chat requires at least 2 other members. For 1:1 chats, use teams_get_chat instead.'));
718
+ }
719
+ const { region, baseUrl } = getApiConfig();
720
+ // Build MRI list for all members, including current user
721
+ const memberMris = [auth.userMri];
722
+ for (const identifier of memberIdentifiers) {
723
+ // Extract object ID and convert to MRI format
724
+ const objectId = extractObjectId(identifier);
725
+ if (!objectId) {
726
+ return err(createError(ErrorCode.INVALID_INPUT, `Invalid user identifier: ${identifier}. Expected MRI (8:orgid:guid), ID with tenant (guid@tenant), or raw GUID.`));
727
+ }
728
+ // Convert to MRI format if not already
729
+ const mri = identifier.startsWith(MRI_ORGID_PREFIX)
730
+ ? identifier
731
+ : `${MRI_ORGID_PREFIX}${objectId}`;
732
+ memberMris.push(mri);
733
+ }
734
+ // Build the request body
735
+ // Format discovered via API research: POST /threads with members having "Admin" role
736
+ const body = {
737
+ members: memberMris.map(mri => ({
738
+ id: mri,
739
+ role: 'Admin',
740
+ })),
741
+ properties: {
742
+ threadType: 'chat',
743
+ },
744
+ };
745
+ // Add topic if provided
746
+ if (topic) {
747
+ body.properties.topic = topic;
748
+ }
749
+ const url = CHATSVC_API.createThread(region, baseUrl);
750
+ const response = await httpRequest(url, {
751
+ method: 'POST',
752
+ headers: {
753
+ ...getMessagingHeaders(auth.skypeToken, auth.authToken, baseUrl),
754
+ 'Accept': 'application/json',
755
+ },
756
+ body: JSON.stringify(body),
757
+ });
758
+ if (!response.ok) {
759
+ return response;
760
+ }
761
+ // The response returns threadResource.id with the new conversation ID
762
+ // Note: Sometimes the API returns an empty body {} but includes the ID in the Location header
763
+ const responseData = response.value.data;
764
+ const threadResource = responseData.threadResource;
765
+ let conversationId = (typeof threadResource?.id === 'string' ? threadResource.id : undefined)
766
+ || (typeof responseData.id === 'string' ? responseData.id : undefined)
767
+ || (typeof responseData.threadId === 'string' ? responseData.threadId : undefined);
768
+ // Fallback: extract from Location header (format: .../threads/19:xxx@thread.v2)
769
+ if (!conversationId) {
770
+ const locationHeader = response.value.headers.get('location');
771
+ if (locationHeader) {
772
+ const match = locationHeader.match(/threads\/(19:[^/]+)/);
773
+ if (match) {
774
+ conversationId = match[1];
775
+ }
776
+ }
777
+ }
778
+ if (!conversationId) {
779
+ // Chat was likely created (201 status) but we couldn't find the ID
780
+ return ok({
781
+ conversationId: '(created - check Teams for conversation ID)',
782
+ members: memberMris,
783
+ topic,
784
+ note: 'Group chat created successfully but API did not return the conversation ID. Check Teams to find the new chat.',
785
+ });
786
+ }
787
+ return ok({
788
+ conversationId,
789
+ members: memberMris,
790
+ topic,
791
+ });
792
+ }
793
+ /**
794
+ * Gets the consumption horizon (read receipts) for a conversation.
795
+ * The consumption horizon indicates where each user has read up to.
796
+ */
797
+ export async function getConsumptionHorizon(conversationId) {
798
+ const authResult = requireMessageAuth();
799
+ if (!authResult.ok) {
800
+ return authResult;
801
+ }
802
+ const auth = authResult.value;
803
+ const { region, baseUrl } = getApiConfig();
804
+ const url = CHATSVC_API.consumptionHorizons(region, conversationId, baseUrl);
805
+ const response = await httpRequest(url, {
806
+ method: 'GET',
807
+ headers: getSkypeAuthHeaders(auth.skypeToken, auth.authToken, baseUrl),
808
+ });
809
+ if (!response.ok) {
810
+ return response;
811
+ }
812
+ const data = response.value.data;
813
+ const horizons = data.consumptionhorizons || [];
814
+ // Find the current user's consumption horizon
815
+ let lastReadMessageId;
816
+ let lastReadTimestamp;
817
+ for (const h of horizons) {
818
+ if (h.id === auth.userMri || h.id.includes(auth.userMri)) {
819
+ // Consumption horizon format: "{timestamp};{timestamp};{messageId}"
820
+ const parts = h.consumptionhorizon.split(';');
821
+ if (parts.length >= 3) {
822
+ lastReadMessageId = parts[2];
823
+ lastReadTimestamp = parseInt(parts[0], 10);
824
+ }
825
+ break;
826
+ }
827
+ }
828
+ return ok({
829
+ conversationId,
830
+ version: data.version,
831
+ lastReadMessageId,
832
+ lastReadTimestamp,
833
+ consumptionHorizons: horizons.map(h => ({
834
+ id: h.id,
835
+ consumptionHorizon: h.consumptionhorizon,
836
+ })),
837
+ });
838
+ }
839
+ /**
840
+ * Marks a conversation as read up to a specific message.
841
+ */
842
+ export async function markAsRead(conversationId, messageId) {
843
+ const authResult = requireMessageAuth();
844
+ if (!authResult.ok) {
845
+ return authResult;
846
+ }
847
+ const auth = authResult.value;
848
+ const { region, baseUrl } = getApiConfig();
849
+ const url = CHATSVC_API.updateConsumptionHorizon(region, conversationId, baseUrl);
850
+ // Format: "{messageId};{messageId};{messageId}" - all three values are the same
851
+ const consumptionHorizon = `${messageId};${messageId};${messageId}`;
852
+ const response = await httpRequest(url, {
853
+ method: 'PUT',
854
+ headers: getSkypeAuthHeaders(auth.skypeToken, auth.authToken, baseUrl),
855
+ body: JSON.stringify({
856
+ consumptionhorizon: consumptionHorizon,
857
+ }),
858
+ });
859
+ if (!response.ok) {
860
+ return response;
861
+ }
862
+ return ok({
863
+ conversationId,
864
+ markedUpTo: messageId,
865
+ });
866
+ }
867
+ /**
868
+ * Gets unread count for a conversation by comparing consumption horizon
869
+ * with recent messages.
870
+ */
871
+ export async function getUnreadStatus(conversationId) {
872
+ // Get consumption horizon
873
+ const horizonResult = await getConsumptionHorizon(conversationId);
874
+ if (!horizonResult.ok) {
875
+ return horizonResult;
876
+ }
877
+ // Get recent messages
878
+ const messagesResult = await getThreadMessages(conversationId, { limit: 50 });
879
+ if (!messagesResult.ok) {
880
+ return messagesResult;
881
+ }
882
+ const lastReadId = horizonResult.value.lastReadMessageId;
883
+ const messages = messagesResult.value.messages;
884
+ // Count messages after the last read position
885
+ let unreadCount = 0;
886
+ let latestMessageId;
887
+ // Messages are sorted oldest-first, so reverse to process newest-first
888
+ const reversedMessages = [...messages].reverse();
889
+ for (const msg of reversedMessages) {
890
+ if (!latestMessageId && !msg.isFromMe) {
891
+ latestMessageId = msg.id;
892
+ }
893
+ if (lastReadId && msg.id === lastReadId) {
894
+ break;
895
+ }
896
+ // Count messages not from the current user
897
+ if (!msg.isFromMe) {
898
+ unreadCount++;
899
+ }
900
+ }
901
+ // If last read message wasn't in our window, count is a lower bound
902
+ return ok({
903
+ conversationId,
904
+ unreadCount,
905
+ lastReadMessageId: lastReadId,
906
+ latestMessageId,
907
+ });
908
+ }
909
+ /**
910
+ * Determines the activity type from message content and properties.
911
+ */
912
+ function detectActivityType(msg) {
913
+ const content = msg.content || '';
914
+ const messageType = msg.messagetype || '';
915
+ // Check for @mention
916
+ if (content.includes('itemtype="http://schema.skype.com/Mention"') ||
917
+ content.includes('itemscope itemtype="http://schema.skype.com/Mention"')) {
918
+ return 'mention';
919
+ }
920
+ // Check for reaction-related message types
921
+ if (messageType.toLowerCase().includes('reaction')) {
922
+ return 'reaction';
923
+ }
924
+ // Check for thread/reply indicators
925
+ if (msg.threadtopic || msg.parentMessageId) {
926
+ return 'reply';
927
+ }
928
+ // Standard message
929
+ if (messageType.includes('RichText') || messageType.includes('Text')) {
930
+ return 'message';
931
+ }
932
+ return 'unknown';
933
+ }
934
+ /**
935
+ * Gets the activity feed (notifications) for the current user.
936
+ * Includes mentions, reactions, replies, and other notifications.
937
+ */
938
+ export async function getActivityFeed(options = {}) {
939
+ const authResult = requireMessageAuth();
940
+ if (!authResult.ok) {
941
+ return authResult;
942
+ }
943
+ const auth = authResult.value;
944
+ const { region, baseUrl } = getApiConfig();
945
+ const limit = options.limit ?? DEFAULT_ACTIVITY_LIMIT;
946
+ let url = CHATSVC_API.activityFeed(region, baseUrl);
947
+ url += `?view=msnp24Equivalent&pageSize=${limit}`;
948
+ const response = await httpRequest(url, {
949
+ method: 'GET',
950
+ headers: getSkypeAuthHeaders(auth.skypeToken, auth.authToken, baseUrl),
951
+ });
952
+ if (!response.ok) {
953
+ return response;
954
+ }
955
+ const rawMessages = response.value.data.messages;
956
+ const syncState = response.value.data.syncState;
957
+ if (!Array.isArray(rawMessages)) {
958
+ return ok({
959
+ activities: [],
960
+ syncState,
961
+ });
962
+ }
963
+ const activities = [];
964
+ for (const raw of rawMessages) {
965
+ const msg = raw;
966
+ // Skip control/system messages that aren't relevant
967
+ const messageType = msg.messagetype;
968
+ if (!messageType ||
969
+ messageType.startsWith('Control/') ||
970
+ messageType === 'ThreadActivity/AddMember' ||
971
+ messageType === 'ThreadActivity/DeleteMember') {
972
+ continue;
973
+ }
974
+ const id = msg.id || msg.originalarrivaltime;
975
+ if (!id)
976
+ continue;
977
+ const content = msg.content || '';
978
+ const contentType = msg.messagetype || 'Text';
979
+ const fromMri = msg.from || '';
980
+ const displayName = msg.imdisplayname || msg.displayName;
981
+ // Safely extract timestamp - returns null if no valid timestamp found
982
+ const timestamp = extractActivityTimestamp(msg);
983
+ if (!timestamp)
984
+ continue;
985
+ // Get source conversation - prefer clumpId (actual source) over conversationid
986
+ // Some activity items have conversationid as "48:notifications" (the virtual conversation)
987
+ // which doesn't work for deep links. clumpId contains the real source conversation.
988
+ const rawConversationId = msg.conversationid || msg.conversationId;
989
+ const clumpId = msg.clumpId;
990
+ // Use clumpId if conversationid is a virtual conversation (48:xxx format)
991
+ const isVirtualConversation = rawConversationId?.startsWith(VIRTUAL_CONVERSATION_PREFIX);
992
+ const conversationId = (isVirtualConversation && clumpId) ? clumpId : rawConversationId;
993
+ const topic = msg.threadtopic || msg.topic;
994
+ // Build activity link if we have a valid source conversation context
995
+ // Skip virtual conversations (48:xxx) as they don't produce working deep links
996
+ let activityLink;
997
+ if (conversationId && !conversationId.startsWith(VIRTUAL_CONVERSATION_PREFIX) && /^\d+$/.test(id)) {
998
+ activityLink = buildMessageLink(conversationId, id);
999
+ }
1000
+ const activityType = detectActivityType(msg);
1001
+ // Extract links before stripping HTML
1002
+ const links = extractLinks(content);
1003
+ activities.push({
1004
+ id,
1005
+ type: activityType,
1006
+ content: stripHtml(content),
1007
+ contentType,
1008
+ sender: {
1009
+ mri: fromMri,
1010
+ displayName,
1011
+ },
1012
+ timestamp,
1013
+ conversationId,
1014
+ topic,
1015
+ activityLink,
1016
+ links: links.length > 0 ? links : undefined,
1017
+ });
1018
+ }
1019
+ // Sort by timestamp (newest first for activity feed)
1020
+ activities.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
1021
+ return ok({
1022
+ activities,
1023
+ syncState,
1024
+ });
1025
+ }
1026
+ /**
1027
+ * Adds a reaction (emoji) to a message.
1028
+ *
1029
+ * @param conversationId - The conversation containing the message
1030
+ * @param messageId - The message ID to react to
1031
+ * @param emojiKey - The emoji key (e.g., 'like', 'heart', 'laugh')
1032
+ */
1033
+ export async function addReaction(conversationId, messageId, emojiKey) {
1034
+ const authResult = requireMessageAuth();
1035
+ if (!authResult.ok) {
1036
+ return authResult;
1037
+ }
1038
+ const auth = authResult.value;
1039
+ const { region, baseUrl } = getApiConfig();
1040
+ const url = CHATSVC_API.messageEmotions(region, conversationId, messageId, baseUrl);
1041
+ const response = await httpRequest(url, {
1042
+ method: 'PUT',
1043
+ headers: {
1044
+ ...getTeamsHeaders(),
1045
+ 'Authentication': `skypetoken=${auth.skypeToken}`,
1046
+ 'Authorization': `Bearer ${auth.authToken}`,
1047
+ },
1048
+ body: JSON.stringify({
1049
+ emotions: {
1050
+ key: emojiKey,
1051
+ value: Date.now(),
1052
+ },
1053
+ }),
1054
+ });
1055
+ if (!response.ok) {
1056
+ return response;
1057
+ }
1058
+ return ok({
1059
+ conversationId,
1060
+ messageId,
1061
+ emoji: emojiKey,
1062
+ });
1063
+ }
1064
+ /**
1065
+ * Removes a reaction (emoji) from a message.
1066
+ *
1067
+ * @param conversationId - The conversation containing the message
1068
+ * @param messageId - The message ID to remove the reaction from
1069
+ * @param emojiKey - The emoji key to remove (e.g., 'like', 'heart')
1070
+ */
1071
+ export async function removeReaction(conversationId, messageId, emojiKey) {
1072
+ const authResult = requireMessageAuth();
1073
+ if (!authResult.ok) {
1074
+ return authResult;
1075
+ }
1076
+ const auth = authResult.value;
1077
+ const { region, baseUrl } = getApiConfig();
1078
+ const url = CHATSVC_API.messageEmotions(region, conversationId, messageId, baseUrl);
1079
+ const response = await httpRequest(url, {
1080
+ method: 'DELETE',
1081
+ headers: {
1082
+ ...getTeamsHeaders(),
1083
+ 'Authentication': `skypetoken=${auth.skypeToken}`,
1084
+ 'Authorization': `Bearer ${auth.authToken}`,
1085
+ },
1086
+ body: JSON.stringify({
1087
+ emotions: {
1088
+ key: emojiKey,
1089
+ },
1090
+ }),
1091
+ });
1092
+ if (!response.ok) {
1093
+ return response;
1094
+ }
1095
+ return ok({
1096
+ conversationId,
1097
+ messageId,
1098
+ emoji: emojiKey,
1099
+ });
1100
+ }